summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/BondQuoteProbe.fs
blob: b01ff18e15605dd4b89dd3da2d632f35df84e2fe (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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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
        SourceRevision: string
        Name: string option
        Price: decimal option
        CleanPrice: decimal option
        AccruedInterest: decimal option
        Date: DateOnly option
        MaturityDate: DateOnly option
        ParValue: decimal option
        IssuePrice: decimal option
        ValueDate: DateOnly option
        ListingDate: DateOnly option
        PublishDate: DateOnly option
        PayInterestDay: string option
        CouponRate: decimal option
        CouponRateExplain: string option
        BondExpireYears: string option
        Rating: string option
        DataStatus: string option
    }

module BondQuote =
    open FundLab.Domain

    /// Best-effort conversion of a quote into pricing terms. Requires the value
    /// date, maturity and coupon rate; when any is missing the caller gets None
    /// rather than a fabricated coupon. Exchange convertible bonds pay annually
    /// (a single 付息日), so the frequency defaults to 1.
    let tryTerms (quote: BondQuote) : BondTerms option =
        match quote.ValueDate, quote.MaturityDate, quote.CouponRate with
        | Some valueDate, Some maturityDate, Some couponRate ->
            Some(
                BondTerms.create
                    (defaultArg quote.ParValue 100m)
                    couponRate
                    1
                    valueDate
                    maturityDate
                    10m
                    0
                    0m
            )
        | _ -> None

/// 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 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 quote =
                                {
                                    Code = payload.Code
                                    SourceRevision = payload.SourceRevision
                                    Name = payload.Name
                                    Price = payload.Price
                                    CleanPrice = payload.CleanPrice
                                    AccruedInterest = payload.AccruedInterest
                                    Date = payload.Date
                                    MaturityDate = payload.MaturityDate
                                    ParValue = payload.ParValue
                                    IssuePrice = payload.IssuePrice
                                    ValueDate = payload.ValueDate
                                    ListingDate = payload.ListingDate
                                    PublishDate = payload.PublishDate
                                    PayInterestDay = payload.PayInterestDay
                                    CouponRate = payload.CouponRate
                                    CouponRateExplain = payload.CouponRateExplain
                                    BondExpireYears = payload.BondExpireYears
                                    Rating = payload.Rating
                                    DataStatus = payload.DataStatus
                                }

                            cache.[normalized] <- quote
                            Ok quote