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 /// 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(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