"""影子柜台本地模拟 broker(P1,docs/design/paper-shadow-desk-design.md §3.2)。 挂在 bullet_trade LiveEngine 的 broker_factory 上:策略下单不出门, 由本 broker 以「下单时刻实时价 ± 滑点」本地撮合,A 股费用/整手/T+1 对齐。 与实盘(QmtBroker)同接口(BrokerBase) → 同一个 LiveEngine 两种柜台, 这是「双轨一致性验证」(§8)的基础:同策略同参数分别接真/假 broker 并跑对账。 价格来源由 price_getter 注入(通常=数据 provider 最新收盘/实时价), 成交回调 on_trade 注入(落 paper_trades 表)。 """ from __future__ import annotations import logging import uuid from datetime import datetime from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) LOT = 100 # A 股整手 class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade duck-typed) """本地虚拟账户撮合台。不继承 BrokerBase(避免硬依赖 bullet_trade import 顺序), LiveEngine 按 duck-typed 协议调用。""" def __init__( self, initial_cash: float = 1_000_000.0, *, commission_rate: float = 0.0003, stamp_duty_rate: float = 0.001, min_commission: float = 5.0, slippage: float = 0.0, price_getter: Optional[Callable[[str], Optional[float]]] = None, limit_getter: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, on_trade: Optional[Callable[[Dict[str, Any]], None]] = None, now_provider: Optional[Callable[[], datetime]] = None, ) -> None: self.initial_cash = float(initial_cash) self.cash = float(initial_cash) self.commission_rate = float(commission_rate) self.stamp_duty_rate = float(stamp_duty_rate) self.min_commission = float(min_commission) self.slippage = float(slippage) self.price_getter = price_getter self.limit_getter = limit_getter self.on_trade = on_trade self._now = now_provider or datetime.now self._connected = True # 本地柜台永远"在线" # security -> {"amount": int, "avg_cost": float} self.positions: Dict[str, Dict[str, Any]] = {} # T+1:今日买入数量(security -> int),before_open 清零 self._today_bought: Dict[str, int] = {} self._today: str = "" self.orders: Dict[str, Dict[str, Any]] = {} self.trades: List[Dict[str, Any]] = [] # ===== 生命周期 ===== def connect(self) -> bool: return True def disconnect(self) -> bool: return True def is_connected(self) -> bool: return True def heartbeat(self) -> None: return None # ===== LiveEngine 0.9.2 兼容查询(本地柜台无外部账户/委托可同步) ===== # 引擎 _start_background_jobs 无条件调 supports_account_sync(),缺方法=启动即崩 # 账户同步必须开:LiveEngine 的 context.portfolio 只认 sync_account 快照, # 关闭=策略永远看到初始 100 万/0 持仓(2026-08-19 实况:次日轮换零卖出, # 买单六连拒"资金不足"——账本与引擎断连,账户第 1 天后永久卡死)。 def supports_account_sync(self) -> bool: return True def supports_orders_sync(self) -> bool: return False def sync_account(self) -> Dict[str, Any]: """把内部账本推给引擎(_apply_account_snapshot 契约),60s 一次。""" info = self.get_account_info() positions = [] for p in info["positions"]: held = int(p["amount"]) locked = int(self._today_bought.get(p["security"], 0)) positions.append({ "security": p["security"], "amount": held, "closeable_amount": max(held - locked, 0), # T+1:今日买入不可卖 "avg_cost": p["avg_cost"], "price": p["price"], "current_price": p["price"], "market_value": p["market_value"], }) return { "available_cash": info["available_cash"], "total_value": info["total_value"], "positions": positions, } def sync_orders(self) -> list: return [] def cleanup(self) -> None: # 引擎关闭时无条件调 return None def before_open(self) -> None: """每个交易日开盘前:清 T+1 买入记录(昨日买的今天可卖)。""" self._today_bought = {} self._today = self._now().strftime("%Y-%m-%d") def after_close(self) -> None: return 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: quote = self.price_getter(security) except Exception as exc: # noqa: BLE001 - 行情失败拒单而非崩柜台 logger.warning("[shadow] 取价失败 %s: %s", security, exc) 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,双轨对账与实盘约束对齐)。 buy 撞涨停买不进、sell 撞跌停卖不出、停牌双向拒。 limit_getter 未注入/异常/无状态 → None(放行,等价旧行为)。 """ if self.limit_getter is None: return None try: status = self.limit_getter(security) except Exception as exc: # noqa: BLE001 - 状态查询失败降级放行 logger.warning("[shadow] 涨跌停状态查询失败 %s: %s", security, exc) return None if not isinstance(status, dict): return None if status.get("is_paused") is True: return "停牌不可交易" if side == "buy" and status.get("is_limit_up") is True: return "涨停拒买" if side == "sell" and status.get("is_limit_down") is True: return "跌停拒卖" return None # ===== 下单(即时全额成交) ===== async def buy(self, security: str, amount: int, price: Optional[float] = None, 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, 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) amount = int(amount) if amount <= 0: return self._reject(order_id, "数量非法") amount = amount - amount % LOT # 整手向下取 if amount <= 0: return self._reject(order_id, "不足一手(100股)") fill = round(ref * (1 + self.slippage) + 1e-9, 2) # 买入价上浮滑点 gross = amount * fill commission = max(gross * self.commission_rate, self.min_commission) if self.cash < gross + commission: return self._reject(order_id, f"资金不足 需{gross + commission:.2f} 有{self.cash:.2f}") self.cash -= gross + commission pos = self.positions.setdefault(security, {"amount": 0, "avg_cost": 0.0}) old_amt, old_cost = pos["amount"], pos["avg_cost"] pos["amount"] = old_amt + amount pos["avg_cost"] = (old_amt * old_cost + gross) / pos["amount"] self._today_bought[security] = self._today_bought.get(security, 0) + amount self._fill(order_id, security, "buy", amount, fill, commission, 0.0) return order_id async def sell(self, security: str, amount: int, price: Optional[float] = None, 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, 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) amount = int(amount) pos = self.positions.get(security) held = int(pos["amount"]) if pos else 0 if amount <= 0 or held <= 0: return self._reject(order_id, "无持仓") # T+1:今日买入部分不可卖 locked = self._today_bought.get(security, 0) sellable = max(held - locked, 0) if amount > sellable: amount = sellable amount = amount - amount % LOT if amount <= 0: return self._reject(order_id, f"可卖不足(T+1锁定{locked}股)") fill = round(ref * (1 - self.slippage) - 1e-9, 2) # 卖出价下压滑点 gross = amount * fill commission = max(gross * self.commission_rate, self.min_commission) stamp_duty = gross * self.stamp_duty_rate self.cash += gross - commission - stamp_duty pos["amount"] = held - amount if pos["amount"] <= 0: self.positions.pop(security, None) self._fill(order_id, security, "sell", amount, fill, commission, stamp_duty) return order_id async def cancel_order(self, order_id: str) -> bool: # 即时全额成交,无可撤单 return False async def get_order_status(self, order_id: str) -> Dict[str, Any]: st = self.orders.get(order_id) or {"order_id": order_id, "status": "not_found"} return dict(st) def get_orders(self, order_id=None, security=None, status=None, from_broker: bool = False) -> List[Dict[str, Any]]: rows = [dict(o) for o in self.orders.values() if (order_id is None or o["order_id"] == order_id) and (security is None or o["security"] == security)] return rows def get_open_orders(self) -> List[Dict[str, Any]]: return [dict(o) for o in self.orders.values() if o["status"] == "open"] def get_trades(self, order_id=None, security=None) -> List[Dict[str, Any]]: return [dict(t) for t in self.trades if (order_id is None or t["order_id"] == order_id) and (security is None or t["security"] == security)] # ===== 账户 ===== def get_positions(self) -> List[Dict[str, Any]]: out = [] for sym, pos in self.positions.items(): px = self._ref_price(sym, None) or pos["avg_cost"] out.append({"security": sym, "amount": pos["amount"], "avg_cost": round(pos["avg_cost"], 6), "market_value": pos["amount"] * px, "price": px}) return out def get_account_info(self) -> Dict[str, Any]: positions = self.get_positions() mv = sum(p["market_value"] for p in positions) # as_of:快照线程写 paper_daily_balance 的日期判据(_should_write_balance # 空串=不写)。有持仓或有成交才有净值可记;两者皆无=纯新账户,不写垃圾行。 as_of = (self._now().strftime("%Y-%m-%d") if (self.positions or self.trades) else "") return { "total_value": self.cash + mv, "available_cash": self.cash, "positions": positions, "market_value": mv, "as_of": as_of, } # ===== 内部 ===== def restore_from_trades(self, trades: List[Dict[str, Any]]) -> None: """从落库成交逐笔重放,重建现金/持仓/T+1 锁定(进程重启恢复虚拟账户)。 ShadowBroker 状态全在内存,此前重启即重置回 initial_cash/0 持仓, 与已落库的 paper_trades/paper_daily_balance 断层(2026-08-19 修复)。 trades 元素与 self.trades 同构:{side,security,amount,price, commission,stamp_duty,datetime},按 datetime 升序重放。 """ today = self._now().strftime("%Y-%m-%d") for t in sorted(trades, key=lambda x: x["datetime"]): amount, price = int(t["amount"]), float(t["price"]) fee = float(t.get("commission") or 0) + float(t.get("stamp_duty") or 0) sym = t["security"] if t["side"] == "buy": self.cash -= amount * price + fee pos = self.positions.setdefault(sym, {"amount": 0, "avg_cost": 0.0}) total_amt = pos["amount"] + amount pos["avg_cost"] = (pos["amount"] * pos["avg_cost"] + amount * price) / total_amt pos["amount"] = total_amt if t["datetime"][:10] == today: self._today_bought[sym] = self._today_bought.get(sym, 0) + amount else: self.cash += amount * price - fee pos = self.positions.get(sym) if pos: pos["amount"] -= amount if pos["amount"] <= 0: self.positions.pop(sym, None) self.trades.append(t) def _new_order(self, side: str, security: str, amount: int, price: Optional[float]) -> str: order_id = f"shadow_{uuid.uuid4().hex[:12]}" self.orders[order_id] = { "order_id": order_id, "status": "open", "side": side, "security": security, "amount": int(amount), "price": price, "created_at": self._now().isoformat(timespec="seconds"), } return order_id def _reject(self, order_id: str, reason: str) -> str: o = self.orders[order_id] o["status"] = "rejected" o["reject_reason"] = reason logger.info("[shadow] 拒单 %s %s %s: %s", o["side"], o["security"], o["amount"], reason) return order_id def _fill(self, order_id: str, security: str, side: str, amount: int, fill: float, commission: float, stamp_duty: float) -> None: o = self.orders[order_id] o.update(status="filled", filled_amount=amount, filled_price=fill) trade = { "order_id": order_id, "security": security, "side": side, "amount": amount, "price": fill, "commission": round(commission, 2), "stamp_duty": round(stamp_duty, 2), "datetime": self._now().strftime("%Y-%m-%d %H:%M:%S"), } self.trades.append(trade) logger.info("[shadow] 成交 %s %s %d股 @%.2f 费%.2f", side, security, amount, fill, commission + stamp_duty) if self.on_trade is not None: try: self.on_trade(trade) except Exception as exc: # noqa: BLE001 - 落库失败不阻断撮合 logger.warning("[shadow] on_trade 回调失败: %s", exc)