feat: Phase 3 J/K/L (Sell Decision, Trade Execution, Portfolio Reconciliation) + fix pre-existing build/boot breakage
Completes VS-10/VS-12/VS-14 and makes the solution and Host actually
build and boot for the first time on this branch (main did not build
before this commit).
Root-cause fixes required to reach a green build/boot (not scoped to
J/K/L but blocking any verification of it):
- Restore Polly PackageVersion accidentally deleted from
Directory.Packages.props (broke KArtSell.Host).
- Remove MediatR dependency from Compliance/VS-04 (package was never
installed; ICommand/ICommandHandler/IMediator never existed) and
wire Endpoint -> Handler directly per this repo's convention.
- Migrate FastEndpoints v5 API calls (SendOkAsync/SendAsync/
SendCreatedAtAsync/SendNotFoundAsync, Description().WithName()) to
the v7 Send.* fluent API across ~10 endpoint files.
- Fix migrations 0036/0038/0039/0040: rewritten from invalid T-SQL
(`IF NOT EXISTS ... BEGIN ... END`) to idiomatic Postgres
(`CREATE TABLE/INDEX IF NOT EXISTS`) — these could not apply to any
fresh database before this fix.
- Collapse 3 duplicate cross-cutting abstractions that shadowed the
BuildingBlocks versions and caused type-mismatch compile errors:
IKrxDataService, IOutboxWriter (ReconcileTradeHandler), IClock
(ApprovalWorkflow/ApprovalPolicy).
- Inject IClock (BuildingBlocks.Time) in place of direct
DateTime.Now/UtcNow across 19 files to satisfy the architecture
test AGENTS.md#DateTime-abstraction rule (13/13 architecture tests
now pass, was 12/13).
- Register all new and previously-unregistered slices in
Program.cs DI (SellDecision, TradeExecution, PortfolioReconciliation,
Compliance, Features/ApprovalWorkflow) — the Host had never
successfully completed a boot with this code present.
- Disable ("[DontRegister]") the older, route-colliding
ApprovalWorkflow/ (Workstream H) endpoint set in favor of
Features/ApprovalWorkflow/ (Workstream G, matches the documented
Features/<Slice>/ convention); kept for its existing test coverage.
See TECH_DEBT-017 for the follow-up decision needed.
Verified: dotnet build 0 errors/0 warnings; architecture tests 13/13;
unit tests 54/54 + 18/18; integration tests 34/36 (2 failures are a
local test-DB migration-journal/schema mismatch, not a code defect);
Host boots cleanly and registers all 34 endpoints.
New tech debt recorded: DEBT-017 (duplicate VS-03 implementation),
DEBT-018 (outbox write not co-transactional with entity write in
TradeExecution/PortfolioReconciliation), DEBT-019 (duplicate
BuildingBlocks-shadowing abstractions, partially resolved).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+323
@@ -0,0 +1,323 @@
|
||||
namespace KArtSell.Integration.Tests.PortfolioReconciliation;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using KArtSell.Modules.ModelOperations.PortfolioReconciliation;
|
||||
using Xunit;
|
||||
|
||||
public class ReconciliationEngineTests
|
||||
{
|
||||
private readonly CostBasisCalculator _costCalc;
|
||||
private readonly MismatchDetector _mismatchDetector;
|
||||
|
||||
public ReconciliationEngineTests()
|
||||
{
|
||||
_costCalc = new CostBasisCalculator();
|
||||
_mismatchDetector = new MismatchDetector();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateWeightedAverageCost_BuyFirst_Success()
|
||||
{
|
||||
// Arrange
|
||||
int previousQuantity = 0;
|
||||
decimal previousCostBasis = 0m;
|
||||
int buyQuantity = 100;
|
||||
decimal buyPrice = 150m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateWeightedAverageCost(
|
||||
previousQuantity, previousCostBasis, buyQuantity, buyPrice);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(150m, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateWeightedAverageCost_SecondBuy_Success()
|
||||
{
|
||||
// Arrange
|
||||
int previousQuantity = 100;
|
||||
decimal previousCostBasis = 15000m; // 100 * 150
|
||||
int buyQuantity = 50;
|
||||
decimal buyPrice = 160m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateWeightedAverageCost(
|
||||
previousQuantity, previousCostBasis, buyQuantity, buyPrice);
|
||||
|
||||
// Assert
|
||||
var expected = (15000m + (50 * 160m)) / 150m; // (15000 + 8000) / 150 = 153.33
|
||||
Assert.Equal(expected, result, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRealizedGainLoss_Profit_Success()
|
||||
{
|
||||
// Arrange
|
||||
int sellQuantity = 100;
|
||||
decimal sellPrice = 160m;
|
||||
decimal weightedAverageCost = 150m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateRealizedGainLoss(
|
||||
sellQuantity, sellPrice, weightedAverageCost);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1000m, result); // (160 - 150) * 100 = 1000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRealizedGainLoss_Loss_Success()
|
||||
{
|
||||
// Arrange
|
||||
int sellQuantity = 100;
|
||||
decimal sellPrice = 140m;
|
||||
decimal weightedAverageCost = 150m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateRealizedGainLoss(
|
||||
sellQuantity, sellPrice, weightedAverageCost);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-1000m, result); // (140 - 150) * 100 = -1000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateUnrealizedGainLoss_Profit_Success()
|
||||
{
|
||||
// Arrange
|
||||
decimal marketValue = 18000m;
|
||||
decimal totalCostBasis = 15000m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateUnrealizedGainLoss(marketValue, totalCostBasis);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3000m, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateUnrealizedGainLoss_Loss_Success()
|
||||
{
|
||||
// Arrange
|
||||
decimal marketValue = 12000m;
|
||||
decimal totalCostBasis = 15000m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.CalculateUnrealizedGainLoss(marketValue, totalCostBasis);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-3000m, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllocateLotsFifo_Success()
|
||||
{
|
||||
// Arrange
|
||||
var lots = new List<Lot>
|
||||
{
|
||||
new Lot { Id = Guid.NewGuid(), Quantity = 50, UnitCost = 100m, FifoOrder = 1 },
|
||||
new Lot { Id = Guid.NewGuid(), Quantity = 100, UnitCost = 110m, FifoOrder = 2 }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _costCalc.AllocateLotsFifo(lots, 120);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(50, result[0].Quantity);
|
||||
Assert.Equal(70, result[1].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllocateLotsFifo_InsufficientQuantity_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var lots = new List<Lot>
|
||||
{
|
||||
new Lot { Id = Guid.NewGuid(), Quantity = 50, UnitCost = 100m, FifoOrder = 1 }
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
_costCalc.AllocateLotsFifo(lots, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectQuantityVariance_NoVariance_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var mismatches = _mismatchDetector.DetectMismatches(
|
||||
approvedQuantity: 100,
|
||||
executedQuantity: 100,
|
||||
approvedPrice: 150m,
|
||||
executedPrice: 150m,
|
||||
tradeDate: DateTime.UtcNow,
|
||||
expectedSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
actualSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
ledgerCostBasis: 15000m,
|
||||
calculatedCostBasis: 15000m,
|
||||
now: DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectQuantityVariance_VarianceDetected_ReturnsMismatch()
|
||||
{
|
||||
// Arrange
|
||||
// 100 -> 99 = 1% variance (exceeds 0.1% threshold)
|
||||
var mismatches = _mismatchDetector.DetectMismatches(
|
||||
approvedQuantity: 100,
|
||||
executedQuantity: 99,
|
||||
approvedPrice: 150m,
|
||||
executedPrice: 150m,
|
||||
tradeDate: DateTime.UtcNow,
|
||||
expectedSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
actualSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
ledgerCostBasis: 15000m,
|
||||
calculatedCostBasis: 14850m,
|
||||
now: DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(mismatches);
|
||||
var quantityMismatch = mismatches.FirstOrDefault(m => m.Type == MismatchType.QuantityVariance);
|
||||
Assert.NotNull(quantityMismatch);
|
||||
Assert.Equal(MismatchSeverity.High, quantityMismatch.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectPriceVariance_VarianceDetected_ReturnsMismatch()
|
||||
{
|
||||
// Arrange
|
||||
// 150 -> 153 = 2% variance (exceeds 2% threshold = at boundary)
|
||||
// Actually 150 -> 153.1 = 2.07% (exceeds)
|
||||
var mismatches = _mismatchDetector.DetectMismatches(
|
||||
approvedQuantity: 100,
|
||||
executedQuantity: 100,
|
||||
approvedPrice: 150m,
|
||||
executedPrice: 153.1m, // 2.07%
|
||||
tradeDate: DateTime.UtcNow,
|
||||
expectedSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
actualSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
ledgerCostBasis: 15000m,
|
||||
calculatedCostBasis: 15310m,
|
||||
now: DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(mismatches);
|
||||
var priceMismatch = mismatches.FirstOrDefault(m => m.Type == MismatchType.PriceVariance);
|
||||
Assert.NotNull(priceMismatch);
|
||||
Assert.Equal(MismatchSeverity.Medium, priceMismatch.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectSettlementDelay_DelayDetected_ReturnsMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDate = DateTime.UtcNow.AddDays(-1);
|
||||
var actualDate = DateTime.UtcNow.AddDays(2); // 3 days late
|
||||
|
||||
var mismatches = _mismatchDetector.DetectMismatches(
|
||||
approvedQuantity: 100,
|
||||
executedQuantity: 100,
|
||||
approvedPrice: 150m,
|
||||
executedPrice: 150m,
|
||||
tradeDate: DateTime.UtcNow.AddDays(-5),
|
||||
expectedSettlementDate: expectedDate,
|
||||
actualSettlementDate: actualDate,
|
||||
ledgerCostBasis: 15000m,
|
||||
calculatedCostBasis: 15000m,
|
||||
now: DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(mismatches);
|
||||
var timingMismatch = mismatches.FirstOrDefault(m => m.Type == MismatchType.SettlementDelay);
|
||||
Assert.NotNull(timingMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCostBasisMismatch_MismatchDetected_ReturnsMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var mismatches = _mismatchDetector.DetectMismatches(
|
||||
approvedQuantity: 100,
|
||||
executedQuantity: 100,
|
||||
approvedPrice: 150m,
|
||||
executedPrice: 150m,
|
||||
tradeDate: DateTime.UtcNow,
|
||||
expectedSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
actualSettlementDate: DateTime.UtcNow.AddDays(2),
|
||||
ledgerCostBasis: 15000.00m,
|
||||
calculatedCostBasis: 14999.50m, // $0.50 delta
|
||||
now: DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(mismatches);
|
||||
var costMismatch = mismatches.FirstOrDefault(m => m.Type == MismatchType.CostBasisMismatch);
|
||||
Assert.NotNull(costMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresEscalation_HighSeverity_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var mismatches = new List<Mismatch>
|
||||
{
|
||||
new Mismatch { Severity = MismatchSeverity.High }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mismatchDetector.RequiresEscalation(mismatches);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresEscalation_MediumOnly_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var mismatches = new List<Mismatch>
|
||||
{
|
||||
new Mismatch { Severity = MismatchSeverity.Medium }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mismatchDetector.RequiresEscalation(mismatches);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCostBasis_Correct_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
decimal calculated = 15000.00m;
|
||||
decimal expected = 15000.01m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.VerifyCostBasis(calculated, expected, tolerance: 0.05m);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCostBasis_OutOfTolerance_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
decimal calculated = 15000.00m;
|
||||
decimal expected = 14999.50m;
|
||||
|
||||
// Act
|
||||
var result = _costCalc.VerifyCostBasis(calculated, expected, tolerance: 0.1m);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user