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 +++++++++++- tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 6 +- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/NavDateProbeTests.fs | 12 +- tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 3 +- tests/FundLab.Api.Tests/StockQuoteProbeTests.fs | 188 +++++++++++++++++++++++ 12 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 src/FundLab.Api/StockQuoteProbe.fs create mode 100644 tests/FundLab.Api.Tests/StockQuoteProbeTests.fs 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") diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index 442ed7c..c9821dc 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -23,7 +23,8 @@ module BondQuoteProbeSupport = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" - member _.FetchBondQuote(_, _) = fetch () } + member _.FetchBondQuote(_, _) = fetch () + member _.FetchStockQuote(_, _) = Ok "" } let stubProbe (getQuote: string * CancellationToken -> Result) = { new IBondQuoteProbe with @@ -38,7 +39,8 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = (repository ()) { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe - BondQuotes = probe } + BondQuotes = probe + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } [] member _.``probe parses name, price and maturity date``() = diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index be044a1..755cc1d 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -32,6 +32,7 @@ + diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs index 1c57fd1..cc513d7 100644 --- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -28,12 +28,17 @@ module NavDateProbeSupport = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = fetch () - member _.FetchBondQuote(_, _) = Ok "" } + member _.FetchBondQuote(_, _) = Ok "" + member _.FetchStockQuote(_, _) = Ok "" } let stubBondQuoteProbe = { new IBondQuoteProbe with member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "bond probe not configured for this test") } + let stubStockQuoteProbe = + { new IStockQuoteProbe with + member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "stock probe not configured for this test") } + let stubNavDateProbe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Ok [] } @@ -91,7 +96,10 @@ type NavDateProbeTests(fixture: PostgresFixture) = let appWithProbe (probe: INavDateProbe) = App.createApplicationWithProbes (repository ()) - { NavDates = probe; NavSeries = stubSeriesProbe; BondQuotes = NavDateProbeSupport.stubBondQuoteProbe } + { NavDates = probe + NavSeries = stubSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } [] member _.``probe parses, dedupes, sorts descending and truncates``() = diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs index f753cbe..81d1733 100644 --- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -40,7 +40,8 @@ type NavSeriesProbeTests(fixture: PostgresFixture) = (repository ()) { NavDates = stubDateProbe NavSeries = probe - BondQuotes = NavDateProbeSupport.stubBondQuoteProbe } + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } [] member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs new file mode 100644 index 0000000..e29c666 --- /dev/null +++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs @@ -0,0 +1,188 @@ +namespace FundLab.Api.Tests + +open System.Threading +open Xunit +open FundLab.Api + +module StockQuoteProbeSupport = + let payload (code: string) (name: string option) (price: string option) = + let optionalText = + function + | Some value -> sprintf "\"%s\"" value + | None -> "null" + + sprintf + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"currency\":\"CNY\"}}" + code + (optionalText name) + (optionalText price) + + let collectorReturning (fetch: unit -> Result) = + { new IMarketDataCollector with + member _.Search(_, _) = Ok "" + member _.FetchNav(_, _) = Ok "" + member _.FetchBondQuote(_, _) = Ok "" + member _.FetchStockQuote(_, _) = fetch () } + + let stubProbe (getQuote: string * CancellationToken -> Result) = + { new IStockQuoteProbe with + member _.GetQuote(code, token) = getQuote (code, token) } + +[] +type StockQuoteProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let appWithQuote (probe: IStockQuoteProbe) = + App.createApplicationWithProbes + (repository ()) + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = probe } + + [] + member _.``probe parses name, price and currency``() = + let json = StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57") + + let probe = + AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe + + match probe.GetQuote("600519", CancellationToken.None) with + | Ok quote -> + Assert.Equal("600519", quote.Code) + Assert.Equal(Some "贵州茅台", quote.Name) + Assert.Equal(Some 1252.57m, quote.Price) + Assert.Equal("CNY", quote.Currency) + | Error failure -> failwithf "expected a stock quote, got %A" failure + + [] + member _.``probe keeps a missing name missing instead of fabricating data``() = + let json = StockQuoteProbeSupport.payload "600519" None (Some "1252.57") + + let probe = + AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe + + match probe.GetQuote("600519", CancellationToken.None) with + | Ok quote -> + Assert.Equal(None, quote.Name) + Assert.Equal(Some 1252.57m, quote.Price) + | Error failure -> failwithf "expected a stock quote, got %A" failure + + [] + member _.``probe rejects a malformed stock code without touching the collector``() = + let probe = + AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) + :> IStockQuoteProbe + + match probe.GetQuote("abc", CancellationToken.None) with + | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) + | other -> failwithf "expected InvalidMarketDataRequest, got %A" other + + [] + member _.``probe surfaces a no valid quote collector failure and does not cache it``() = + let mutable calls = 0 + + let collector = + StockQuoteProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Error "AKShare collector failed: no valid quote for 000000") + + let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe + + match probe.GetQuote("000000", CancellationToken.None) with + | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 000000", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + match probe.GetQuote("000000", CancellationToken.None) with + | Error(MarketDataCollectorUnavailable _) -> () + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + Assert.Equal(2, calls) + + [] + member _.``probe maps malformed payloads to an invalid payload reason``() = + let probe = + AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockQuoteProbe + + match probe.GetQuote("600519", CancellationToken.None) with + | Error(InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [] + member _.``probe memoises successful lookups per stock code``() = + let mutable calls = 0 + + let collector = + StockQuoteProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Ok(StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57"))) + + let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe + + let first = probe.GetQuote("600519", CancellationToken.None) + let second = probe.GetQuote("600519", CancellationToken.None) + + Assert.Equal(1, calls) + Assert.Equal(first |> Result.map (fun quote -> quote.Price), second |> Result.map (fun quote -> quote.Price)) + + [] + member _.``market stock-quote endpoint returns the probe record``() = + let probe = + StockQuoteProbeSupport.stubProbe (fun (code, _) -> + Assert.Equal("600519", code) + + Ok + { + Code = "600519" + Name = Some "贵州茅台" + Price = Some 1252.57m + Currency = "CNY" + }) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/stock-quote?code=600519" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"600519\"", body) + Assert.Contains("\"name\":\"贵州茅台\"", body) + Assert.Contains("1252.57", body) + Assert.Contains("\"currency\":\"CNY\"", body) + + [] + member _.``market stock-quote endpoint answers 503 when the probe fails``() = + let probe = + StockQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/stock-quote?code=000000" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + Assert.Contains("code 2", body) + + [] + member _.``market stock-quote endpoint rejects a malformed code with 400``() = + let probe = + StockQuoteProbeSupport.stubProbe (fun _ -> + Error(InvalidMarketDataRequest "stock code must contain exactly six digits")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/stock-quote?code=abc" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) -- cgit v1.2.3