namespace FundLab.Domain.Tests module DomainTests = open System open Xunit open FundLab.Domain let private unwrap result = match result with | Ok value -> value | Error error -> failwithf "%A" error [] let ``new fund retains initial cash and starts with zero units`` () = let fund = Fund.create "退休账户" 1000m 1.00000000m Assert.Equal("退休账户", fund.Name) Assert.Equal(1000m, fund.NetAssets) Assert.Equal(0m, fund.OutstandingUnits) Assert.Equal(FundStatus.Empty, fund.Status) Assert.Equal(UnitNavStatus.ZeroUnits, Fund.unitNavStatus fund) [] let ``pending order is not a confirmed holding`` () = Assert.False(OrderStatus.isConfirmed OrderStatus.PendingNav) Assert.True(OrderStatus.isConfirmed OrderStatus.Settled) [] let ``external redemption creates a payable without changing user holdings`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000001") let depositId = Guid.Parse("00000000-0000-0000-0000-000000000011") let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000012") let at = DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "退休账户" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 25.00000000m 1.00000000m at true |> unwrap let fund = Ledger.getFund fundId state |> unwrap Assert.Equal(75.00000000m, fund.OutstandingUnits) Assert.Equal(100.00m, fund.AvailableCash) Assert.Equal(25.00m, fund.balanceSheet.RedemptionPayable) Assert.Equal(75.00m, fund.balanceSheet.NetAssets) [] let ``external redemption payment is idempotent and conflicts are rejected`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000002") let depositId = Guid.Parse("00000000-0000-0000-0000-000000000021") let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000022") let at = DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero) let deposited = Ledger.empty |> Ledger.initializeFund fundId "支付测试" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap let redeemed = deposited |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 25.00000000m 1.00000000m at true |> unwrap let paid = redeemed |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true |> unwrap let replayed = redeemed |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true |> unwrap let conflict = paid |> Ledger.payExternalRedemption fundId redemptionId "payment-1" (at.AddMinutes 1.0) true let fund = Ledger.getFund fundId paid |> unwrap Assert.Equal(paid, replayed) Assert.Equal(Error(IdempotencyConflict "payment-1"), conflict) Assert.Equal(75.00m, fund.AvailableCash) Assert.Equal(0.00m, fund.balanceSheet.RedemptionPayable) Assert.Equal(75.00m, fund.balanceSheet.NetAssets) [] let ``underlying purchase freezes then confirms cash and holding`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000003") let depositId = Guid.Parse("00000000-0000-0000-0000-000000000031") let orderId = Guid.Parse("00000000-0000-0000-0000-000000000032") let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000033") let at = DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero) let deposited = Ledger.empty |> Ledger.initializeFund fundId "底层申购" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap let frozen = deposited |> Ledger.freezeUnderlyingPurchase fundId orderId "purchase-1" instrumentId 60.00m at true |> unwrap let frozenFund = Ledger.getFund fundId frozen |> unwrap Assert.Equal(40.00m, frozenFund.AvailableCash) Assert.Equal(60.00m, frozenFund.FrozenCash) Assert.Equal(100.00m, frozenFund.balanceSheet.NetAssets) let confirmed = frozen |> Ledger.confirmUnderlyingPurchase fundId orderId "purchase-confirm-1" 20.00000000m 3.00000000m at true |> unwrap let fund = Ledger.getFund fundId confirmed |> unwrap let holding = fund.Holdings |> Map.find instrumentId let order = fund.Orders |> Map.find orderId Assert.Equal(40.00m, fund.AvailableCash) Assert.Equal(0.00m, fund.FrozenCash) Assert.Equal(20.00000000m, holding.AvailableUnits) Assert.Equal(60.00m, holding.SettledValue) Assert.Equal(OrderStatus.Confirmed, order.Status) Assert.Equal(100.00m, fund.balanceSheet.NetAssets) [] let ``cancelled underlying purchase releases its frozen cash`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000004") let depositId = Guid.Parse("00000000-0000-0000-0000-000000000041") let orderId = Guid.Parse("00000000-0000-0000-0000-000000000042") let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000043") let at = DateTimeOffset(2026, 1, 4, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "取消申购" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap |> Ledger.freezeUnderlyingPurchase fundId orderId "purchase-1" instrumentId 60.00m at true |> unwrap |> Ledger.cancelUnderlyingPurchase fundId orderId "purchase-cancel-1" at true |> unwrap let fund = Ledger.getFund fundId state |> unwrap let order = fund.Orders |> Map.find orderId Assert.Equal(100.00m, fund.AvailableCash) Assert.Equal(0.00m, fund.FrozenCash) Assert.Equal(OrderStatus.Cancelled, order.Status) Assert.Empty(fund.Holdings) [] let ``underlying redemption uses a receivable until cash is received`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000005") let depositId = Guid.Parse("00000000-0000-0000-0000-000000000051") let purchaseId = Guid.Parse("00000000-0000-0000-0000-000000000052") let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000053") let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000054") let at = DateTimeOffset(2026, 1, 5, 0, 0, 0, TimeSpan.Zero) let purchased = Ledger.empty |> Ledger.initializeFund fundId "底层赎回" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap |> Ledger.freezeUnderlyingPurchase fundId purchaseId "purchase-1" instrumentId 60.00m at true |> unwrap |> Ledger.confirmUnderlyingPurchase fundId purchaseId "purchase-confirm-1" 20.00000000m 3.00000000m at true |> unwrap |> Ledger.freezeUnderlyingRedemption fundId redemptionId "redemption-1" instrumentId 20.00000000m at true |> unwrap let confirmed = purchased |> Ledger.confirmUnderlyingRedemption fundId redemptionId "redemption-confirm-1" 3.00000000m at true |> unwrap let beforeReceipt = Ledger.getFund fundId confirmed |> unwrap Assert.Equal(40.00m, beforeReceipt.AvailableCash) Assert.Equal(60.00m, beforeReceipt.balanceSheet.RedemptionReceivable) Assert.Equal(0.00m, beforeReceipt.balanceSheet.OtherInTransitAssets) Assert.Equal(100.00m, beforeReceipt.balanceSheet.NetAssets) Assert.Equal(100.00000000m, beforeReceipt.OutstandingUnits) let received = confirmed |> Ledger.receiveUnderlyingRedemption fundId redemptionId "redemption-receipt-1" at true |> unwrap let fund = Ledger.getFund fundId received |> unwrap let order = fund.Orders |> Map.find redemptionId Assert.Equal(100.00m, fund.AvailableCash) Assert.Equal(0.00m, fund.balanceSheet.RedemptionReceivable) Assert.Equal(0.00m, fund.balanceSheet.OtherInTransitAssets) Assert.Equal(OrderStatus.Settled, order.Status) Assert.Equal(100.00m, fund.balanceSheet.NetAssets) [] let ``operations keep two funds isolated and synthetic provenance explicit`` () = let fundA = Guid.Parse("00000000-0000-0000-0000-000000000006") let fundB = Guid.Parse("00000000-0000-0000-0000-000000000007") let at = DateTimeOffset(2026, 1, 6, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundA "基金A" 0m 1.00000000m true |> unwrap |> Ledger.initializeFund fundB "基金B" 25.00m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundA (Guid.NewGuid()) "fund-a-deposit" 100.00m 1.00000000m at true |> unwrap let first = Ledger.getFund fundA state |> unwrap let second = Ledger.getFund fundB state |> unwrap Assert.Equal(100.00m, first.AvailableCash) Assert.Equal(25.00m, second.AvailableCash) Assert.True(first.IsSynthetic) Assert.True(second.IsSynthetic) Assert.All(first.Events, fun event -> Assert.True(event.IsSynthetic)) Assert.All(first.CashFlows, fun flow -> Assert.True(flow.IsSynthetic)) [] let ``precision and residual ownership are explicit`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000008") let at = DateTimeOffset(2026, 1, 7, 0, 0, 0, TimeSpan.Zero) let depositId = Guid.Parse("00000000-0000-0000-0000-000000000081") let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000082") let initialized = Ledger.empty |> Ledger.initializeFund fundId "残差基金" 0m 1.00000000m true |> unwrap let badPrecision = initialized |> Ledger.confirmExternalDeposit fundId depositId "bad-precision" 1.001m 1.00000000m at true let afterDeposit = initialized |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 1.00m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 1.00000000m 1.00500000m at true |> unwrap let fund = Ledger.getFund fundId afterDeposit |> unwrap let blocked = afterDeposit |> Ledger.confirmExternalDeposit fundId (Guid.NewGuid()) "deposit-2" 1.00m 1.00000000m at true let allocated = afterDeposit |> Ledger.allocateResidual fundId (Guid.Parse("00000000-0000-0000-0000-000000000083")) "residual-allocation-1" ResidualOwner.FundReserve at true |> unwrap |> Ledger.confirmExternalDeposit fundId (Guid.NewGuid()) "deposit-3" 1.00m 1.00000000m at true |> unwrap let allocatedFund = Ledger.getFund fundId allocated |> unwrap Assert.Equal(Error(InvalidPrecision "external deposit cash"), badPrecision) Assert.Equal(ResidualState.RequiresAllocation 0.00500000m, fund.ResidualState) Assert.Equal(Error(ResidualOwnershipRequired 0.00500000m), blocked) Assert.Equal(ResidualState.Allocated(0.00500000m, ResidualOwner.FundReserve), allocatedFund.ResidualState) Assert.Equal(1, List.length allocatedFund.ResidualAllocations) let residualEvent = allocatedFund.Events |> List.find (fun (event: LedgerEvent) -> event.EventType = "residual_allocated") Assert.Equal(Some ResidualOwner.FundReserve, residualEvent.ResidualOwner) Assert.Equal(2.00m, allocatedFund.AvailableCash) [] let ``residual allocation requires zero outstanding units`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000B") let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000B1") let redemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000B2") let allocationId = Guid.Parse("00000000-0000-0000-0000-0000000000B3") let at = DateTimeOffset(2026, 1, 9, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "残差未清" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 1.00m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 0.50000000m 1.00500000m at true |> unwrap let result = state |> Ledger.allocateResidual fundId allocationId "residual-allocation-1" ResidualOwner.FundReserve at true Assert.Equal(Error(InvalidState "residual allocation requires zero outstanding units"), result) [] let ``external redemption payment settles only the referenced redemption`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000C") let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000C1") let firstRedemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000C2") let secondRedemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000C3") let at = DateTimeOffset(2026, 1, 10, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "逐笔付款" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId firstRedemptionId "redemption-1" 10.00000000m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId secondRedemptionId "redemption-2" 20.00000000m 1.00000000m at true |> unwrap |> Ledger.payExternalRedemption fundId firstRedemptionId "payment-1" at true |> unwrap let fund = Ledger.getFund fundId state |> unwrap let first = fund.ExternalRedemptions |> Map.find firstRedemptionId let second = fund.ExternalRedemptions |> Map.find secondRedemptionId let unknownPayment = state |> Ledger.payExternalRedemption fundId (Guid.Parse("00000000-0000-0000-0000-0000000000CF")) "payment-unknown" at true Assert.Equal(20.00m, fund.RedemptionPayable) Assert.Equal(ExternalRedemptionStatus.Paid, first.Status) Assert.Equal(ExternalRedemptionStatus.PendingPayment, second.Status) Assert.Equal(Error(ExternalRedemptionNotFound(Guid.Parse("00000000-0000-0000-0000-0000000000CF"))), unknownPayment) [] let ``empty idempotency keys and identifiers are rejected`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000D") let transactionId = Guid.Parse("00000000-0000-0000-0000-0000000000D1") let at = DateTimeOffset(2026, 1, 11, 0, 0, 0, TimeSpan.Zero) let initialized = Ledger.empty |> Ledger.initializeFund fundId "输入校验" 0m 1.00000000m true |> unwrap let emptyKey = initialized |> Ledger.confirmExternalDeposit fundId transactionId " " 1.00m 1.00000000m at true let emptyTransaction = initialized |> Ledger.confirmExternalDeposit fundId Guid.Empty "deposit-1" 1.00m 1.00000000m at true let emptyOrder = initialized |> Ledger.freezeUnderlyingPurchase fundId Guid.Empty "purchase-1" (Guid.NewGuid()) 1.00m at true Assert.Equal(Error(InvalidState "idempotency key cannot be empty"), emptyKey) Assert.Equal(Error(InvalidIdentifier "transaction id"), emptyTransaction) Assert.Equal(Error(InvalidIdentifier "order id"), emptyOrder) [] let ``duplicate transaction identifiers are rejected`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000E") let transactionId = Guid.Parse("00000000-0000-0000-0000-0000000000E1") let at = DateTimeOffset(2026, 1, 12, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "交易唯一" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId transactionId "deposit-1" 1.00m 1.00000000m at true |> unwrap let duplicate = state |> Ledger.confirmExternalDeposit fundId transactionId "deposit-2" 1.00m 1.00000000m at true Assert.Equal(Error(TransactionAlreadyExists transactionId), duplicate) [] let ``time weighted return excludes external additional capital`` () = let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) let observations = [ { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } { At = at 2; NetAssets = 200.00m; ExternalCashFlow = 100.00m } ] let result = Performance.timeWeightedReturn observations |> unwrap Assert.Equal(0.00m, result) [] let ``invalid fund and reservation operations return typed errors`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-000000000009") let missingFund = Ledger.getFund fundId Ledger.empty let initialized = Ledger.empty |> Ledger.initializeFund fundId "错误路径" 0m 1.00000000m true |> unwrap let duplicate = initialized |> Ledger.initializeFund fundId "重复基金" 0m 1.00000000m true let insufficientCash = initialized |> Ledger.freezeUnderlyingPurchase fundId (Guid.NewGuid()) "purchase-1" (Guid.NewGuid()) 1.00m (DateTimeOffset.UtcNow) true let insufficientUnits = initialized |> Ledger.confirmExternalRedemption fundId (Guid.NewGuid()) "redemption-1" 1.00000000m 1.00000000m (DateTimeOffset.UtcNow) true Assert.Equal(Error(FundNotFound fundId), missingFund) Assert.Equal(Error(FundAlreadyExists fundId), duplicate) Assert.Equal(Error(InsufficientCash 1.00m), insufficientCash) Assert.Equal(Error(InsufficientUnits 1.00000000m), insufficientUnits) [] let ``performance observations must be chronological`` () = let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) let observations = [ { At = at 2; NetAssets = 100.00m; ExternalCashFlow = 0.00m } { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } ] let result = Performance.timeWeightedReturn observations Assert.Equal(Error(InvalidObservation "observations must be strictly ordered"), result) [] let ``external redemption payment cannot spend frozen or invested cash`` () = let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000A") let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000A1") let purchaseId = Guid.Parse("00000000-0000-0000-0000-0000000000A2") let instrumentId = Guid.Parse("00000000-0000-0000-0000-0000000000A3") let redemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000A4") let at = DateTimeOffset(2026, 1, 8, 0, 0, 0, TimeSpan.Zero) let state = Ledger.empty |> Ledger.initializeFund fundId "现金不足" 0m 1.00000000m true |> unwrap |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true |> unwrap |> Ledger.freezeUnderlyingPurchase fundId purchaseId "purchase-1" instrumentId 100.00m at true |> unwrap |> Ledger.confirmUnderlyingPurchase fundId purchaseId "purchase-confirm-1" 100.00000000m 1.00000000m at true |> unwrap |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 50.00000000m 1.00000000m at true |> unwrap let payment = state |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true Assert.Equal(Error(InsufficientCash 50.00m), payment) [] let ``time weighted return distinguishes investment growth from withdrawals`` () = let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) let growth = [ { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } { At = at 2; NetAssets = 110.00m; ExternalCashFlow = 0.00m } ] |> Performance.timeWeightedReturn |> unwrap let withdrawal = [ { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } { At = at 2; NetAssets = 50.00m; ExternalCashFlow = -50.00m } ] |> Performance.timeWeightedReturn |> unwrap Assert.Equal(0.10m, growth) Assert.Equal(0.00m, withdrawal) [] let ``single invalid performance observations are rejected`` () = let at = DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero) let negative = Performance.timeWeightedReturn [ { At = at; NetAssets = -1.00m; ExternalCashFlow = 0.00m } ] let zero = Performance.timeWeightedReturn [ { At = at; NetAssets = 0.00m; ExternalCashFlow = 0.00m } ] Assert.Equal(Error(InvalidObservation "net assets cannot be negative"), negative) Assert.Equal(Error(InvalidObservation "first net assets must be positive"), zero) module RedemptionPolicyTests = open Xunit open FundLab.Domain let private unwrap result = match result with | Ok value -> value | Error error -> failwithf "%A" error [] let ``redemption request above available units is rejected`` () = Assert.Equal(Error "requested units exceed available holdings", RedemptionPolicy.validateRequest 10.00000000m 10.00000001m 0.00m) Assert.Equal(Error "redemption units must be positive", RedemptionPolicy.validateRequest 10.00000000m 0m 0.00m) Assert.Equal(Error "redemption fee cannot be negative", RedemptionPolicy.validateRequest 10.00000000m 5.00000000m -0.01m) Assert.Equal(Ok(), RedemptionPolicy.validateRequest 10.00000000m 10.00000000m 1.50m) [] let ``full redemption releases the entire position cost`` () = let computed = RedemptionPolicy.compute 40.00000000m 2.5m 1.00m 100.00m 40.00000000m |> unwrap Assert.Equal(100.00m, computed.GrossCash) Assert.Equal(1.00m, computed.Fee) Assert.Equal(99.00m, computed.Proceeds) Assert.Equal(100.00m, computed.RedeemedCost) [] let ``partial redemption writes off cost pro rata rounded down`` () = let computed = RedemptionPolicy.compute 10.00000000m 2.5m 0.00m 100.00m 40.00000000m |> unwrap Assert.Equal(25.00m, computed.GrossCash) Assert.Equal(0.00m, computed.Fee) Assert.Equal(25.00m, computed.Proceeds) Assert.Equal(25.00m, computed.RedeemedCost) let uneven = RedemptionPolicy.compute 1.00000000m 3m 0.00m 100.00m 3.00000000m |> unwrap Assert.Equal(3.00m, uneven.GrossCash) Assert.Equal(33.33m, uneven.RedeemedCost) [] let ``redemption computation rejects invalid economics`` () = Assert.Equal(Error "redemption units exceed position units", RedemptionPolicy.compute 41.00000000m 2.5m 0.00m 100.00m 40.00000000m) Assert.Equal(Error "unit nav must be positive", RedemptionPolicy.compute 1.00000000m 0m 0.00m 100.00m 40.00000000m) Assert.Equal(Error "redemption fee exceeds redemption value", RedemptionPolicy.compute 1.00000000m 2.5m 3.00m 100.00m 40.00000000m) Assert.Equal(Error "redemption proceeds rounds to zero", RedemptionPolicy.compute 0.00000001m 1500000m 0.01m 100.00m 40.00000000m) Assert.Equal(Error "redemption cash rounds to zero", RedemptionPolicy.compute 0.00000001m 2m 0.00m 100.00m 40.00000000m)