diff options
| -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 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundValuationTests.fs | 2 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockQuoteProbeTests.fs | 18 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockSellTests.fs | 2 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockTradeTests.fs | 3 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/StockRulesTests.fs | 62 |
12 files changed, 297 insertions, 17 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)) diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs index 197686d..409bfba 100644 --- a/tests/FundLab.Api.Tests/FundValuationTests.fs +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -7,7 +7,7 @@ open FundLab.Api module FundValuationSupport = let stockQuote (code: string) (price: decimal) : StockQuote = - { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY" } + { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY"; Market = Some "sh"; Adjust = Some "none"; Suspended = Some false } let bondQuote (code: string) (price: decimal) : BondQuote = { diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs index 2cad557..b869f17 100644 --- a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs @@ -58,6 +58,21 @@ type StockQuoteProbeTests(fixture: PostgresFixture) = | Error failure -> failwithf "expected a stock quote, got %A" failure [<Fact>] + member _.``probe parses market, adjust and suspension flags``() = + let json = + "{\"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\":\"000001\"},\"quote\":{\"name\":\"平安银行\",\"price\":\"11.2300\",\"currency\":\"CNY\",\"market\":\"sz\",\"adjust\":\"none\",\"suspended\":true}}" + + let probe = + AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe + + match probe.GetQuote("000001", CancellationToken.None) with + | Ok quote -> + Assert.Equal<string option>(Some "sz", quote.Market) + Assert.Equal<string option>(Some "none", quote.Adjust) + Assert.Equal<bool option>(Some true, quote.Suspended) + | Error failure -> failwithf "expected a stock quote, got %A" failure + + [<Fact>] member _.``probe keeps a missing name missing instead of fabricating data``() = let json = StockQuoteProbeSupport.payload "600519" None (Some "1252.57") @@ -139,6 +154,9 @@ type StockQuoteProbeTests(fixture: PostgresFixture) = Name = Some "贵州茅台" Price = Some 1252.57m Currency = "CNY" + Market = Some "sh" + Adjust = Some "none" + Suspended = Some false }) let status, body = diff --git a/tests/FundLab.Api.Tests/StockSellTests.fs b/tests/FundLab.Api.Tests/StockSellTests.fs index cbdef8b..972293a 100644 --- a/tests/FundLab.Api.Tests/StockSellTests.fs +++ b/tests/FundLab.Api.Tests/StockSellTests.fs @@ -6,7 +6,7 @@ open FundLab.Api module StockSellSupport = let quote (code: string) (price: decimal) : StockQuote = - { Code = code; Name = Some "招商银行"; Price = Some price; Currency = "CNY" } + { Code = code; Name = Some "招商银行"; Price = Some price; Currency = "CNY"; Market = Some "sh"; Adjust = Some "none"; Suspended = Some false } let stubQuoteProbe (quotes: Map<string, decimal>) = { new IStockQuoteProbe with diff --git a/tests/FundLab.Api.Tests/StockTradeTests.fs b/tests/FundLab.Api.Tests/StockTradeTests.fs index a2269a1..f8a8492 100644 --- a/tests/FundLab.Api.Tests/StockTradeTests.fs +++ b/tests/FundLab.Api.Tests/StockTradeTests.fs @@ -20,6 +20,9 @@ module StockTradeSupport = Name = Some "贵州茅台" Price = Some 1252.57m Currency = "CNY" + Market = Some "sh" + Adjust = Some "none" + Suspended = Some false } let appWithQuote (probe: IStockQuoteProbe) = diff --git a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj index 468ee16..c5b87a1 100644 --- a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj +++ b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj @@ -22,6 +22,7 @@ <Compile Include="DomainTests.fs" /> <Compile Include="InvestmentPlanTests.fs" /> <Compile Include="BondRulesTests.fs" /> + <Compile Include="StockRulesTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Domain.Tests/StockRulesTests.fs b/tests/FundLab.Domain.Tests/StockRulesTests.fs new file mode 100644 index 0000000..ddf943f --- /dev/null +++ b/tests/FundLab.Domain.Tests/StockRulesTests.fs @@ -0,0 +1,62 @@ +namespace FundLab.Domain.Tests + +module StockRulesTests = + + open System + open Xunit + open FundLab.Domain + + let private terms = StockTerms.aShareDefault + + [<Fact>] + let ``buy cost applies commission minimum and transfer fee`` () = + // gross 1000.00 -> commission 0.25 floors to 5.00, transfer 0.01. + Assert.Equal(1005.01m, StockRules.buyCost terms 100m 10m) + // gross 100000.00 -> commission 25.00 (above the minimum), transfer 1.00. + Assert.Equal(100026.00m, StockRules.buyCost terms 1000m 100m) + + [<Fact>] + let ``sell proceeds deduct commission, stamp duty and transfer fee`` () = + Assert.Equal(994.49m, StockRules.sellProceeds terms 100m 10m) + Assert.Equal(99924.00m, StockRules.sellProceeds terms 1000m 100m) + + [<Fact>] + let ``sell proceeds are lower than buy cost on a round trip`` () = + Assert.True(StockRules.sellProceeds terms 100m 10m < StockRules.buyCost terms 100m 10m) + + [<Fact>] + let ``quantity must be a whole multiple of the lot size`` () = + match StockRules.validateQuantity terms 100m with + | Ok value -> Assert.Equal(100m, value) + | Error message -> failwithf "expected a valid lot, got %s" message + + match StockRules.validateQuantity terms 50m with + | Ok _ -> failwith "expected a lot-size error" + | Error message -> Assert.Contains("multiple of 100", message) + + match StockRules.validateQuantity terms 0m with + | Ok _ -> failwith "expected a positive-quantity error" + | Error message -> Assert.Contains("positive", message) + + [<Fact>] + let ``settlement applies the cutoff then T+1 and skips the weekend`` () = + let thursday = DateOnly(2026, 9, 24) + + // Before the 15:00 cutoff: T+1 lands on Friday. + Assert.Equal(DateOnly(2026, 9, 25), StockRules.settlementDate terms thursday (TimeOnly(10, 0))) + + // At/after the cutoff: the order slips to Friday, T+1 lands on Monday. + Assert.Equal(DateOnly(2026, 9, 28), StockRules.settlementDate terms thursday (TimeOnly(15, 0))) + Assert.Equal(DateOnly(2026, 9, 28), StockRules.settlementDate terms thursday (TimeOnly(15, 30))) + + [<Fact>] + let ``effective trade date shifts to the next trading day after the cutoff`` () = + let thursday = DateOnly(2026, 9, 24) + Assert.Equal(thursday, StockRules.effectiveTradeDate terms thursday (TimeOnly(14, 59))) + Assert.Equal(DateOnly(2026, 9, 25), StockRules.effectiveTradeDate terms thursday (TimeOnly(15, 0))) + + [<Fact>] + let ``a stock bought today cannot be sold until T+1`` () = + let thursday = DateOnly(2026, 9, 24) + Assert.False(StockRules.isSellAllowed terms thursday thursday) + Assert.True(StockRules.isSellAllowed terms thursday (DateOnly(2026, 9, 25))) |
