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:
+19
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user