summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 08:58:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 08:58:52 +0800
commit72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15 (patch)
tree8d1bf9285314eb37ce47525e4d0c052fcdf08ca8 /src
parentd4b0c26b396bfcf1be029d8db3a1c0fc033a6765 (diff)
downloadfund-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.fs8
-rw-r--r--src/FundLab.Api/MarketData.fs37
-rw-r--r--src/FundLab.Api/StockQuoteProbe.fs9
-rw-r--r--src/FundLab.Api/akshare_collector.py61
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/StockRules.fs110
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))