b1e38ac374
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>
324 lines
9.4 KiB
C#
324 lines
9.4 KiB
C#
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);
|
|
}
|
|
}
|