diff --git a/sanguo_trader/shadow/broker.py b/sanguo_trader/shadow/broker.py index 79203d7..b9be691 100644 --- a/sanguo_trader/shadow/broker.py +++ b/sanguo_trader/shadow/broker.py @@ -118,15 +118,34 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du return None # ===== 行情 ===== - def _ref_price(self, security: str, price: Optional[float]) -> Optional[float]: - ref = price if price and price > 0 else None - if ref is None and self.price_getter is not None: + def _ref_price(self, security: str, price: Optional[float], + market: bool = False) -> Optional[float]: + """成交参考价。 + + 市价单(market=True)成交基准=实时行情(price_getter):下单带的 price 只是 + 保护性委托上限(策略侧行情×1.015,BrokerBase 契注明"可用作保护价/参考价"), + 按它记账会让影子恒比实盘贵~1.5%(2026-08-24 双轨 4/6 红根因:momentum/ + small_cap 首次真实成交日 25 票价差紧聚 -150bps+级联现金虚耗致 002038 + 资金不足漏买)。行情不可得才退回委托价(告警,可用性优先)。 + 限价单沿用传入价(触价语义,不取行情,与旧行为一致),缺价时行情兜底。 + """ + if not market and price is not None and price > 0: + return price + if self.price_getter is not None: try: - ref = self.price_getter(security) + quote = self.price_getter(security) except Exception as exc: # noqa: BLE001 - 行情失败拒单而非崩柜台 logger.warning("[shadow] 取价失败 %s: %s", security, exc) - ref = None - return ref + quote = None + if quote is not None and quote > 0: + return quote + if price is not None and price > 0: + if market: + logger.warning( + "[shadow] %s 市价单无实时行情,退回委托价 %.2f(保护上限口径)", + security, price) + return price + return None def _limit_blocked(self, security: str, side: str) -> Optional[str]: """涨跌停/停牌拒单原因(P1.3,双轨对账与实盘约束对齐)。 @@ -156,9 +175,12 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du wait_timeout: Optional[float] = None, remark: Optional[str] = None, *, market: bool = False) -> str: order_id = self._new_order("buy", security, amount, price) - ref = self._ref_price(security, price) + ref = self._ref_price(security, price, market=market) if ref is None or ref <= 0: return self._reject(order_id, "无参考价") + # 市价转限价语义:决策到成交间快速拉升、行情已超保护上限 → 按上限成交,不追高 + if market and price is not None and price > 0 and ref > price: + ref = price blocked = self._limit_blocked(security, "buy") if blocked: return self._reject(order_id, blocked) @@ -186,9 +208,12 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du wait_timeout: Optional[float] = None, remark: Optional[str] = None, *, market: bool = False) -> str: order_id = self._new_order("sell", security, amount, price) - ref = self._ref_price(security, price) + ref = self._ref_price(security, price, market=market) if ref is None or ref <= 0: return self._reject(order_id, "无参考价") + # 市价转限价语义(卖侧对称):行情已跌破保护下限 → 按下限成交,不杀跌 + if market and price is not None and price > 0 and ref < price: + ref = price blocked = self._limit_blocked(security, "sell") if blocked: return self._reject(order_id, blocked) diff --git a/tests/trader/test_shadow_broker.py b/tests/trader/test_shadow_broker.py index 51fc744..7a0ead0 100644 --- a/tests/trader/test_shadow_broker.py +++ b/tests/trader/test_shadow_broker.py @@ -23,12 +23,12 @@ def _mk_broker(cash: float = 100_000.0, **kw) -> ShadowBroker: ) -def _buy(b: ShadowBroker, sec: str, amt: int, px: float | None = None): - return asyncio.run(b.buy(sec, amt, px)) +def _buy(b: ShadowBroker, sec: str, amt: int, px: float | None = None, **kw): + return asyncio.run(b.buy(sec, amt, px, **kw)) -def _sell(b: ShadowBroker, sec: str, amt: int, px: float | None = None): - return asyncio.run(b.sell(sec, amt, px)) +def _sell(b: ShadowBroker, sec: str, amt: int, px: float | None = None, **kw): + return asyncio.run(b.sell(sec, amt, px, **kw)) def test_buy_fills_with_commission_and_slippage(): @@ -97,6 +97,55 @@ def test_no_price_rejects(): assert "无参考价" in b.orders[oid]["reject_reason"] +# ---- 市价单成交基准=实时行情(2026-08-24 双轨 4/6 红根因回归) ---- + +def test_market_buy_fills_at_quote_not_protective_price(): + """市价单带的 price 是保护上限(策略侧行情×1.015),成交基准必须是实时行情。 + + 08-24 实锤:momentum/small_cap 首次真实成交日,影子按保护价记账 → 25 票 + 价差紧聚 -150bps + 级联现金虚耗 → 002038 影子资金不足漏买,双轨 4/6 红。 + BrokerBase 契约(bullet_trade/broker/base.py):market=True 时 price 亦视为 + 市价,可用作保护价/参考价——不是期望成交价。 + """ + b = _mk_broker(cash=1_000_000, slippage=0.0) + oid = _buy(b, "600519.SH", 100, px=101.5, market=True) + assert b.orders[oid]["status"] == "filled" + assert b.orders[oid]["filled_price"] == pytest.approx(100.0) # 行情价,非 101.5 + + +def test_market_sell_fills_at_quote_not_protective_floor(): + """卖侧对称:保护下限不作成交价(08-24 实锤 510500 影子卖 7.64 vs 实盘 7.76)。""" + b = _mk_broker(cash=1_000_000, slippage=0.0) + _buy(b, "600519.SH", 100, px=100.0) + b.before_open() + oid = _sell(b, "600519.SH", 100, px=98.5, market=True) + assert b.orders[oid]["status"] == "filled" + assert b.orders[oid]["filled_price"] == pytest.approx(100.0) # 行情价,非 98.5 + + +def test_market_buy_quote_beyond_protective_cap_fills_at_cap(): + """行情已超保护上限(决策到成交间快速拉升):按上限成交(市价转限价语义),不追高。""" + b = _mk_broker(cash=1_000_000, slippage=0.0, prices={"600519.SH": 105.0}) + oid = _buy(b, "600519.SH", 100, px=101.5, market=True) + assert b.orders[oid]["filled_price"] == pytest.approx(101.5) + + +def test_market_order_no_quote_falls_back_to_protective_price(): + """行情不可得:退回委托价(告警)而非拒单——可用性优先,影子不因缺行情停摆。""" + b = _mk_broker(cash=1_000_000, slippage=0.0, prices={}) + oid = _buy(b, "600519.SH", 100, px=101.5, market=True) + assert b.orders[oid]["status"] == "filled" + assert b.orders[oid]["filled_price"] == pytest.approx(101.5) + + +def test_limit_order_keeps_passed_price_semantics(): + """限价单(market=False)沿用传入价——触价语义不变。""" + b = _mk_broker(cash=1_000_000, slippage=0.0) + oid = _buy(b, "600519.SH", 100, px=99.0) + assert b.orders[oid]["status"] == "filled" + assert b.orders[oid]["filled_price"] == pytest.approx(99.0) + + # ---- P1.3 涨跌停/停牌拒单(双轨对账:与实盘 QMT 约束对齐,减少对账噪音) ---- def _limit_map_getter(status: dict):