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 } /// 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 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 } cache.[normalized] <- quote Ok quote