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

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

/// A single bond's current quote. Every descriptive field is optional because
/// the upstream AKShare endpoint may not carry it; missing stays missing rather
/// than being filled with a fabricated value.
type BondQuote =
    {
        Code: string
        Name: string option
        Price: decimal option
        MaturityDate: DateOnly option
    }

/// Read-only probe that returns the current quote for a bond code.
///
/// Like INavDateProbe there is no synthetic fallback: a failed probe surfaces as
/// a MarketDataFailure so the API answers 503 with the underlying reason.
type IBondQuoteProbe =
    abstract GetQuote: code: string * CancellationToken -> Result<BondQuote, MarketDataFailure>

/// AKShare-backed bond-quote probe. Successful lookups are memoised per bond
/// code (the F# equivalent of functools.lru_cache); failures are never cached.
type AkshareBondQuoteProbe(collector: IMarketDataCollector) =
    let cache = ConcurrentDictionary<string, BondQuote>(StringComparer.Ordinal)

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

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

    interface IBondQuoteProbe with
        member _.GetQuote(code, token) =
            let normalized = if isNull code then "" else code.Trim()

            if not (isBondCode normalized) then
                Error(InvalidMarketDataRequest "bond code must contain exactly six digits")
            else
                match cache.TryGetValue normalized with
                | true, cached -> Ok cached
                | _ ->
                    match collector.FetchBondQuote(normalized, token) with
                    | Error message -> Error(MarketDataCollectorUnavailable message)
                    | Ok json ->
                        match MarketData.parseBondQuotePayload json with
                        | Error message -> Error(InvalidMarketDataPayload message)
                        | Ok payload ->
                            let quote =
                                {
                                    Code = payload.Code
                                    Name = payload.Name
                                    Price = payload.Price
                                    MaturityDate = payload.MaturityDate
                                }

                            cache.[normalized] <- quote
                            Ok quote