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