summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 11:08:05 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 11:08:05 +0800
commit9818daebaf6fb99124a3196f880b3b8a7998d3a6 (patch)
tree0b027703ff1ff164a89965723cfd9d5a3fee5081 /tests/FundLab.Api.Tests
parent8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff)
downloadfund-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.fs254
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
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>