summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/NavDateProbe.fs
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 /src/FundLab.Api/NavDateProbe.fs
parent36ee23a0c406c16e975276238a861299899ec79a (diff)
downloadfund-lab-0597ac74757e223c17a6477ff49d69a559168279.tar.gz
Add real AKShare NAV-date probe slice (3d-12)
Diffstat (limited to 'src/FundLab.Api/NavDateProbe.fs')
-rw-r--r--src/FundLab.Api/NavDateProbe.fs57
1 files changed, 57 insertions, 0 deletions
diff --git a/src/FundLab.Api/NavDateProbe.fs b/src/FundLab.Api/NavDateProbe.fs
new file mode 100644
index 0000000..0437fd8
--- /dev/null
+++ b/src/FundLab.Api/NavDateProbe.fs
@@ -0,0 +1,57 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+
+/// Read-only probe that returns the most recent NAV dates for a fund code.
+///
+/// This is the "real data first" seam for the market NAV-date endpoint: the only
+/// implementation talks to the AKShare collector, and there is deliberately no
+/// synthetic fallback. A failed probe surfaces as a MarketDataFailure so the API
+/// can answer 503 with the underlying reason instead of inventing dates.
+type INavDateProbe =
+ abstract RecentNavDates: code: string * limit: int * CancellationToken -> Result<DateOnly list, MarketDataFailure>
+
+/// AKShare-backed probe. Successful lookups are memoised per fund code
+/// (the F# equivalent of functools.lru_cache) so repeated UI reads during one
+/// process do not re-spawn the collector; failures are never cached.
+type AkshareNavDateProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, DateOnly 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 > 60 then 60
+ else limit
+
+ member _.Clear() = cache.Clear()
+
+ interface INavDateProbe with
+ member _.RecentNavDates(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
+ match cache.TryGetValue normalized with
+ | true, cached -> Ok(cached |> List.truncate (clip limit))
+ | _ ->
+ 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 dates =
+ payload.Observations
+ |> List.map (fun observation -> observation.NavDate)
+ |> List.distinct
+ |> List.sortDescending
+
+ cache.[normalized] <- dates
+ Ok(dates |> List.truncate (clip limit))