From 0597ac74757e223c17a6477ff49d69a559168279 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:19:09 +0800 Subject: Add real AKShare NAV-date probe slice (3d-12) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/NavDateProbeTests.fs | 248 +++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 tests/FundLab.Api.Tests/NavDateProbeTests.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 374c63e..a77e503 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -29,6 +29,7 @@ + diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs new file mode 100644 index 0000000..0f7e506 --- /dev/null +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -0,0 +1,248 @@ +namespace FundLab.Api.Tests + +open System +open System.Diagnostics +open System.IO +open System.Text.Json +open System.Threading +open Xunit +open FundLab.Api + +module NavDateProbeSupport = + let navPayloadJson (code: string) (observations: (string * string) list) = + let body = + observations + |> List.map (fun (date, nav) -> + sprintf + "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":null,\"daily_return\":null}" + date + nav) + |> 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 + + let collectorReturning (fetch: unit -> Result) = + { new IMarketDataCollector with + member _.Search(_, _) = Ok "" + member _.FetchNav(_, _) = fetch () } + + let realAksharePython () = + let configured = Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHON") + + if String.IsNullOrWhiteSpace configured then + None + elif File.Exists configured then + Some configured + else + let path = Environment.GetEnvironmentVariable("PATH") |> Option.ofObj |> Option.defaultValue "" + + path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + |> Array.tryPick (fun directory -> + let candidate = Path.Combine(directory, configured) + if File.Exists candidate then Some candidate else None) + + let repositoryRoot () = + let mutable directory = DirectoryInfo(AppContext.BaseDirectory) + + while not (isNull directory) + && not (File.Exists(Path.Combine(directory.FullName, "scripts", "probe_akshare_nav_dates.py"))) do + directory <- directory.Parent + + if isNull directory then + failwith "could not locate scripts/probe_akshare_nav_dates.py from the test output directory" + else + directory.FullName + +/// xUnit has no first-class runtime skip in 2.x, so this fact sets Skip during +/// discovery when the host has no reachable AKShare interpreter configured. +/// That keeps the live probe detectable ("skipped, not faked") instead of red. +type RealAkshareFactAttribute() = + inherit FactAttribute() + + do + if (NavDateProbeSupport.realAksharePython ()).IsNone then + base.Skip <- + "set FUND_LAB_AKSHARE_PYTHON to a python interpreter with akshare installed to run the live probe" + +[] +type NavDateProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let appWithProbe (probe: INavDateProbe) = + App.createApplicationWithNavDateProbe (repository ()) probe + + [] + member _.``probe parses, dedupes, sorts descending and truncates``() = + let payload = + NavDateProbeSupport.navPayloadJson + "000001" + [ + "2026-09-16", "1.1205" + "2026-09-21", "1.1902" + "2026-09-16", "1.1205" + "2026-09-18", "1.1500" + "2026-09-17", "1.1180" + "2026-09-15", "1.1000" + "2026-09-11", "1.0900" + ] + + let probe = AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok payload)) :> INavDateProbe + + match probe.RecentNavDates("000001", 5, CancellationToken.None) with + | Ok dates -> + Assert.Equal( + [ + DateOnly(2026, 9, 21) + DateOnly(2026, 9, 18) + DateOnly(2026, 9, 17) + DateOnly(2026, 9, 16) + DateOnly(2026, 9, 15) + ], + dates + ) + | Error failure -> failwithf "expected NAV dates, got %A" failure + + [] + member _.``probe memoises successful lookups per code``() = + let mutable calls = 0 + + let collector = + NavDateProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Ok(NavDateProbeSupport.navPayloadJson "000001" [ "2026-09-21", "1.1902" ])) + + let probe = AkshareNavDateProbe(collector) :> INavDateProbe + let first = probe.RecentNavDates("000001", 5, CancellationToken.None) + let second = probe.RecentNavDates("000001", 5, CancellationToken.None) + + Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], first) + Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], second) + Assert.Equal(1, calls) + + [] + member _.``probe rejects a malformed fund code without touching the collector``() = + let collector = + NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called") + + let probe = AkshareNavDateProbe(collector) :> INavDateProbe + + match probe.RecentNavDates("abc", 5, CancellationToken.None) with + | Error (InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) + | other -> failwithf "expected InvalidMarketDataRequest, got %A" other + + [] + member _.``probe maps collector failures to an unavailable reason``() = + let collector = + NavDateProbeSupport.collectorReturning (fun () -> Error "AKShare is unreachable: connection timed out") + + let probe = AkshareNavDateProbe(collector) :> INavDateProbe + + match probe.RecentNavDates("000001", 5, CancellationToken.None) with + | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("connection timed out", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + [] + member _.``probe maps malformed payloads to an invalid payload reason``() = + let probe = + AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavDateProbe + + match probe.RecentNavDates("000001", 5, CancellationToken.None) with + | Error (InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [] + member _.``market nav-dates endpoint returns the probe dates``() = + let probe = + { new INavDateProbe with + member _.RecentNavDates(code, limit, _) = + Assert.Equal("000001", code) + Assert.Equal(5, limit) + Ok [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18) ] } + + let status, body = + PersistenceTestHelpers.invoke + (appWithProbe probe) + "GET" + "/api/market/nav-dates?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"000001\"", body) + Assert.Contains("\"dates\":[\"2026-09-21\",\"2026-09-18\"]", body) + + [] + member _.``market nav-dates endpoint answers 503 when the probe fails``() = + let probe = + { new INavDateProbe with + member _.RecentNavDates(_, _, _) = + Error(MarketDataCollectorUnavailable "AKShare is unreachable: connection timed out") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithProbe probe) + "GET" + "/api/market/nav-dates?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + Assert.Contains("connection timed out", body) + + [] + member _.``market nav-dates endpoint rejects a malformed code with 400``() = + let probe = + { new INavDateProbe with + member _.RecentNavDates(_, _, _) = + Error(InvalidMarketDataRequest "fund code must contain exactly six digits") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithProbe probe) + "GET" + "/api/market/nav-dates?code=abc" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) + + [] + member _.``live AKShare probe returns the five most recent NAV dates``() = + let python = (NavDateProbeSupport.realAksharePython ()).Value + let script = Path.Combine(NavDateProbeSupport.repositoryRoot (), "scripts", "probe_akshare_nav_dates.py") + + let startInfo = ProcessStartInfo(python) + startInfo.ArgumentList.Add script + startInfo.ArgumentList.Add "--code" + startInfo.ArgumentList.Add "000001" + startInfo.ArgumentList.Add "--limit" + startInfo.ArgumentList.Add "5" + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + startInfo.UseShellExecute <- false + + match Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHONPATH") with + | value when not (String.IsNullOrWhiteSpace value) -> startInfo.Environment["PYTHONPATH"] <- value + | _ -> () + + use probeProcess = Process.Start startInfo + let standardOutput = probeProcess.StandardOutput.ReadToEnd() + let standardError = probeProcess.StandardError.ReadToEnd() + probeProcess.WaitForExit 60000 |> ignore + + Assert.True(probeProcess.ExitCode = 0, sprintf "probe exited with %d: %s" probeProcess.ExitCode standardError) + + use document = JsonDocument.Parse standardOutput + + let dates = + document.RootElement.GetProperty("dates").EnumerateArray() + |> Seq.map (fun element -> element.GetString()) + |> Seq.toList + + Assert.Equal(5, List.length dates) + Assert.Equal(List.sortDescending dates, dates) -- cgit v1.2.3