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 /// 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(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))