summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/NavDateProbe.fs
blob: 0437fd83f709a2155d032c1d2ce96ecd62328bfe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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))