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)