From 7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 02:13:04 +0800 Subject: Add real AKShare stock quote probe slice (3d-17) --- src/FundLab.Api/StockQuoteProbe.fs | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/FundLab.Api/StockQuoteProbe.fs (limited to 'src/FundLab.Api/StockQuoteProbe.fs') diff --git a/src/FundLab.Api/StockQuoteProbe.fs b/src/FundLab.Api/StockQuoteProbe.fs new file mode 100644 index 0000000..a41070a --- /dev/null +++ b/src/FundLab.Api/StockQuoteProbe.fs @@ -0,0 +1,61 @@ +namespace FundLab.Api + +open System +open System.Collections.Concurrent +open System.Threading + +/// A single stock's current quote. Name is optional because a quote venue may +/// not carry it; missing stays missing rather than being filled in. +type StockQuote = + { + Code: string + Name: string option + Price: decimal option + Currency: string + } + +/// Read-only probe that returns the current quote for a stock code. +/// +/// Like the other market probes there is no synthetic fallback: a failure is +/// surfaced as a MarketDataFailure so the API answers 503 with the reason. +type IStockQuoteProbe = + abstract GetQuote: code: string * CancellationToken -> Result + +/// AKShare-backed stock-quote probe. Successful lookups are memoised per code +///; failures are never cached. +type AkshareStockQuoteProbe(collector: IMarketDataCollector) = + let cache = ConcurrentDictionary(StringComparer.Ordinal) + + let isStockCode (value: string) = + not (String.IsNullOrWhiteSpace value) + && value.Length = 6 + && value |> Seq.forall Char.IsDigit + + member _.Clear() = cache.Clear() + + interface IStockQuoteProbe with + member _.GetQuote(code, token) = + let normalized = if isNull code then "" else code.Trim() + + if not (isStockCode normalized) then + Error(InvalidMarketDataRequest "stock code must contain exactly six digits") + else + match cache.TryGetValue normalized with + | true, cached -> Ok cached + | _ -> + match collector.FetchStockQuote(normalized, token) with + | Error message -> Error(MarketDataCollectorUnavailable message) + | Ok json -> + match MarketData.parseStockQuotePayload json with + | Error message -> Error(InvalidMarketDataPayload message) + | Ok payload -> + let quote = + { + Code = payload.Code + Name = payload.Name + Price = payload.Price + Currency = payload.Currency + } + + cache.[normalized] <- quote + Ok quote -- cgit v1.2.3