summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
commit0597ac74757e223c17a6477ff49d69a559168279 (patch)
tree3ab8934481bf9aaef89163fd216f15248fe118ac /tests
parent36ee23a0c406c16e975276238a861299899ec79a (diff)
downloadfund-lab-0597ac74757e223c17a6477ff49d69a559168279.tar.gz
Add real AKShare NAV-date probe slice (3d-12)
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs248
2 files changed, 249 insertions, 0 deletions
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 @@
<Compile Include="ReturnsTests.fs" />
<Compile Include="InvestmentPlanTests.fs" />
<Compile Include="RebalanceTests.fs" />
+ <Compile Include="NavDateProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
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<string, string>) =
+ { 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"
+
+[<Collection("postgres")>]
+type NavDateProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithProbe (probe: INavDateProbe) =
+ App.createApplicationWithNavDateProbe (repository ()) probe
+
+ [<Fact>]
+ 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 list>(
+ [
+ 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
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<RealAkshareFact>]
+ 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<string list>(List.sortDescending dates, dates)