namespace FundLab.Api.Tests open System open System.Text.Json open Xunit open FundLab.Api open FundLab.Domain [] 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 [] 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) [] 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") [] member _.``reconciliation for a missing fund answers 404``() = let status, body = reconciliation (Guid.NewGuid()) Assert.Equal(404, status) Assert.Contains("FUND_NOT_FOUND", body)