From 72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 08:58:52 +0800 Subject: Add stock probe market/adjust/suspension fields and StockRules (3d-30 B1-B2) --- src/FundLab.Api/App.fs | 8 ++++- src/FundLab.Api/MarketData.fs | 37 ++++++++++++++++++++++ src/FundLab.Api/StockQuoteProbe.fs | 9 ++++++ src/FundLab.Api/akshare_collector.py | 61 +++++++++++++++++++++++++++--------- 4 files changed, 100 insertions(+), 15 deletions(-) (limited to 'src/FundLab.Api') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index d515ca6..a77fdf5 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -591,6 +591,9 @@ type StockQuoteApiResponse = name: string option price: string option currency: string + market: string option + adjust: string option + suspended: bool option } type StockDailyObservationApiResponse = @@ -2291,7 +2294,10 @@ module App = ({ code = quote.Code name = quote.Name price = quote.Price |> Option.map decimalText - currency = quote.Currency } + currency = quote.Currency + market = quote.Market + adjust = quote.Adjust + suspended = quote.Suspended } : StockQuoteApiResponse) next ctx diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index 152ffd7..12e4893 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -71,6 +71,9 @@ type MarketDataStockQuotePayload = Name: string option Price: decimal option Currency: string + Market: string option + Adjust: string option + Suspended: bool option } type MarketDataStockDailyBar = @@ -87,6 +90,8 @@ type MarketDataStockDailyPayload = SourceRevision: string CollectedAt: DateTimeOffset Code: string + Market: string option + Adjust: string option Bars: MarketDataStockDailyBar list } @@ -183,6 +188,27 @@ module MarketData = else Error(sprintf "payload property '%s' must be null or a string" name)) + let private optionalBool (root: JsonElement) name = + optionalProperty root name + |> Result.bind (function + | None -> Ok None + | Some property -> + match property.ValueKind with + | JsonValueKind.Null -> Ok None + | JsonValueKind.True -> Ok(Some true) + | JsonValueKind.False -> Ok(Some false) + | _ -> Error(sprintf "payload property '%s' must be null or a boolean" name)) + + let private optionalStringOrMissing (root: JsonElement) name = + optionalProperty root name + |> Result.bind (function + | None -> Ok None + | Some property when property.ValueKind = JsonValueKind.Null -> Ok None + | Some property when property.ValueKind = JsonValueKind.String -> + let value = property.GetString() + if String.IsNullOrWhiteSpace value then Ok None else Ok(Some value) + | Some _ -> Error(sprintf "payload property '%s' must be null or a string" name)) + let private validateEnvelope root operation = result { let! schemaVersion = requiredString root "schema_version" @@ -475,6 +501,9 @@ module MarketData = let! priceProperty = requiredProperty quoteProperty "price" let! price = optionalDecimal "price" priceProperty let! currency = requiredString quoteProperty "currency" + let! market = optionalStringOrMissing quoteProperty "market" + let! adjust = optionalStringOrMissing quoteProperty "adjust" + let! suspended = optionalBool quoteProperty "suspended" return { @@ -485,6 +514,9 @@ module MarketData = Name = name Price = price Currency = currency + Market = market + Adjust = adjust + Suspended = suspended } } with @@ -530,12 +562,17 @@ module MarketData = if List.isEmpty bars then return! Error "observations must not be empty" + let! market = optionalStringOrMissing root "market" + let! adjust = optionalStringOrMissing root "adjust" + return { Source = source SourceRevision = sourceRevision CollectedAt = collectedAt Code = codeProperty + Market = market + Adjust = adjust Bars = bars } } diff --git a/src/FundLab.Api/StockQuoteProbe.fs b/src/FundLab.Api/StockQuoteProbe.fs index a41070a..b450e13 100644 --- a/src/FundLab.Api/StockQuoteProbe.fs +++ b/src/FundLab.Api/StockQuoteProbe.fs @@ -12,6 +12,12 @@ type StockQuote = Name: string option Price: decimal option Currency: string + /// Exchange prefix: "sh", "sz" or "bj". + Market: string option + /// Price adjustment flag: "none", "qfq" or "hfq". + Adjust: string option + /// True when the venue reports no trades for the session. + Suspended: bool option } /// Read-only probe that returns the current quote for a stock code. @@ -55,6 +61,9 @@ type AkshareStockQuoteProbe(collector: IMarketDataCollector) = Name = payload.Name Price = payload.Price Currency = payload.Currency + Market = payload.Market + Adjust = payload.Adjust + Suspended = payload.Suspended } cache.[normalized] <- quote diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index 9234d81..aed053a 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -333,17 +333,26 @@ def first_positive_decimal(*values): 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. + # rejects the symbol or carries no valid last price. Volume is returned so + # callers can flag a suspended session (no trades => zero volume). frame = ak.stock_bid_ask_em(symbol=code) if frame is None or frame.empty: return None + price = None + volume = None + for _, row in frame.iterrows(): item = text(row.get("item")) if item in ("最新", "最新价"): - return first_positive_decimal(row.get("value")) + price = first_positive_decimal(row.get("value")) + elif item in ("总量", "成交量", "总手"): + volume = decimal_text(row.get("value")) - return None + if price is None: + return None + + return price, volume def stock_from_snapshot(code): @@ -407,17 +416,22 @@ def stock_daily_observations(frame, date_key, close_key, volume_key, amount_key) return rows -def stock_market_symbol(code): +def stock_market(code): + # A-share exchange mapping from the six-digit code prefix. if code[0] in ("6", "9"): - return f"sh{code}" + return "sh" if code[0] in ("0", "3", "2"): - return f"sz{code}" + return "sz" if code[0] in ("4", "8"): - return f"bj{code}" - return f"sh{code}" + return "bj" + return "sh" -def stock_daily(code, days_text): +def stock_market_symbol(code): + return f"{stock_market(code)}{code}" + + +def stock_daily(code, days_text, adjust_text=None): code = text(code) if code is None or not re.fullmatch(r"\d{6}", code): raise ValueError("stock code must contain exactly six digits") @@ -433,10 +447,12 @@ def stock_daily(code, days_text): if days < 1 or days > 30: raise ValueError("days must be an integer between 1 and 30") + adjust = adjust_text if adjust_text in ("qfq", "hfq") else "" + symbol = stock_market_symbol(code) providers = [ - ((ak.stock_zh_a_hist, {"symbol": code, "period": "daily", "adjust": ""}), "日期", "收盘", "成交量", "成交额"), - ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": ""}), "date", "close", "volume", "amount"), + ((ak.stock_zh_a_hist, {"symbol": code, "period": "daily", "adjust": adjust}), "日期", "收盘", "成交量", "成交额"), + ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": adjust}), "date", "close", "volume", "amount"), ((ak.stock_zh_a_hist_tx, {"symbol": symbol}), "date", "close", "volume", "amount"), ] @@ -460,6 +476,8 @@ def stock_daily(code, days_text): "source_revision": source_revision(), "collected_at": collected_at(), "instrument": {"code": code}, + "market": stock_market(code), + "adjust": adjust or "none", "observations": observations[-days:], } @@ -473,11 +491,15 @@ def stock_quote(code): name = None price = None + volume = None try: - price = stock_price_from_bid_ask(code) + direct = stock_price_from_bid_ask(code) except Exception: - price = None + direct = None + + if direct is not None: + price, volume = direct if price is None: found = stock_from_snapshot(code) @@ -485,6 +507,13 @@ def stock_quote(code): raise ValueError(f"no valid quote for {code}") name, price = found + suspended = False + if volume is not None: + try: + suspended = Decimal(volume) == 0 + except Exception: + suspended = False + return { "schema_version": SCHEMA_VERSION, "operation": "stock-quote", @@ -496,6 +525,9 @@ def stock_quote(code): "name": name, "price": price, "currency": "CNY", + "market": stock_market(code), + "adjust": "none", + "suspended": suspended, }, } @@ -506,6 +538,7 @@ def main(): parser.add_argument("--query") parser.add_argument("--code") parser.add_argument("--days") + parser.add_argument("--adjust", choices=("none", "qfq", "hfq")) args = parser.parse_args() try: @@ -518,7 +551,7 @@ def main(): elif args.operation == "stock-quote": payload = stock_quote(args.code) else: - payload = stock_daily(args.code, args.days) + payload = stock_daily(args.code, args.days, args.adjust) json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) sys.stdout.write("\n") -- cgit v1.2.3