From 545f2a507fd0138f7fd50ed7fb2631d0273db625 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 04:59:12 +0800 Subject: Integrate bond and stock positions into fund valuation (3d-23) --- tests/FundLab.Api.Tests/FundValuationTests.fs | 178 ++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/FundLab.Api.Tests/FundValuationTests.fs (limited to 'tests/FundLab.Api.Tests/FundValuationTests.fs') diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs new file mode 100644 index 0000000..0cfee18 --- /dev/null +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -0,0 +1,178 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module FundValuationSupport = + let stockQuote (code: string) (price: decimal) : StockQuote = + { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY" } + + let bondQuote (code: string) (price: decimal) : BondQuote = + { + Code = code + SourceRevision = "akshare-test/hs-cov-spot" + Name = Some "南航转债" + Price = Some price + CleanPrice = Some price + AccruedInterest = None + Date = None + MaturityDate = None + } + + let stockProbeReturning (prices: Map) = + { new IStockQuoteProbe with + member _.GetQuote(code, _) = + match prices.TryFind code with + | Some price -> Ok(stockQuote code price) + | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) } + + let bondProbeReturning (prices: Map) = + { new IBondQuoteProbe with + member _.GetQuote(code, _) = + match prices.TryFind code with + | Some price -> Ok(bondQuote code price) + | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) } + + let probes (stockPrices: Map) (bondPrices: Map) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = bondProbeReturning bondPrices + StockQuotes = stockProbeReturning stockPrices + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + +[] +type FundValuationTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund (initialCash: decimal) = + let command = + { + Name = "组合估值测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "valuation-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 buyStock fundId code quantity price = + let key = fixture.Key(sprintf "valuation-stock-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateStockTrade( + key, + fundId, + { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price } + ) + with + | StockTradeWriteResult.StockTradeCreated _ -> () + | other -> failwithf "unexpected stock trade result: %A" other + + let buyBond fundId code quantity price = + let key = fixture.Key(sprintf "valuation-bond-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateBondTrade( + key, + fundId, + { InstrumentCode = code; BondName = Some "南航转债"; Quantity = quantity; Price = price } + ) + with + | BondTradeWriteResult.BondTradeCreated _ -> () + | other -> failwithf "unexpected bond trade result: %A" other + + let app stockPrices bondPrices = + App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes stockPrices bondPrices) + + let getValuation stockPrices bondPrices fundId = + PersistenceTestHelpers.invoke + (app stockPrices bondPrices) + "GET" + (sprintf "/api/funds/%O/valuation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``mixed valuation prices live positions and flags the unavailable one``() = + let fundId = createFund 10000.00m + buyStock fundId "600519" 100m 1000.00m + buyBond fundId "110075" 10m 100.00m + + let status, body = + getValuation (Map.ofList [ "600519", 1252.57m ]) (Map.ofList [ "110075", 106.368m ]) fundId + + Assert.Equal(200, status) + Assert.Contains("\"cash\":\"10000.00\"", body) + Assert.Contains("\"positionsMarketValue\":\"126320.68\"", body) + Assert.Contains("\"portfolioValue\":\"136320.68\"", body) + Assert.Contains("\"pricedPositions\":2", body) + Assert.Contains("\"unavailablePositions\":0", body) + + [] + member _.``all-unavailable valuation keeps cash intact and marks positions unavailable without zeroing``() = + let fundId = createFund 5000.00m + buyStock fundId "600519" 100m 1000.00m + buyBond fundId "110075" 10m 100.00m + + let status, body = getValuation Map.empty Map.empty fundId + + Assert.Equal(200, status) + Assert.Contains("\"cash\":\"5000.00\"", body) + Assert.Contains("\"positionsMarketValue\":\"0.00\"", body) + Assert.Contains("\"portfolioValue\":\"5000.00\"", body) + Assert.Contains("\"unavailablePositions\":2", body) + Assert.Contains("\"status\":\"unavailable\"", body) + Assert.Contains("\"quantity\":\"100.00000000\"", body) + Assert.Contains("\"quantity\":\"10.00000000\"", body) + + [] + member _.``partial failure values available positions and does not inflate on missing price``() = + let fundId = createFund 2000.00m + buyStock fundId "600519" 100m 1000.00m + buyBond fundId "110075" 10m 100.00m + + let status, body = getValuation (Map.ofList [ "600519", 10.00m ]) Map.empty fundId + + Assert.Equal(200, status) + Assert.Contains("\"positionsMarketValue\":\"1000.00\"", body) + Assert.Contains("\"portfolioValue\":\"3000.00\"", body) + Assert.Contains("\"pricedPositions\":1", body) + Assert.Contains("\"unavailablePositions\":1", body) + + [] + member _.``without probes every position is marked unavailable and excluded from market value``() = + let fundId = createFund 0.00m + buyStock fundId "600519" 100m 1000.00m + + let status, body = + PersistenceTestHelpers.invoke + (App.createApplication (repository ())) + "GET" + (sprintf "/api/funds/%O/valuation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"positionsMarketValue\":\"0.00\"", body) + Assert.Contains("\"portfolioValue\":\"0.00\"", body) + Assert.Contains("\"status\":\"unavailable\"", body) + Assert.Contains("\"quantity\":\"100.00000000\"", body) + Assert.Contains("\"unavailablePositions\":1", body) + + [] + member _.``valuation of a missing fund answers 404``() = + let status, body = getValuation Map.empty Map.empty (Guid.NewGuid()) + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) -- cgit v1.2.3