summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/StockDailyProbeTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 03:25:00 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 03:25:00 +0800
commit7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (patch)
treed7374c1b4bb5308374c3386ac3768ac7209a6440 /tests/FundLab.Api.Tests/StockDailyProbeTests.fs
parent8c01adf05f1428bf2ada49574b25529c064da884 (diff)
downloadfund-lab-7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3.tar.gz
Add real AKShare stock daily history probe slice (3d-19)
Diffstat (limited to 'tests/FundLab.Api.Tests/StockDailyProbeTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/StockDailyProbeTests.fs198
1 files changed, 198 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/StockDailyProbeTests.fs b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs
new file mode 100644
index 0000000..d8f8ee4
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs
@@ -0,0 +1,198 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module StockDailyProbeSupport =
+ let payload (code: string) (observations: (string * string * string option * string option) list) =
+ let body =
+ observations
+ |> List.map (fun (date, close, volume, amount) ->
+ let optionalText =
+ function
+ | Some value -> sprintf "\"%s\"" value
+ | None -> "null"
+
+ sprintf
+ "{\"date\":\"%s\",\"close\":\"%s\",\"volume\":%s,\"amount\":%s}"
+ date
+ close
+ (optionalText volume)
+ (optionalText amount))
+ |> String.concat ","
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-daily\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}"
+ code
+ body
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = Ok ""
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = Ok ""
+ member _.FetchStockDaily(_, _, _) = fetch () }
+
+ let stubProbe (recent: string * int * CancellationToken -> Result<StockDailyObservation list, MarketDataFailure>) =
+ { new IStockDailyProbe with
+ member _.RecentDaily(code, days, token) = recent (code, days, token) }
+
+[<Collection("postgres")>]
+type StockDailyProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithDaily (probe: IStockDailyProbe) =
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe
+ StockDaily = probe }
+
+ [<Fact>]
+ member _.``probe decodes observations ascending and de-duplicates dates``() =
+ let json =
+ StockDailyProbeSupport.payload
+ "600519"
+ [
+ "2026-09-21", "1252.57", Some "2501689.0", Some "3135910045.0"
+ "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0"
+ "2026-09-18", "1257.12", Some "2489087.0", Some "3135849108.0"
+ "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0"
+ ]
+
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Ok observations ->
+ Assert.Equal<DateOnly list>(
+ [ DateOnly(2026, 9, 17); DateOnly(2026, 9, 18); DateOnly(2026, 9, 21) ],
+ observations |> List.map (fun observation -> observation.BarDate)
+ )
+
+ Assert.Equal(3, List.length observations)
+ Assert.Equal<decimal option>(Some 3135910045.0m, (observations |> List.last).Amount)
+ | Error failure -> failwithf "expected daily observations, got %A" failure
+
+ [<Fact>]
+ member _.``probe rejects a payload whose code disagrees with the request``() =
+ let json = StockDailyProbeSupport.payload "000001" [ "2026-09-21", "1252.57", None, None ]
+
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message)
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``probe rejects a malformed stock code without touching the collector``() =
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> failwith "collector should not be called"))
+ :> IStockDailyProbe
+
+ match probe.RecentDaily("abc", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message)
+ | other -> failwithf "expected InvalidMarketDataRequest, got %A" other
+
+ [<Fact>]
+ member _.``probe surfaces a no valid history collector failure``() =
+ let probe =
+ AkshareStockDailyProbe(
+ StockDailyProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid daily history for 999999")
+ )
+ :> IStockDailyProbe
+
+ match probe.RecentDaily("999999", 5, CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid daily history for 999999", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ [<Fact>]
+ member _.``probe maps malformed payloads to an invalid payload reason``() =
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``market stock-daily endpoint returns the probe observations``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun (code, days, _) ->
+ Assert.Equal("600519", code)
+ Assert.Equal(5, days)
+
+ Ok
+ [
+ { BarDate = DateOnly(2026, 9, 18); Close = 1257.12m; Volume = Some 2489087m; Amount = Some 3135849108m }
+ { BarDate = DateOnly(2026, 9, 21); Close = 1252.57m; Volume = Some 2501689m; Amount = Some 3135910045m }
+ ])
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=600519&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"600519\"", body)
+ Assert.Contains("\"date\":\"2026-09-21\"", body)
+ Assert.Contains("1252.57", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint answers 503 when the probe fails``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ ->
+ Error(MarketDataCollectorUnavailable "AKShare collector failed: no valid daily history for 999999"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=999999&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint rejects a malformed code with 400``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ ->
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=abc&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint rejects an out-of-range days with 400``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ -> failwith "probe should not be called for invalid days")
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=600519&days=999"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("days must be an integer between 1 and 30", body)