From 70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:56:35 +0800 Subject: Add real NAV series multi-day returns (3d-13) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/NavDateProbeTests.fs | 6 +- tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 237 +++++++++++++++++++++++ tests/FundLab.Web.Tests/BoundaryTests.fs | 40 ++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 tests/FundLab.Api.Tests/NavSeriesProbeTests.fs (limited to 'tests') diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index a77e503..07db95f 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -30,6 +30,7 @@ + diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs index 0f7e506..6197950 100644 --- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -71,8 +71,12 @@ type RealAkshareFactAttribute() = type NavDateProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) + let stubSeriesProbe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Ok [] } + let appWithProbe (probe: INavDateProbe) = - App.createApplicationWithNavDateProbe (repository ()) probe + App.createApplicationWithProbes (repository ()) { NavDates = probe; NavSeries = stubSeriesProbe } [] member _.``probe parses, dedupes, sorts descending and truncates``() = diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs new file mode 100644 index 0000000..14fd8e8 --- /dev/null +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -0,0 +1,237 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module NavSeriesProbeSupport = + let payload (code: string) (observations: (string * string * string option) list) = + let body = + observations + |> List.map (fun (date, nav, accumulated) -> + let accumulatedText = + match accumulated with + | Some value -> sprintf "\"%s\"" value + | None -> "null" + + sprintf + "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":%s,\"daily_return\":null}" + date + nav + accumulatedText) + |> String.concat "," + + sprintf + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"nav\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}" + code + body + +[] +type NavSeriesProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let stubDateProbe = + { new INavDateProbe with + member _.RecentNavDates(_, _, _) = Ok [] } + + let appWithSeries (probe: INavSeriesProbe) = + App.createApplicationWithProbes (repository ()) { NavDates = stubDateProbe; NavSeries = probe } + + [] + member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = + let json = + NavSeriesProbeSupport.payload + "000001" + [ + "2026-09-16", "1.1205", Some "2.5000" + "2026-09-21", "1.1902", None + "2026-09-16", "1.1205", Some "2.5000" + "2026-09-18", "1.1500", Some "2.5300" + "2026-09-17", "0.0000", None + "2026-09-15", "1.1000", Some "2.4800" + ] + + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 4, CancellationToken.None) with + | Ok points -> + Assert.Equal( + [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18); DateOnly(2026, 9, 16); DateOnly(2026, 9, 15) ], + points |> List.map (fun point -> point.NavDate) + ) + + Assert.Equal(4, List.length points) + Assert.Equal(Some 2.53m, (points |> List.find (fun point -> point.NavDate = DateOnly(2026, 9, 18))).AccumulatedNav) + Assert.Equal(None, (points |> List.head).AccumulatedNav) + | Error failure -> failwithf "expected NAV series, got %A" failure + + [] + member _.``series memoises successful lookups per code and day``() = + let mutable calls = 0 + + let collector = + NavDateProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Ok(NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "1.1902", None ])) + + let probe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe + let first = probe.RecentNavSeries("000001", 30, CancellationToken.None) + let second = probe.RecentNavSeries("000001", 30, CancellationToken.None) + + Assert.Equal(1, calls) + Assert.Equal(first |> Result.map (List.map (fun point -> point.NavDate)), second |> Result.map (List.map (fun point -> point.NavDate))) + + [] + member _.``series returns an empty list when no usable nav remains``() = + let json = NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "0.0000", None; "2026-09-18", "0.0000", None ] + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Ok points -> Assert.Empty points + | Error failure -> failwithf "expected empty NAV series, got %A" failure + + [] + member _.``series rejects a malformed fund code without touching the collector``() = + let probe = + AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) + :> INavSeriesProbe + + match probe.RecentNavSeries("abc", 30, CancellationToken.None) with + | Error (InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) + | other -> failwithf "expected InvalidMarketDataRequest, got %A" other + + [] + member _.``series maps collector failures to an unavailable reason``() = + let probe = + AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Error "collector exited with code 2")) + :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + [] + member _.``series maps malformed payloads to an invalid payload reason``() = + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Error (InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [] + member _.``market nav-series endpoint returns the probe points``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(code, limit, _) = + Assert.Equal("000001", code) + Assert.Equal(30, limit) + + Ok + [ + { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } + { NavDate = DateOnly(2026, 9, 18); Nav = 1.15m; AccumulatedNav = Some 2.53m } + ] } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"000001\"", body) + Assert.Contains("\"navDate\":\"2026-09-21\"", body) + Assert.Contains("1.1902", body) + Assert.Contains("\"navDate\":\"2026-09-18\"", body) + Assert.Contains("2.53", body) + + [] + member _.``market nav-series endpoint passes the requested limit``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, limit, _) = + Assert.Equal(3, limit) + Ok [ { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } ] } + + let status, _ = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001&limit=3" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + + [] + member _.``market nav-series endpoint returns an empty list for no usable points``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Ok [] } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"points\":[]", body) + + [] + member _.``market nav-series endpoint answers 503 when the probe fails``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Error(MarketDataCollectorUnavailable "collector exited with code 2") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + Assert.Contains("code 2", body) + + [] + member _.``market nav-series endpoint rejects a malformed code with 400``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = + Error(InvalidMarketDataRequest "fund code must contain exactly six digits") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=abc" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) + + [] + member _.``market nav-series endpoint rejects a malformed limit with 400``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = failwith "probe should not be called for an invalid limit" } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001&limit=0" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("limit must be an integer between 1 and 250", body) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 2435f32..6b4eef8 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -418,6 +418,46 @@ module ConfirmationBoundaryTests = Assert.Equal(Some "混合型", Api.decodeOptionalText (someText "混合型")) Assert.Equal(None, Api.decodeOptionalText (box { case = "None"; fields = [||] })) + [] + let ``nav series decodes points with optional accumulated nav`` () = + let raw : RawMarketNavSeriesPoint array = + [| + { navDate = "2026-09-21"; nav = "1.19020000"; accumulatedNav = null } + { navDate = "2026-09-18"; nav = "1.15000000"; accumulatedNav = someText "2.53000000" } + |] + + let decoded = raw |> Array.map Api.decodeNavSeriesPoint |> Array.toList + + Assert.Equal(2, decoded.Length) + Assert.Equal("2026-09-21", decoded[0].navDate) + Assert.Equal("1.19020000", decoded[0].nav) + Assert.Equal(None, decoded[0].accumulatedNav) + Assert.Equal(Some "2.53000000", decoded[1].accumulatedNav) + + [] + let ``nav series read completion stores points and flags availability`` () = + let model = init () + let raw = { code = "000001"; points = [| { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = null } |] } + let updated, _ = update (NavSeriesReadCompleted(model.navSeriesReadSeq, raw)) model + + Assert.Equal(1, updated.navSeries.Length) + Assert.Equal("1.1902", updated.navSeries[0].nav) + Assert.True(updated.navSeriesAvailable) + + [] + let ``nav series read failure clears the chart without a zero fill`` () = + let model = + { init () with + navSeries = [ { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = None } ] + navSeriesAvailable = true + navSeriesInFlight = true } + + let updated, _ = update (NavSeriesReadFailed(model.navSeriesReadSeq, "collector exited with code 2")) model + + Assert.Empty updated.navSeries + Assert.False(updated.navSeriesAvailable) + Assert.False(updated.navSeriesInFlight) + [] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) -- cgit v1.2.3