summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/StockDailyProbe.fs
blob: 8099267a494c8813b6ce957a2c899deb4ac2f910 (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
77
78
79
80
namespace FundLab.Api

open System
open System.Collections.Concurrent
open System.Threading

/// One daily close of a stock's history.
type StockDailyObservation =
    {
        BarDate: DateOnly
        Close: decimal
        Volume: decimal option
        Amount: decimal option
    }

/// Read-only probe that returns recent daily history for a stock code.
///
/// Like the other market probes there is no synthetic fallback: a failure is
/// surfaced as a MarketDataFailure so the API answers 503 with the reason.
type IStockDailyProbe =
    abstract RecentDaily: code: string * days: int * CancellationToken -> Result<StockDailyObservation list, MarketDataFailure>

/// AKShare-backed stock-daily probe. Successful lookups are memoised per code
/// and requested window; failures are never cached. A payload whose instrument
/// code disagrees with the request is rejected outright.
type AkshareStockDailyProbe(collector: IMarketDataCollector) =
    let cache = ConcurrentDictionary<string, StockDailyObservation list>(StringComparer.Ordinal)

    let isStockCode (value: string) =
        not (String.IsNullOrWhiteSpace value)
        && value.Length = 6
        && value |> Seq.forall Char.IsDigit

    let clip days =
        if days < 1 then 1
        elif days > 30 then 30
        else days

    member _.Clear() = cache.Clear()

    interface IStockDailyProbe with
        member _.RecentDaily(code, days, token) =
            let normalized = if isNull code then "" else code.Trim()

            if not (isStockCode normalized) then
                Error(InvalidMarketDataRequest "stock code must contain exactly six digits")
            else
                let bounded = clip days
                let cacheKey = sprintf "%s|%d" normalized bounded

                match cache.TryGetValue cacheKey with
                | true, cached -> Ok cached
                | _ ->
                    match collector.FetchStockDaily(normalized, bounded, token) with
                    | Error message -> Error(MarketDataCollectorUnavailable message)
                    | Ok json ->
                        match MarketData.parseStockDailyPayload json with
                        | Error message -> Error(InvalidMarketDataPayload message)
                        | Ok payload when not (String.Equals(payload.Code, normalized, StringComparison.Ordinal)) ->
                            Error(
                                InvalidMarketDataPayload(
                                    sprintf "payload code '%s' does not match requested code '%s'" payload.Code normalized
                                )
                            )
                        | Ok payload ->
                            let observations =
                                payload.Bars
                                |> List.filter (fun bar -> bar.Close > 0m)
                                |> List.map (fun bar ->
                                    {
                                        BarDate = bar.BarDate
                                        Close = bar.Close
                                        Volume = bar.Volume
                                        Amount = bar.Amount
                                    })
                                |> List.distinctBy (fun bar -> bar.BarDate)
                                |> List.sortBy (fun bar -> bar.BarDate)

                            cache.[cacheKey] <- observations
                            Ok observations