"""A 股撮合纯函数(match_session / 费率 / 100·200 股 / 封板)。 match_bar 必须是 raw 价格(spec §3.3 / §6.2)。所有费率来自 AccountConfig。 拒单返回 PaperReject;成交返回 PaperTrade。 资金检查由 Account 在 apply 前负责(matcher 不看资金)。 费用口径(review H3 修正): - transfer_fee 直接出**双向总额**(沪深买卖均收),Account 不再 ×2。 - stamp_duty 仅卖;commission 最低 5 元。 最小手数(分期项):科创板 200 股起、1 股递增(不整倍); 主板/创业/北交所 100 股整倍。卖出不取整(允许零股退出)。 """ import pandas as pd from .limit import ( get_board, is_locked_for_buy_symbol, is_locked_for_sell_symbol, limit_down_price, limit_ratio, limit_up_price, lot_size_for, ) from .models import ( AccountConfig, MatchSession, OrderSide, PaperOrder, PaperReject, PaperTrade, ) def cross_order( order: PaperOrder, match_bar: pd.Series, prev_close_raw: float, cfg: AccountConfig, is_st: bool = False, ) -> PaperTrade | PaperReject: """单笔订单撮合。 步骤: 1. 涨跌停封板拒单(raw,按板块幅度) 2. 成交价(NEXT_OPEN=bar.open / CURRENT_CLOSE=bar.close) 3. 限价单触价检查(含 H4:限价超涨停/跌停先拒) 4. 买入最小手数(科创 200 起 1 股递增;其余 100 整倍;卖出不取整) 5. 滑点(买 += slippage,卖 -= slippage;H2) 6. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费双向 H3) """ 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, 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, order.listing_days ): return PaperReject(order.strategy_id, symbol, "limit_down_locked", bar_date) # 2. 成交价(按 match_session) if order.match_session == MatchSession.NEXT_OPEN: fill_price = float(match_bar["open"]) elif order.match_session == MatchSession.CURRENT_CLOSE: fill_price = float(match_bar["close"]) elif order.match_session == MatchSession.CALL_AUCTION: # 集合竞价开盘价(最大成交量原则 → open)。当前同 NEXT_OPEN 定价; # 未来区分开盘/尾盘集合竞价需扩枚举(CALL_AUCTION_OPEN/CLOSE)。 fill_price = float(match_bar["open"]) else: return PaperReject( order.strategy_id, symbol, "unsupported_match_session", bar_date ) # 3. 限价单:先查涨跌停边界(H4),再查触价 if not order.is_market: board = get_board(symbol) ratio = limit_ratio(board, is_st) up = limit_up_price(prev_close_raw, ratio, cfg.pricetick) down = limit_down_price(prev_close_raw, ratio, cfg.pricetick) # H4:限价超涨停 / 跌停拒单(== 边界允许,> / < 才拒) if order.side == OrderSide.BUY and order.price > up: return PaperReject(order.strategy_id, symbol, "price_above_limit", bar_date) if order.side == OrderSide.SELL and order.price < down: return PaperReject(order.strategy_id, symbol, "price_below_limit", bar_date) # 触价(用原始 fill_price,slippage 不影响触价判断) if order.side == OrderSide.BUY and fill_price > order.price: return PaperReject(order.strategy_id, symbol, "limit_not_touched", bar_date) if order.side == OrderSide.SELL and fill_price < order.price: return PaperReject(order.strategy_id, symbol, "limit_not_touched", bar_date) # 4. 买入最小手数(科创 200 起 1 股递增;其余 100 整倍;卖出不取整) volume = order.volume if order.side == OrderSide.BUY: 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: fill_price += cfg.slippage else: fill_price -= cfg.slippage # 6. 费用(transfer_fee 双向,H3) gross = volume * fill_price commission = max(gross * cfg.rate, cfg.min_commission) stamp_duty = gross * cfg.stamp_duty_rate if order.side == OrderSide.SELL else 0.0 transfer_fee = gross * cfg.transfer_fee_rate * 2 # 沪深双向 return PaperTrade( strategy_id=order.strategy_id, symbol=symbol, side=order.side, price=fill_price, volume=volume, commission=commission, stamp_duty=stamp_duty, transfer_fee=transfer_fee, bar_date=bar_date, match_session=order.match_session, )