feat(trader): 科创板200股最小手数(分期项切片)—lot_size_for+matcher板块取整

科创板(688/689)最小200股1股递增(不整倍); 主板/创业/北交100整倍; 卖出不取整。
- limit.py 加 lot_size_for(symbol)
- matcher cross_order 买入取整按板块(star≥200不取整, 其余100整倍)
- routes_paper cta size=lot_size_for(symbol)
- test: 688981 买150拒/买250不取整; 主板用例不变; 63/63通过
This commit is contained in:
2026-07-07 23:37:50 +08:00
parent 1ed7b72aca
commit 05dba7fe46
4 changed files with 47 additions and 9 deletions
+3 -1
View File
@@ -145,8 +145,10 @@ def _run_replay(db, aid, req: PaperCreateRequest):
cls = get_strategy_class(s.name)
if cls is None:
continue # 策略不可用(本机无 vnpy_ctastrategy)→ 跳过
from sanguo_trader.limit import lot_size_for
cta = PaperCtaEngine(s.name, match_session=s.match_session,
listing_days=s.listing_days, size=100) # A 股 1 手=100 股
listing_days=s.listing_days,
size=lot_size_for(s.symbol)) # 主板 100 / 科创 200 股一手
vt_symbol = f"{s.symbol}.{guess_exchange(s.symbol).value}"
strat = cls(cta, s.name, vt_symbol, s.params) # CtaTemplate(cta_engine, name, vt_symbol, setting)
strat.trading = True # 允许 send_order(等价 on_start
+5
View File
@@ -31,6 +31,11 @@ def get_board(symbol: str) -> str:
return "main" # 沪/深主板
def lot_size_for(symbol: str) -> int:
"""最小交易手数对应股数:科创板 200(1 股递增),其余板块 100。"""
return 200 if get_board(symbol) == "star" else 100
_LIMIT_RATIO = {"main": 0.10, "gem": 0.20, "star": 0.20, "bse": 0.30}
_ST_RATIO = 0.05
+15 -7
View File
@@ -1,4 +1,4 @@
"""A 股撮合纯函数(match_session / 费率 / 100 股 / 封板)。
"""A 股撮合纯函数(match_session / 费率 / 100·200 股 / 封板)。
match_bar 必须是 raw 价格(spec §3.3 / §6.2)。所有费率来自 AccountConfig。
拒单返回 PaperReject;成交返回 PaperTrade。
@@ -7,6 +7,9 @@ match_bar 必须是 raw 价格(spec §3.3 / §6.2)。所有费率来自 Acco
费用口径(review H3 修正):
- transfer_fee 直接出**双向总额**(沪深买卖均收),Account 不再 ×2。
- stamp_duty 仅卖;commission 最低 5 元。
最小手数(分期项):科创板 200 股起、1 股递增(不整倍);
主板/创业/北交所 100 股整倍。卖出不取整(允许零股退出)。
"""
import pandas as pd
@@ -17,6 +20,7 @@ from .limit import (
limit_down_price,
limit_ratio,
limit_up_price,
lot_size_for,
)
from .models import (
AccountConfig,
@@ -27,8 +31,6 @@ from .models import (
PaperTrade,
)
MIN_LOT = 100
def cross_order(
order: PaperOrder,
@@ -43,7 +45,7 @@ def cross_order(
1. 涨跌停封板拒单(raw,按板块幅度)
2. 成交价(NEXT_OPEN=bar.open / CURRENT_CLOSE=bar.close
3. 限价单触价检查(含 H4:限价超涨停/跌停先拒)
4. 100 股取整(买入向下取整;卖出允许零股
4. 买入最小手数(科创 200 起 1 股递增;其余 100 整倍;卖出不取整
5. 滑点(买 += slippage,卖 -= slippageH2
6. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费双向 H3)
"""
@@ -92,11 +94,17 @@ def cross_order(
if order.side == OrderSide.SELL and fill_price < order.price:
return PaperReject(order.strategy_id, symbol, "limit_not_touched", bar_date)
# 4. 100 股取整(买入向下取整;卖出不取整,允许零股退出
# 4. 买入最小手数(科创 200 起 1 股递增;其余 100 整倍;卖出不取整
volume = order.volume
if order.side == OrderSide.BUY:
volume = (volume // MIN_LOT) * MIN_LOT
if volume < MIN_LOT:
min_lot = lot_size_for(symbol)
if get_board(symbol) == "star":
# 科创板:≥200,1 股递增(不整倍)
if volume < min_lot:
return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", bar_date)
else:
volume = (volume // min_lot) * min_lot
if volume < min_lot:
return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", bar_date)
# 5. 滑点(H2;默认 0 不影响)
+23
View File
@@ -174,6 +174,29 @@ def test_sell_odd_lot_allowed():
assert isinstance(t, PaperTrade) and t.volume == 50
# ---- 科创板 200 股最小手数(分期项)----
def test_star_buy_below_200_rejected():
"""科创板 688981 买 150 股 < 200 → 拒单(200 股起)。"""
r = cross_order(
PaperOrder("s1", "688981", OrderSide.BUY, 0, 150, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "volume_below_min_lot"
def test_star_buy_250_not_rounded_to_200():
"""科创板 688981 买 250 股 → ≥200,1 股递增不取整,成交 250(非主板 100 整倍)。"""
t = cross_order(
PaperOrder("s1", "688981", OrderSide.BUY, 0, 250, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(t, PaperTrade) and t.volume == 250
# ---- 费用 ----
def test_commission_uses_min_5_yuan():
# 100 股 × 10 元 × 0.0003 = 0.3 → 不足 5 元,收 5