diff options
Diffstat (limited to 'src/FundLab.Api/NavDateProbe.fs')
| -rw-r--r-- | src/FundLab.Api/NavDateProbe.fs | 57 |
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)) |
