diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index 3ba236a..84eb50e 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -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) diff --git a/sanguo_trader/limit.py b/sanguo_trader/limit.py index 5085349..6d2dd2f 100644 --- a/sanguo_trader/limit.py +++ b/sanguo_trader/limit.py @@ -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 diff --git a/sanguo_trader/matcher.py b/sanguo_trader/matcher.py index 5891183..3f19e8f 100644 --- a/sanguo_trader/matcher.py +++ b/sanguo_trader/matcher.py @@ -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,卖 -= slippage;H2) 6. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费双向 H3) """ @@ -92,12 +94,18 @@ 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: - return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", bar_date) + 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 不影响) if order.side == OrderSide.BUY: diff --git a/tests/trader/test_matcher.py b/tests/trader/test_matcher.py index 451f6fc..cd6d019 100644 --- a/tests/trader/test_matcher.py +++ b/tests/trader/test_matcher.py @@ -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