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/App.fs | 26 +++++++++ src/FundLab.Api/FundLab.Api.fsproj | 1 + src/FundLab.Api/MarketData.fs | 44 +++++++++++++++ src/FundLab.Api/MarketDataService.fs | 4 ++ src/FundLab.Api/Program.fs | 4 +- src/FundLab.Api/StockQuoteProbe.fs | 61 +++++++++++++++++++++ src/FundLab.Api/akshare_collector.py | 102 ++++++++++++++++++++++++++++++++++- 7 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 src/FundLab.Api/StockQuoteProbe.fs (limited to 'src/FundLab.Api') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index b083c37..9565485 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -357,6 +357,7 @@ type MarketProbes = NavDates: INavDateProbe NavSeries: INavSeriesProbe BondQuotes: IBondQuoteProbe + StockQuotes: IStockQuoteProbe } type BondQuoteApiResponse = @@ -367,6 +368,14 @@ type BondQuoteApiResponse = maturityDate: string option } +type StockQuoteApiResponse = + { + code: string + name: string option + price: string option + currency: string + } + module App = let addOptionFriendlyJson (services: IServiceCollection) = let settings = @@ -1728,11 +1737,28 @@ module App = ctx | Error failure -> marketDataError failure next ctx + let private getStockQuote (probe: IStockQuoteProbe) : HttpHandler = + fun next ctx -> + let code = ctx.Request.Query["code"].ToString() + + match probe.GetQuote(code, ctx.RequestAborted) with + | Ok quote -> + json + ({ code = quote.Code + name = quote.Name + price = quote.Price |> Option.map decimalText + currency = quote.Currency } + : StockQuoteApiResponse) + next + ctx + | Error failure -> marketDataError failure next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries GET >=> route "/market/bond-quote" >=> getBondQuote probes.BondQuotes + GET >=> route "/market/stock-quote" >=> getStockQuote probes.StockQuotes ] let private createApplicationInternal diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index ef4df25..62c6324 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -21,6 +21,7 @@ + diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index ad2a623..87ef145 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -48,6 +48,17 @@ type MarketDataBondQuotePayload = MaturityDate: DateOnly option } +type MarketDataStockQuotePayload = + { + Source: string + SourceRevision: string + CollectedAt: DateTimeOffset + Code: string + Name: string option + Price: decimal option + Currency: string + } + type MarketDataInstrumentRecord = { Code: string @@ -354,3 +365,36 @@ module MarketData = } with | :? JsonException -> Error "payload must be valid JSON" + + let parseStockQuotePayload (json: string) : Result = + try + use document = JsonDocument.Parse(json) + let root = document.RootElement + + result { + let! source, sourceRevision, collectedAt = validateEnvelope root "stock-quote" + let! instrumentProperty = requiredProperty root "instrument" + let! codeProperty = requiredString instrumentProperty "code" + + if not (isFundCode codeProperty) then + return! Error "stock code must contain exactly six digits" + + let! quoteProperty = requiredProperty root "quote" + let! name = optionalString quoteProperty "name" + let! priceProperty = requiredProperty quoteProperty "price" + let! price = optionalDecimal "price" priceProperty + let! currency = requiredString quoteProperty "currency" + + return + { + Source = source + SourceRevision = sourceRevision + CollectedAt = collectedAt + Code = codeProperty + Name = name + Price = price + Currency = currency + } + } + with + | :? JsonException -> Error "payload must be valid JSON" diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs index 0108ea7..7460bad 100644 --- a/src/FundLab.Api/MarketDataService.fs +++ b/src/FundLab.Api/MarketDataService.fs @@ -19,6 +19,7 @@ type IMarketDataCollector = abstract Search: query: string * CancellationToken -> Result abstract FetchNav: code: string * CancellationToken -> Result abstract FetchBondQuote: code: string * CancellationToken -> Result + abstract FetchStockQuote: code: string * CancellationToken -> Result type IMarketDataService = abstract Search: query: string * CancellationToken -> Result @@ -207,6 +208,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py member _.FetchBondQuote(code: string, token: CancellationToken) = execute token [ "--operation"; "bond-quote"; "--code"; code ] + member _.FetchStockQuote(code: string, token: CancellationToken) = + execute token [ "--operation"; "stock-quote"; "--code"; code ] + type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) = let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled) diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index e51311f..410583c 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -22,11 +22,13 @@ let main argv = let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe + let stockQuoteProbe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe let probes = { NavDates = navDateProbe NavSeries = navSeriesProbe - BondQuotes = bondQuoteProbe } + BondQuotes = bondQuoteProbe + StockQuotes = stockQuoteProbe } let app = builder.Build() app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes) 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 diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index 652f32d..62632bf 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -207,9 +207,105 @@ def bond_quote(code): } +def first_positive_decimal(*values): + for value in values: + price = decimal_text(value) + if price is not None and Decimal(price) > 0: + return price + return None + + +def stock_price_from_bid_ask(code): + # Single-symbol realtime snapshot (eastmoney). Returns None when the venue + # rejects the symbol or carries no valid last price. + frame = ak.stock_bid_ask_em(symbol=code) + if frame is None or frame.empty: + return None + + for _, row in frame.iterrows(): + item = text(row.get("item")) + if item in ("最新", "最新价"): + return first_positive_decimal(row.get("value")) + + return None + + +def stock_from_snapshot(code): + # Whole-market snapshots differ per provider; each entry is + # (fetch, code column, name column, price column, exchange prefixes). + providers = [ + (ak.stock_zh_a_spot_tx, "code", "name", "zxj", {"sh": 0, "sz": 1, "bj": 2}), + (ak.stock_zh_a_spot_em, "代码", "名称", "最新价", None), + ] + + for fetch, code_key, name_key, price_key, prefixes in providers: + try: + frame = fetch() + except Exception: + continue + + if frame is None or frame.empty: + continue + + candidates = [] + + for _, row in frame.iterrows(): + row_code = text(row.get(code_key)) or "" + if row_code != code and not row_code.lower().endswith(code): + continue + + price = first_positive_decimal(row.get(price_key)) + if price is None: + continue + + prefix = row_code[:2].lower() + rank = prefixes.get(prefix, 3) if prefixes is not None else 0 + candidates.append((rank, row_code, text(row.get(name_key)), price)) + + if candidates: + _, _, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0] + return name, price + + return None + + +def stock_quote(code): + code = text(code) + if code is None or not re.fullmatch(r"\d{6}", code): + raise ValueError("stock code must contain exactly six digits") + + name = None + price = None + + try: + price = stock_price_from_bid_ask(code) + except Exception: + price = None + + if price is None: + found = stock_from_snapshot(code) + if found is None: + raise ValueError(f"no valid quote for {code}") + name, price = found + + return { + "schema_version": SCHEMA_VERSION, + "operation": "stock-quote", + "source": "akshare", + "source_revision": source_revision(), + "collected_at": collected_at(), + "instrument": {"code": code}, + "quote": { + "name": name, + "price": price, + "currency": "CNY", + }, + } + + def main(): parser = argparse.ArgumentParser() - parser.add_argument("--operation", choices=("search", "nav", "bond-quote"), required=True) + parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True) parser.add_argument("--query") parser.add_argument("--code") args = parser.parse_args() @@ -219,8 +315,10 @@ def main(): payload = search(args.query) elif args.operation == "nav": payload = nav(args.code) - else: + elif args.operation == "bond-quote": payload = bond_quote(args.code) + else: + payload = stock_quote(args.code) json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) sys.stdout.write("\n") -- cgit v1.2.3