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 ParValue = None IssuePrice = None ValueDate = None ListingDate = None PublishDate = None PayInterestDay = None CouponRate = None CouponRateExplain = None BondExpireYears = None Rating = None DataStatus = 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 } let emptyStockDailyProbe = { new IStockDailyProbe with member _.RecentDaily(_, _, _) = Ok [] } let stockDailyProbeReturning (bars: Map) = { new IStockDailyProbe with member _.RecentDaily(code, _, _) = match bars.TryFind code with | Some observations -> Ok observations | None -> Error(MarketDataCollectorUnavailable(sprintf "no daily bars for %s" code)) } let probesWithDaily (stockPrices: Map) (bondPrices: Map) (stockDaily: IStockDailyProbe) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = bondProbeReturning bondPrices StockQuotes = stockProbeReturning stockPrices StockDaily = stockDaily } [] 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 CleanPrice = price AccruedInterest = 0m ParValue = 100m SettlementDate = DateOnly(2026, 9, 21) CouponRate = None ValueDate = None MaturityDate = None TradeDate = Some(DateOnly(2026, 9, 21)) } ) with | BondTradeWriteResult.BondTradeCreated _ -> () | other -> failwithf "unexpected bond trade result: %A" other let buyBondWithTerms fundId code quantity cleanPrice = let key = fixture.Key(sprintf "valuation-bond-terms-%s" (Guid.NewGuid().ToString("N"))) match repository().CreateBondTrade( key, fundId, { InstrumentCode = code BondName = Some "南航转债" Quantity = quantity Price = cleanPrice CleanPrice = cleanPrice AccruedInterest = 0m ParValue = 100m SettlementDate = DateOnly(2026, 9, 21) CouponRate = Some 0.02m ValueDate = Some(DateOnly(2020, 10, 15)) MaturityDate = Some(DateOnly(2026, 10, 15)) TradeDate = Some(DateOnly(2026, 9, 21)) } ) 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 _.``bond valuation accrues interest point-in-time and values on a dirty basis``() = let fundId = createFund 10000.00m buyBondWithTerms fundId "110075" 10m 106.368m let valuationAt asOf = PersistenceTestHelpers.invoke (app Map.empty (Map.ofList [ "110075", 106.368m ])) "GET" (sprintf "/api/funds/%O/valuation?asOfDate=%s" fundId asOf) [ "Authorization", "Bearer test-token" ] "" let status, early = valuationAt "2026-09-21" Assert.Equal(200, status) Assert.Contains("\"accruedInterest\":\"1.86849300\"", early) Assert.Contains("\"dirtyPrice\":\"108.23649300\"", early) Assert.Contains("\"valueBasis\":\"dirty\"", early) Assert.Contains("\"marketValue\":\"1082.36\"", early) let _, later = valuationAt "2026-10-01" Assert.Contains("\"accruedInterest\":\"1.92328800\"", later) Assert.Contains("\"marketValue\":\"1082.91\"", later) [] 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) [] member _.``refresh persists a stock daily close and valuation prefers that snapshot over a live quote``() = let fundId = createFund 10000.00m buyStock fundId "601398" 100m 1000.00m let daily : StockDailyObservation = { BarDate = DateOnly(2026, 9, 21) Close = 1111.00m Volume = None Amount = None } let dailyProbe = FundValuationSupport.stockDailyProbeReturning (Map.ofList [ "601398", [ daily ] ]) let appWithDaily = App.createApplicationWithProbes (repository ()) (FundValuationSupport.probesWithDaily Map.empty Map.empty dailyProbe) let refreshStatus, refreshBody = PersistenceTestHelpers.invoke appWithDaily "POST" (sprintf "/api/funds/%O/market-data/refresh" fundId) [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, refreshStatus) Assert.Contains("\"asOfDate\":\"2026-09-21\"", refreshBody) Assert.Contains("\"instrumentCode\":\"601398\"", refreshBody) Assert.Contains("\"assetClass\":\"stock\"", refreshBody) Assert.Contains("\"snapshotDate\":\"2026-09-21\"", refreshBody) Assert.Contains("\"price\":\"1111.00000000\"", refreshBody) Assert.Contains("\"failures\":[]", refreshBody) // A deliberately different live quote must not override the persisted close. let status, body = getValuation (Map.ofList [ "601398", 9999.00m ]) Map.empty fundId Assert.Equal(200, status) Assert.Contains("\"positionsMarketValue\":\"111100.00\"", body) Assert.Contains("\"priceSource\":\"snapshot\"", body) [] member _.``refresh marks instruments without market data as failures and never fabricates a price``() = let fundId = createFund 5000.00m buyStock fundId "600000" 100m 1000.00m buyBond fundId "110059" 10m 100.00m let appMissing = App.createApplicationWithProbes (repository ()) (FundValuationSupport.probesWithDaily Map.empty Map.empty FundValuationSupport.emptyStockDailyProbe) let refreshStatus, refreshBody = PersistenceTestHelpers.invoke appMissing "POST" (sprintf "/api/funds/%O/market-data/refresh" fundId) [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, refreshStatus) Assert.Contains("\"refreshed\":[]", refreshBody) Assert.Contains("\"instrumentCode\":\"600000\"", refreshBody) Assert.Contains("\"reason\":\"no daily bar on or before the refresh date\"", refreshBody) Assert.Contains("\"instrumentCode\":\"110059\"", refreshBody) Assert.Contains("\"reason\":\"no valid quote for 110059\"", refreshBody) let status, body = getValuation Map.empty Map.empty fundId Assert.Equal(200, status) Assert.Contains("\"positionsMarketValue\":\"0.00\"", body) Assert.Contains("\"unavailablePositions\":2", body) Assert.Contains("\"status\":\"unavailable\"", body) [] member _.``refresh persists a bond valuation price and valuation prefers that snapshot``() = let fundId = createFund 8000.00m buyBond fundId "113050" 10m 100.00m let appWithBond = App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes Map.empty (Map.ofList [ "113050", 106.368m ])) let refreshStatus, refreshBody = PersistenceTestHelpers.invoke appWithBond "POST" (sprintf "/api/funds/%O/market-data/refresh" fundId) [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, refreshStatus) Assert.Contains("\"assetClass\":\"bond\"", refreshBody) Assert.Contains("\"price\":\"106.36800000\"", refreshBody) let status, body = getValuation Map.empty (Map.ofList [ "113050", 200.00m ]) fundId Assert.Equal(200, status) Assert.Contains("\"positionsMarketValue\":\"1063.68\"", body) Assert.Contains("\"priceSource\":\"snapshot\"", body)