summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/NavSeriesProbe.fs
blob: 7c7a4376af7037b105b004dce53cb5f42910a118 (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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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)