diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:58:52 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:58:52 +0800 |
| commit | 72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15 (patch) | |
| tree | 8d1bf9285314eb37ce47525e4d0c052fcdf08ca8 /src | |
| parent | d4b0c26b396bfcf1be029d8db3a1c0fc033a6765 (diff) | |
| download | fund-lab-72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15.tar.gz | |
Add stock probe market/adjust/suspension fields and StockRules (3d-30 B1-B2)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 8 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketData.fs | 37 | ||||
| -rw-r--r-- | src/FundLab.Api/StockQuoteProbe.fs | 9 | ||||
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 61 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/StockRules.fs | 110 |
6 files changed, 211 insertions, 15 deletions
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") diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 9417a4c..6d6a8ac 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -15,6 +15,7 @@ <Compile Include="Rebalance.fs" /> <Compile Include="Dividend.fs" /> <Compile Include="BondRules.fs" /> + <Compile Include="StockRules.fs" /> <Compile Include="Performance.fs" /> </ItemGroup> </Project> diff --git a/src/FundLab.Domain/StockRules.fs b/src/FundLab.Domain/StockRules.fs new file mode 100644 index 0000000..0102e5b --- /dev/null +++ b/src/FundLab.Domain/StockRules.fs @@ -0,0 +1,110 @@ +namespace FundLab.Domain + +open System + +/// Stock trading terms. Every rate/limit is supplied by the caller (config), +/// nothing about a specific regime is hardcoded into the functions below. +/// +/// A-share conventions the caller typically supplies: 100-share lot, commission +/// rate with a per-order minimum, sell-side stamp duty, transfer fee, a 15:00 +/// same-day cutoff and T+1 settlement. +type StockTerms = + { + /// Minimum tradable unit in shares (A shares: 100). + MinUnit: decimal + /// Brokerage commission rate applied to the gross amount. + CommissionRate: decimal + /// Minimum commission per order (e.g. 5.00). + CommissionMin: decimal + /// Stamp duty rate, charged on sells only (e.g. 0.0005). + StampDutyRate: decimal + /// Transfer fee rate applied to the gross amount (both sides). + TransferFeeRate: decimal + /// Latest same-day order time; at/after this the order is next-day. + CutoffTime: TimeOnly + /// Settlement lag in trading days (T+1 => 1). + SettlementDays: int + } + +module StockTerms = + let create + (minUnit: decimal) + (commissionRate: decimal) + (commissionMin: decimal) + (stampDutyRate: decimal) + (transferFeeRate: decimal) + (cutoffTime: TimeOnly) + (settlementDays: int) + : StockTerms = + { + MinUnit = minUnit + CommissionRate = commissionRate + CommissionMin = commissionMin + StampDutyRate = stampDutyRate + TransferFeeRate = transferFeeRate + CutoffTime = cutoffTime + SettlementDays = settlementDays + } + + /// Common A-share defaults; callers may override any field. + let aShareDefault: StockTerms = + create 100m 0.00025m 5.00m 0.0005m 0.00001m (TimeOnly(15, 0)) 1 + +/// Pure stock trading rules: cutoff handling, T+1 settlement, fee bounds and +/// lot-size validation. +module StockRules = + let private nextTradingDay (date: DateOnly) = + let next = date.AddDays 1 + + match next.DayOfWeek with + | DayOfWeek.Saturday -> next.AddDays 2 + | DayOfWeek.Sunday -> next.AddDays 1 + | _ -> next + + /// Order date after applying the same-day cutoff: an order at/after the + /// cutoff is treated as placed on the next trading day. + let effectiveTradeDate (terms: StockTerms) (tradeDate: DateOnly) (at: TimeOnly) : DateOnly = + if at >= terms.CutoffTime then nextTradingDay tradeDate else tradeDate + + /// Settlement date for a trade, skipping weekends. A real exchange trading + /// calendar would extend this; weekends are the minimum correctness bar. + let settlementDate (terms: StockTerms) (tradeDate: DateOnly) (at: TimeOnly) : DateOnly = + let rec advance (date: DateOnly) (remaining: int) = + if remaining <= 0 then date else advance (nextTradingDay date) (remaining - 1) + + advance (effectiveTradeDate terms tradeDate at) terms.SettlementDays + + let private commission (terms: StockTerms) (gross: decimal) = + let raw = Decimal.Round(gross * terms.CommissionRate, 2, MidpointRounding.AwayFromZero) + if gross > 0m && raw < terms.CommissionMin then terms.CommissionMin else raw + + let private transferFee (terms: StockTerms) (gross: decimal) = + Decimal.Round(gross * terms.TransferFeeRate, 2, MidpointRounding.AwayFromZero) + + /// Cash cost of buying `quantity` shares: gross plus commission and + /// transfer fee (no stamp duty on buys). + let buyCost (terms: StockTerms) (quantity: decimal) (price: decimal) : decimal = + let gross = Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero) + let fees = commission terms gross + transferFee terms gross + gross + fees + + /// Cash proceeds of selling `quantity` shares: gross minus commission, + /// stamp duty and transfer fee. + let sellProceeds (terms: StockTerms) (quantity: decimal) (price: decimal) : decimal = + let gross = Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero) + let fees = commission terms gross + transferFee terms gross + Decimal.Round(gross * terms.StampDutyRate, 2, MidpointRounding.AwayFromZero) + gross - fees + + /// Quantity must be positive and a whole multiple of the minimum unit. + let validateQuantity (terms: StockTerms) (quantity: decimal) : Result<decimal, string> = + if quantity <= 0m then + Error "quantity must be positive" + elif terms.MinUnit > 0m && (quantity % terms.MinUnit) <> 0m then + Error(sprintf "quantity must be a multiple of %O shares" terms.MinUnit) + else + Ok quantity + + /// A stock cannot be sold on the same day it was bought (T+1): given a buy + /// date and a sell date, returns true only once settlement has elapsed. + let isSellAllowed (terms: StockTerms) (buyDate: DateOnly) (sellDate: DateOnly) : bool = + sellDate >= settlementDate terms buyDate (TimeOnly(0, 0)) |
