fix(trader): M+L 接口校验 (listing_days/NaN/输入校验/类型注数) review

M1: PaperOrder+limit+matcher 加 listing_days(创业/科创/北交所前5日不锁,0=已过)
M3: is_locked_for_*_symbol cfg 注解 AccountConfig
M4: matcher NaN bar 拒单 bar_missing
L3: PositionLedger price/volume 正数校验
L5: PaperOrder __post_init__ volume 类型校验(拒 float/bool)
M5: current_close 契约 docstring + H3 残留注释修正(transfer_fee 双向)
79 tests passed.
This commit is contained in:
2026-07-07 11:23:16 +08:00
parent b874be1d84
commit 05d74fc2c1
5 changed files with 126 additions and 9 deletions
+19 -4
View File
@@ -16,6 +16,8 @@ from decimal import ROUND_HALF_UP, Decimal
import pandas as pd
from .models import AccountConfig
# ---- 板块分类(按代码前缀)----
def get_board(symbol: str) -> str:
@@ -100,11 +102,18 @@ def is_locked_for_buy_symbol(
bar: pd.Series,
symbol: str,
prev_close_raw: float,
cfg: "object",
cfg: AccountConfig,
is_st: bool = False,
listing_days: int = 0,
) -> bool:
"""涨停封板(一字板或 T 字板)→ 买单拒单。"""
"""涨停封板(一字板或 T 字板)→ 买单拒单。
新股前 5 日无涨跌幅限制(创业/科创/北交所):listing_days<5 → 不锁。
主板新股首日 ±44% 首版未实现(TODO),按普通 ±10% 处理。
"""
board = get_board(symbol)
if 1 <= listing_days <= 5 and board in ("gem", "star", "bse"):
return False
up = limit_up_price(prev_close_raw, limit_ratio(board, is_st), cfg.pricetick)
return is_one_word_lock(bar, up, cfg.pricetick) or is_t_lock(bar, up, cfg.pricetick)
@@ -113,10 +122,16 @@ def is_locked_for_sell_symbol(
bar: pd.Series,
symbol: str,
prev_close_raw: float,
cfg: "object",
cfg: AccountConfig,
is_st: bool = False,
listing_days: int = 0,
) -> bool:
"""跌停封板(一字板或跌停 T 字板)→ 卖单拒单。"""
"""跌停封板(一字板或跌停 T 字板)→ 卖单拒单。
新股前 5 日无涨跌幅限制(创业/科创/北交所):listing_days<5 → 不锁。
"""
board = get_board(symbol)
if 1 <= listing_days <= 5 and board in ("gem", "star", "bse"):
return False
down = limit_down_price(prev_close_raw, limit_ratio(board, is_st), cfg.pricetick)
return is_one_word_lock(bar, down, cfg.pricetick) or _is_t_lock_down(bar, down, cfg.pricetick)
+7 -2
View File
@@ -50,13 +50,18 @@ def cross_order(
symbol = order.symbol
bar_date = str(match_bar.get("date", ""))
# 0. 停牌/缺 barNaN)拒单(review M4
_open = match_bar["open"]
if _open != _open: # NaN 检测(NaN != NaN
return PaperReject(order.strategy_id, symbol, "bar_missing", bar_date)
# 1. 涨跌停封板拒单(raw
if order.side == OrderSide.BUY and is_locked_for_buy_symbol(
match_bar, symbol, prev_close_raw, cfg, is_st
match_bar, symbol, prev_close_raw, cfg, is_st, order.listing_days
):
return PaperReject(order.strategy_id, symbol, "limit_up_locked", bar_date)
if order.side == OrderSide.SELL and is_locked_for_sell_symbol(
match_bar, symbol, prev_close_raw, cfg, is_st
match_bar, symbol, prev_close_raw, cfg, is_st, order.listing_days
):
return PaperReject(order.strategy_id, symbol, "limit_down_locked", bar_date)
+12 -3
View File
@@ -33,7 +33,7 @@ class AccountConfig:
rate: float = 0.0003 # 佣金率
min_commission: float = 5.0 # 最低佣金 5 元
stamp_duty_rate: float = 0.0005 # 印花税(仅卖,2023.8.28 起 0.05%
transfer_fee_rate: float = 0.00001 # 过户费率(沪深双向 ×2,由 Account 计算
transfer_fee_rate: float = 0.00001 # 过户费率(沪深双向matcher 出 ×2 总额,review H3
slippage: float = 0.0
pricetick: float = 0.01
size: float = 1.0
@@ -41,7 +41,11 @@ class AccountConfig:
@dataclass(frozen=True)
class PaperOrder:
"""策略下单请求。match_session 决定撮合时点。"""
"""策略下单请求。match_session 决定撮合时点。
listing_days:上市天数(新股涨跌停特判,0=已过新股期;spec §6.2)。
current_close 契约:策略 on_bar 内不得访问当根 close/high/low,否则前瞻偏差。
"""
strategy_id: str
symbol: str
@@ -50,11 +54,16 @@ class PaperOrder:
volume: int
is_market: bool = True
match_session: MatchSession = MatchSession.NEXT_OPEN
listing_days: int = 0
def __post_init__(self) -> None:
if not isinstance(self.volume, int) or isinstance(self.volume, bool):
raise TypeError(f"volume 必须是 int,收到 {type(self.volume).__name__}")
@dataclass(frozen=True)
class PaperTrade:
"""已成交记录(含费用拆分)。transfer_fee 为单边Account 扣款时 ×2。"""
"""已成交记录(含费用拆分)。transfer_fee 为沪深双向总额(review H3Account 不再 ×2"""
strategy_id: str
symbol: str
+4
View File
@@ -19,6 +19,8 @@ class PositionLedger:
def apply_buy(self, price: float, volume: int) -> None:
"""买入:刷新移动加权均价,新买入量计入 frozen(T+1)。"""
if volume <= 0 or price <= 0:
raise ValueError(f"price/volume 必须为正: price={price}, volume={volume}")
total_cost = self.avg_price * self.volume + price * volume
self.volume += volume
self.avg_price = total_cost / self.volume if self.volume else 0.0
@@ -27,6 +29,8 @@ class PositionLedger:
def apply_sell(self, price: float, volume: int) -> None:
"""卖出:扣减持仓量。price 保留接口对称(不影响剩余持仓均价)。
清仓时 avg_price 归零(避免下一次买入残留历史成本)。"""
if volume <= 0 or price <= 0:
raise ValueError(f"price/volume 必须为正: price={price}, volume={volume}")
if volume > self.available:
raise ValueError(
f"卖出超过可卖量: want {volume}, available {self.available}"
+84
View File
@@ -0,0 +1,84 @@
"""C-S0 review M+L 修复测试:listing_days / NaN bar / 输入校验 / volume 类型。"""
import pandas as pd
import pytest
from sanguo_trader.models import AccountConfig, PaperOrder, OrderSide
from sanguo_trader.limit import is_locked_for_buy_symbol
from sanguo_trader.matcher import cross_order
from sanguo_trader.position_ledger import PositionLedger
CFG = AccountConfig(initial_capital=1_000_000)
def bar(o, h, l, c):
return pd.Series({"open": o, "high": h, "low": l, "close": c})
# ---- M1: listing_days 新股前 5 日无涨跌幅(创业/科创/北交所)----
def test_new_stock_gem_first_5_days_no_limit():
# 创业板 300750 第 1 日,一字涨停板也不锁
b = bar(12.0, 12.0, 12.0, 12.0)
assert is_locked_for_buy_symbol(b, "300750", 10.0, CFG, is_st=False, listing_days=1) is False
def test_new_stock_after_5_days_locked():
# 创业板第 6 日恢复 ±20%,涨停一字板锁
b = bar(12.0, 12.0, 12.0, 12.0)
assert is_locked_for_buy_symbol(b, "300750", 10.0, CFG, is_st=False, listing_days=6) is True
def test_main_board_new_stock_still_locked_by_10pct():
# 主板新股不享受前 5 日豁免(±44% 首版未实现,按普通 ±10% 锁)
up = 11.0
b = bar(up, up, up, up)
assert is_locked_for_buy_symbol(b, "600000", 10.0, CFG, is_st=False, listing_days=1) is True
def test_matcher_passes_listing_days_to_unlock():
# 端到端:创业板新股 listing_days=1,一字板 matcher 不拒单(成交)
b = bar(12.0, 12.0, 12.0, 12.0)
o = PaperOrder("s", "300750", OrderSide.BUY, 0, 100, is_market=True, listing_days=1)
t = cross_order(o, b, 10.0, CFG)
assert not hasattr(t, "reason") # 成交(非拒单)
assert t.price == 12.0
# ---- M4: NaN bar 拒单 ----
def test_nan_bar_rejected_bar_missing():
nan = float("nan")
b = bar(nan, nan, nan, nan)
r = cross_order(PaperOrder("s", "600000", OrderSide.BUY, 0, 100, True), b, 10.0, CFG)
assert hasattr(r, "reason") and r.reason == "bar_missing"
# ---- L3: PositionLedger 输入校验 ----
def test_position_ledger_rejects_nonpositive_buy():
p = PositionLedger("600000")
with pytest.raises(ValueError):
p.apply_buy(price=-1, volume=100)
with pytest.raises(ValueError):
p.apply_buy(price=10, volume=0)
def test_position_ledger_rejects_nonpositive_sell():
p = PositionLedger("600000")
p.apply_buy(10.0, 100)
p.unfreeze()
with pytest.raises(ValueError):
p.apply_sell(price=0, volume=100)
# ---- L5: PaperOrder volume 类型校验 ----
def test_paper_order_rejects_float_volume():
with pytest.raises(TypeError):
PaperOrder("s", "600000", OrderSide.BUY, 10.0, 100.5, True)
def test_paper_order_rejects_bool_volume():
with pytest.raises(TypeError):
PaperOrder("s", "600000", OrderSide.BUY, 10.0, True, True)
def test_paper_order_accepts_int_volume_and_default_listing_days():
o = PaperOrder("s", "600000", OrderSide.BUY, 10.0, 100, True)
assert o.volume == 100
assert o.listing_days == 0