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