summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/StockQuoteProbe.fs
blob: b450e13b4aabe696fac60d4b1dbb628e591d506a (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
namespace FundLab.Api

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

/// A single stock's current quote. Name is optional because a quote venue may
/// not carry it; missing stays missing rather than being filled in.
type StockQuote =
    {
        Code: string
        Name: string option
        Price: decimal option
        Currency: string
        /// Exchange prefix: "sh", "sz" or "bj".
        Market: string option
        /// Price adjustment flag: "none", "qfq" or "hfq".
        Adjust: string option
        /// True when the venue reports no trades for the session.
        Suspended: bool option
    }

/// Read-only probe that returns the current quote 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 IStockQuoteProbe =
    abstract GetQuote: code: string * CancellationToken -> Result<StockQuote, MarketDataFailure>

/// AKShare-backed stock-quote probe. Successful lookups are memoised per code
///; failures are never cached.
type AkshareStockQuoteProbe(collector: IMarketDataCollector) =
    let cache = ConcurrentDictionary<string, StockQuote>(StringComparer.Ordinal)

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

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

    interface IStockQuoteProbe with
        member _.GetQuote(code, 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
                match cache.TryGetValue normalized with
                | true, cached -> Ok cached
                | _ ->
                    match collector.FetchStockQuote(normalized, token) with
                    | Error message -> Error(MarketDataCollectorUnavailable message)
                    | Ok json ->
                        match MarketData.parseStockQuotePayload json with
                        | Error message -> Error(InvalidMarketDataPayload message)
                        | Ok payload ->
                            let quote =
                                {
                                    Code = payload.Code
                                    Name = payload.Name
                                    Price = payload.Price
                                    Currency = payload.Currency
                                    Market = payload.Market
                                    Adjust = payload.Adjust
                                    Suspended = payload.Suspended
                                }

                            cache.[normalized] <- quote
                            Ok quote