diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
| commit | 9818daebaf6fb99124a3196f880b3b8a7998d3a6 (patch) | |
| tree | 0b027703ff1ff164a89965723cfd9d5a3fee5081 /tests/FundLab.Api.Tests | |
| parent | 8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff) | |
| download | fund-lab-9818daebaf6fb99124a3196f880b3b8a7998d3a6.tar.gz | |
Add cash ledger event-source breakdown and reconciliation API/UI (3d-31 B4c)
Diffstat (limited to 'tests/FundLab.Api.Tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/CashReconciliationTests.fs | 254 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 |
2 files changed, 255 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/CashReconciliationTests.fs b/tests/FundLab.Api.Tests/CashReconciliationTests.fs new file mode 100644 index 0000000..59f6fd6 --- /dev/null +++ b/tests/FundLab.Api.Tests/CashReconciliationTests.fs @@ -0,0 +1,254 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Xunit +open FundLab.Api +open FundLab.Domain + +[<Collection("postgres")>] +type CashReconciliationTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + + let createFund (initialCash: decimal) = + let command = + { + Name = "现金对账测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "cash-recon-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "现金对账测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "cash-recon-test-hash") + code + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) = + let revision = sprintf "cash-recon-test/%O" (Guid.NewGuid()) + + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = DateTimeOffset.UtcNow.AddSeconds(-10.0) + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, sprintf "cash-recon-hash/%s" revision) + + let deposit fundId amount idempotencyKey = + match + repository().CreateCapitalDeposit( + idempotencyKey, + fundId, + { Amount = amount; Note = Some "追加" } + ) + with + | CapitalDepositWriteResult.CapitalDepositCreated _ + | CapitalDepositWriteResult.CapitalDepositReplayed _ -> () + | other -> failwithf "unexpected deposit result: %A" other + + let buyStockDebit fundId code quantity price idempotencyKey = + match + repository().CreateStockTrade( + idempotencyKey, + fundId, + { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price }, + debitAvailableCash = true + ) + with + | StockTradeWriteResult.StockTradeCreated _ + | StockTradeWriteResult.StockTradeReplayed _ -> () + | other -> failwithf "unexpected stock trade result: %A" other + + let sellStock fundId code quantity price idempotencyKey = + match + repository().CreateStockSell( + idempotencyKey, + fundId, + { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price; FeeAmount = 0m } + ) + with + | StockSellWriteResult.StockSellCreated _ + | StockSellWriteResult.StockSellReplayed _ -> () + | other -> failwithf "unexpected stock sell result: %A" other + + let buyBond fundId code quantity price = + match + repository().CreateBondTrade( + fixture.Key(sprintf "cash-recon-bond-%s" (Guid.NewGuid().ToString("N"))), + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = price + CleanPrice = price + AccruedInterest = 0m + ParValue = 100m + SettlementDate = today + CouponRate = None + ValueDate = None + MaturityDate = None + TradeDate = Some today + } + ) + with + | BondTradeWriteResult.BondTradeCreated _ -> () + | other -> failwithf "unexpected bond trade result: %A" other + + let recordBondCoupon fundId code amount idempotencyKey = + match + repository().RecordBondCashflow( + idempotencyKey, + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + EventType = "coupon" + EventDate = today + Quantity = 10m + Amount = amount + Note = None + } + ) + with + | BondCashflowWriteResult.BondCashflowCreated _ + | BondCashflowWriteResult.BondCashflowReplayed _ -> () + | other -> failwithf "unexpected bond cashflow result: %A" other + + let subscribe fundId code amount idempotencyKey = + insertQuoteOnDate code 1.0m today + + let order = + match + repository().CreateSubscriptionOrder( + idempotencyKey, + fundId, + { FundCode = code; Amount = amount; FeeAmount = 0m }, + today + ) + with + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> order + | other -> failwithf "unexpected subscription order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key(sprintf "cash-recon-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed _ -> () + | other -> failwithf "unexpected subscription confirm: %A" other + + let app () = App.createApplication (repository ()) + + let reconciliation fundId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/cash-reconciliation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let sourceEntry (body: string) (source: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("sources").EnumerateArray() + |> Seq.find (fun entry -> entry.GetProperty("source").GetString() = source) + |> fun entry -> entry.Clone() + + let eventCount (body: string) (source: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("events").EnumerateArray() + |> Seq.filter (fun entry -> entry.GetProperty("source").GetString() = source) + |> Seq.length + + [<Fact>] + member _.``cash reconciliation balances across every source category``() = + let fundId = createFund 10000.00m + deposit fundId 5000.00m (fixture.Key "cash-recon-deposit") + buyStockDebit fundId "600519" 100m 10.00m (fixture.Key "cash-recon-buy") + sellStock fundId "600519" 40m 10.00m (fixture.Key "cash-recon-sell") + buyBond fundId "110075" 10m 100.00m + recordBondCoupon fundId "110075" 50.00m (fixture.Key "cash-recon-coupon") + subscribe fundId (seedInstrument ()) 1000.00m (fixture.Key "cash-recon-subscribe") + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"openingCash\":\"10000.00\"", body) + Assert.Contains("\"netInflow\":\"3450.00\"", body) + Assert.Contains("\"closingCash\":\"13450.00\"", body) + Assert.Contains("\"ledgerBalance\":\"13450.00\"", body) + Assert.Contains("\"difference\":\"0.00\"", body) + + Assert.Equal("5000.00", (sourceEntry body "capital_deposit").GetProperty("netAmount").GetString()) + Assert.Equal("-1000.00", (sourceEntry body "stock_buy").GetProperty("netAmount").GetString()) + Assert.Equal("400.00", (sourceEntry body "stock_sell").GetProperty("netAmount").GetString()) + Assert.Equal("50.00", (sourceEntry body "bond_coupon").GetProperty("netAmount").GetString()) + Assert.Equal("-1000.00", (sourceEntry body "subscription").GetProperty("netAmount").GetString()) + + // every category reconciles to zero, i.e. no unexplained cash movement + Assert.Equal(0.00m, (repository().GetCashReconciliation fundId |> Result.defaultWith failwith).Difference) + + [<Fact>] + member _.``replayed operations do not double count ledger events``() = + let fundId = createFund 1000.00m + let depositKey = fixture.Key "cash-recon-idem-deposit" + let buyKey = fixture.Key "cash-recon-idem-buy" + + deposit fundId 500.00m depositKey + deposit fundId 500.00m depositKey + buyStockDebit fundId "600519" 100m 10.00m buyKey + buyStockDebit fundId "600519" 100m 10.00m buyKey + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"difference\":\"0.00\"", body) + Assert.Contains("\"ledgerBalance\":\"500.00\"", body) // 1000 + 500 - 1000 + + Assert.Equal(1, eventCount body "capital_deposit") + Assert.Equal(1, eventCount body "stock_buy") + + [<Fact>] + member _.``reconciliation for a missing fund answers 404``() = + let status, body = reconciliation (Guid.NewGuid()) + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index f37dce4..66cc2aa 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -43,6 +43,7 @@ <Compile Include="SipPlanStatusTests.fs" /> <Compile Include="RebalanceArchiveTests.fs" /> <Compile Include="RebalanceStockTests.fs" /> + <Compile Include="CashReconciliationTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> |
