summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/BondQuoteProbe.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
commit3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9 (patch)
treeb86805c2680327412d6f5d554d4506ebfe62fc2f /src/FundLab.Api/BondQuoteProbe.fs
parent70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (diff)
downloadfund-lab-3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9.tar.gz
Add real AKShare bond quote probe slice (3d-14)
Diffstat (limited to 'src/FundLab.Api/BondQuoteProbe.fs')
-rw-r--r--src/FundLab.Api/BondQuoteProbe.fs62
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