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)