From 79600c15aef9bd411abe88dcf36286a102b5abea Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:56:14 +0800 Subject: Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25) --- tests/FundLab.Api.Tests/FundValuationTests.fs | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) (limited to 'tests') diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs index 0cfee18..1897fd3 100644 --- a/tests/FundLab.Api.Tests/FundValuationTests.fs +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -42,6 +42,28 @@ module FundValuationSupport = 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 = @@ -176,3 +198,108 @@ type FundValuationTests(fixture: PostgresFixture) = 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) -- cgit v1.2.3