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