diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 22:59:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 22:59:00 +0800 |
| commit | c60905e9e7f992a7f8c79c3812e92a44b2d606f5 (patch) | |
| tree | a895bc6f0ebe669407ffe4d1a11439e03a6fe0d4 /tests/FundLab.Domain.Tests/DomainTests.fs | |
| download | fund-lab-c60905e9e7f992a7f8c79c3812e92a44b2d606f5.tar.gz | |
feat(core): 建立 fund-lab 可运行基线
[变更性质]
- 本提交冻结当前可构建、可测试的应用基线,不包含 PostgreSQL 持久化。
[新增功能]
- 建立 F# Domain、API、Worker、Web 及测试项目。
- 增加 Bearer 认证、健康检查、账本领域模型和中文空状态页面。
[实现方案]
- 使用环境变量模板注入认证配置,并排除数据、凭证和构建产物。
- 保留 19 个 Domain 测试和 5 个 API 测试作为后续变更基准。
[影响范围]
- 为后续 3a PostgreSQL FOF 创建/读取切片提供可回滚基线。
- 当前仍不接入真实基金数据、真实交易或数据库。
Diffstat (limited to 'tests/FundLab.Domain.Tests/DomainTests.fs')
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 493 |
1 files changed, 493 insertions, 0 deletions
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs new file mode 100644 index 0000000..f2b4138 --- /dev/null +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -0,0 +1,493 @@ +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 + + [<Fact>] + 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) + + [<Fact>] + let ``pending order is not a confirmed holding`` () = + Assert.False(OrderStatus.isConfirmed OrderStatus.PendingNav) + Assert.True(OrderStatus.isConfirmed OrderStatus.Settled) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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)) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) |
