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
|
import pandas as pd
|
||||||
|
|
||||||
|
from .models import AccountConfig
|
||||||
|
|
||||||
|
|
||||||
# ---- 板块分类(按代码前缀)----
|
# ---- 板块分类(按代码前缀)----
|
||||||
def get_board(symbol: str) -> str:
|
def get_board(symbol: str) -> str:
|
||||||
@@ -100,11 +102,18 @@ def is_locked_for_buy_symbol(
|
|||||||
bar: pd.Series,
|
bar: pd.Series,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
prev_close_raw: float,
|
prev_close_raw: float,
|
||||||
cfg: "object",
|
cfg: AccountConfig,
|
||||||
is_st: bool = False,
|
is_st: bool = False,
|
||||||
|
listing_days: int = 0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""涨停封板(一字板或 T 字板)→ 买单拒单。"""
|
"""涨停封板(一字板或 T 字板)→ 买单拒单。
|
||||||
|
|
||||||
|
新股前 5 日无涨跌幅限制(创业/科创/北交所):listing_days<5 → 不锁。
|
||||||
|
主板新股首日 ±44% 首版未实现(TODO),按普通 ±10% 处理。
|
||||||
|
"""
|
||||||
board = get_board(symbol)
|
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)
|
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)
|
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,
|
bar: pd.Series,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
prev_close_raw: float,
|
prev_close_raw: float,
|
||||||
cfg: "object",
|
cfg: AccountConfig,
|
||||||
is_st: bool = False,
|
is_st: bool = False,
|
||||||
|
listing_days: int = 0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""跌停封板(一字板或跌停 T 字板)→ 卖单拒单。"""
|
"""跌停封板(一字板或跌停 T 字板)→ 卖单拒单。
|
||||||
|
|
||||||
|
新股前 5 日无涨跌幅限制(创业/科创/北交所):listing_days<5 → 不锁。
|
||||||
|
"""
|
||||||
board = get_board(symbol)
|
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)
|
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)
|
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
|
symbol = order.symbol
|
||||||
bar_date = str(match_bar.get("date", ""))
|
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)
|
# 1. 涨跌停封板拒单(raw)
|
||||||
if order.side == OrderSide.BUY and is_locked_for_buy_symbol(
|
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)
|
return PaperReject(order.strategy_id, symbol, "limit_up_locked", bar_date)
|
||||||
if order.side == OrderSide.SELL and is_locked_for_sell_symbol(
|
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)
|
return PaperReject(order.strategy_id, symbol, "limit_down_locked", bar_date)
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -33,7 +33,7 @@ class AccountConfig:
|
|||||||
rate: float = 0.0003 # 佣金率
|
rate: float = 0.0003 # 佣金率
|
||||||
min_commission: float = 5.0 # 最低佣金 5 元
|
min_commission: float = 5.0 # 最低佣金 5 元
|
||||||
stamp_duty_rate: float = 0.0005 # 印花税(仅卖,2023.8.28 起 0.05%)
|
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
|
slippage: float = 0.0
|
||||||
pricetick: float = 0.01
|
pricetick: float = 0.01
|
||||||
size: float = 1.0
|
size: float = 1.0
|
||||||
@@ -41,7 +41,11 @@ class AccountConfig:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PaperOrder:
|
class PaperOrder:
|
||||||
"""策略下单请求。match_session 决定撮合时点。"""
|
"""策略下单请求。match_session 决定撮合时点。
|
||||||
|
|
||||||
|
listing_days:上市天数(新股涨跌停特判,0=已过新股期;spec §6.2)。
|
||||||
|
current_close 契约:策略 on_bar 内不得访问当根 close/high/low,否则前瞻偏差。
|
||||||
|
"""
|
||||||
|
|
||||||
strategy_id: str
|
strategy_id: str
|
||||||
symbol: str
|
symbol: str
|
||||||
@@ -50,11 +54,16 @@ class PaperOrder:
|
|||||||
volume: int
|
volume: int
|
||||||
is_market: bool = True
|
is_market: bool = True
|
||||||
match_session: MatchSession = MatchSession.NEXT_OPEN
|
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)
|
@dataclass(frozen=True)
|
||||||
class PaperTrade:
|
class PaperTrade:
|
||||||
"""已成交记录(含费用拆分)。transfer_fee 为单边,Account 扣款时 ×2。"""
|
"""已成交记录(含费用拆分)。transfer_fee 为沪深双向总额(review H3,Account 不再 ×2)。"""
|
||||||
|
|
||||||
strategy_id: str
|
strategy_id: str
|
||||||
symbol: str
|
symbol: str
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class PositionLedger:
|
|||||||
|
|
||||||
def apply_buy(self, price: float, volume: int) -> None:
|
def apply_buy(self, price: float, volume: int) -> None:
|
||||||
"""买入:刷新移动加权均价,新买入量计入 frozen(T+1)。"""
|
"""买入:刷新移动加权均价,新买入量计入 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
|
total_cost = self.avg_price * self.volume + price * volume
|
||||||
self.volume += volume
|
self.volume += volume
|
||||||
self.avg_price = total_cost / self.volume if self.volume else 0.0
|
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:
|
def apply_sell(self, price: float, volume: int) -> None:
|
||||||
"""卖出:扣减持仓量。price 保留接口对称(不影响剩余持仓均价)。
|
"""卖出:扣减持仓量。price 保留接口对称(不影响剩余持仓均价)。
|
||||||
清仓时 avg_price 归零(避免下一次买入残留历史成本)。"""
|
清仓时 avg_price 归零(避免下一次买入残留历史成本)。"""
|
||||||
|
if volume <= 0 or price <= 0:
|
||||||
|
raise ValueError(f"price/volume 必须为正: price={price}, volume={volume}")
|
||||||
if volume > self.available:
|
if volume > self.available:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"卖出超过可卖量: want {volume}, available {self.available}"
|
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