From 05d74fc2c1a67c86b958031188de16d2c3aefeff Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 7 Jul 2026 11:23:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(trader):=20M+L=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20(listing=5Fdays/NaN/=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C/=E7=B1=BB=E5=9E=8B=E6=B3=A8=E6=95=B0)=20revi?= =?UTF-8?q?ew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sanguo_trader/limit.py | 23 +++++++-- sanguo_trader/matcher.py | 9 +++- sanguo_trader/models.py | 15 ++++-- sanguo_trader/position_ledger.py | 4 ++ tests/trader/test_m_l_fixes.py | 84 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 tests/trader/test_m_l_fixes.py diff --git a/sanguo_trader/limit.py b/sanguo_trader/limit.py index b39fb33..5085349 100644 --- a/sanguo_trader/limit.py +++ b/sanguo_trader/limit.py @@ -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) diff --git a/sanguo_trader/matcher.py b/sanguo_trader/matcher.py index 7cb3c11..5891183 100644 --- a/sanguo_trader/matcher.py +++ b/sanguo_trader/matcher.py @@ -50,13 +50,18 @@ def cross_order( symbol = order.symbol bar_date = str(match_bar.get("date", "")) + # 0. 停牌/缺 bar(NaN)拒单(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) diff --git a/sanguo_trader/models.py b/sanguo_trader/models.py index 93cf6db..7153eee 100644 --- a/sanguo_trader/models.py +++ b/sanguo_trader/models.py @@ -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 H3,Account 不再 ×2)。""" strategy_id: str symbol: str diff --git a/sanguo_trader/position_ledger.py b/sanguo_trader/position_ledger.py index c88cb6c..0aabd04 100644 --- a/sanguo_trader/position_ledger.py +++ b/sanguo_trader/position_ledger.py @@ -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}" diff --git a/tests/trader/test_m_l_fixes.py b/tests/trader/test_m_l_fixes.py new file mode 100644 index 0000000..94f82cd --- /dev/null +++ b/tests/trader/test_m_l_fixes.py @@ -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