summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/NavSeriesProbe.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
commit70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (patch)
treec4b8ce56b1867d8b4c744422fef3e965eb2aadda /src/FundLab.Api/NavSeriesProbe.fs
parent0597ac74757e223c17a6477ff49d69a559168279 (diff)
downloadfund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz
Add real NAV series multi-day returns (3d-13)
Diffstat (limited to 'src/FundLab.Api/NavSeriesProbe.fs')
-rw-r--r--src/FundLab.Api/NavSeriesProbe.fs76
1 files changed, 76 insertions, 0 deletions
diff --git a/src/FundLab.Api/NavSeriesProbe.fs b/src/FundLab.Api/NavSeriesProbe.fs
new file mode 100644
index 0000000..7c7a437
--- /dev/null
+++ b/src/FundLab.Api/NavSeriesProbe.fs
@@ -0,0 +1,76 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Globalization
+open System.Threading
+
+/// One point of a fund's real net-value series.
+type NavSeriesPoint =
+ {
+ NavDate: DateOnly
+ Nav: decimal
+ AccumulatedNav: decimal option
+ }
+
+/// Read-only probe that returns the most recent NAV points for a fund code.
+/// Like INavDateProbe there is no synthetic fallback: a failure is surfaced as a
+/// MarketDataFailure so the API answers 503 with the underlying reason.
+type INavSeriesProbe =
+ abstract RecentNavSeries: code: string * limit: int * CancellationToken -> Result<NavSeriesPoint list, MarketDataFailure>
+
+/// AKShare-backed series probe. Successful lookups are memoised per code and
+/// calendar day (the F# equivalent of functools cache keyed on code + today), so
+/// the UI can re-read the curve during one day without re-spawning the collector.
+/// Failures are never cached.
+type AkshareNavSeriesProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, NavSeriesPoint list>(StringComparer.Ordinal)
+
+ let isFundCode (value: string) =
+ not (String.IsNullOrWhiteSpace value)
+ && value.Length = 6
+ && value |> Seq.forall Char.IsDigit
+
+ let clip limit =
+ if limit < 1 then 1
+ elif limit > 250 then 250
+ else limit
+
+ let todayText () =
+ (ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+
+ member _.Clear() = cache.Clear()
+
+ interface INavSeriesProbe with
+ member _.RecentNavSeries(code, limit, token) =
+ let normalized = if isNull code then "" else code.Trim()
+
+ if not (isFundCode normalized) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ else
+ let bounded = clip limit
+ let cacheKey = sprintf "%s|%s" normalized (todayText ())
+
+ match cache.TryGetValue cacheKey with
+ | true, cached -> Ok(cached |> List.truncate bounded)
+ | _ ->
+ match collector.FetchNav(normalized, token) with
+ | Error message -> Error(MarketDataCollectorUnavailable message)
+ | Ok json ->
+ match MarketData.parseNavPayload json with
+ | Error message -> Error(InvalidMarketDataPayload message)
+ | Ok payload ->
+ let points =
+ payload.Observations
+ |> List.filter (fun observation -> observation.Nav > 0m)
+ |> List.map (fun observation ->
+ {
+ NavDate = observation.NavDate
+ Nav = observation.Nav
+ AccumulatedNav = observation.AccumulatedNav
+ })
+ |> List.distinctBy (fun point -> point.NavDate)
+ |> List.sortByDescending (fun point -> point.NavDate)
+
+ cache.[cacheKey] <- points
+ Ok(points |> List.truncate bounded)