diff options
Diffstat (limited to 'src/FundLab.Api/BondQuoteProbe.fs')
| -rw-r--r-- | src/FundLab.Api/BondQuoteProbe.fs | 62 |
1 files changed, 62 insertions, 0 deletions
diff --git a/src/FundLab.Api/BondQuoteProbe.fs b/src/FundLab.Api/BondQuoteProbe.fs new file mode 100644 index 0000000..0cb7c3b --- /dev/null +++ b/src/FundLab.Api/BondQuoteProbe.fs @@ -0,0 +1,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 |
