summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
commit79600c15aef9bd411abe88dcf36286a102b5abea (patch)
treee5e3bfbff6f60a48a3c0ff8b78b48da98c8f238a /tests/FundLab.Api.Tests
parent6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (diff)
downloadfund-lab-79600c15aef9bd411abe88dcf36286a102b5abea.tar.gz
Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/FundValuationTests.fs127
1 files changed, 127 insertions, 0 deletions
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<string, StockDailyObservation list>) =
+ { 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<string, decimal>)
+ (bondPrices: Map<string, decimal>)
+ (stockDaily: IStockDailyProbe)
+ =
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = bondProbeReturning bondPrices
+ StockQuotes = stockProbeReturning stockPrices
+ StockDaily = stockDaily }
+
[<Collection("postgres")>]
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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)