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