e2dc473682
根因(代码级实锤): broker._ref_price 优先用下单传入price,而市价单的price是保护性委托上限(策略侧行情×1.015,BrokerBase契明文'可用作保护价/参考价'非期望成交价)→影子每笔买入虚高~1.5%,卖侧对称低~1.5%。08-24证据三连:momentum/small_cap首次真实成交日25票价差紧聚-150bps(-112~-189);卖侧510500同样-1.54%(排除'晚一根bar随行情'解释);级联=虚价吃掉影子现金6k+→002038买第6只差1678元资金不足拒单(shadow_60日志)。排除用户初判的滑点/费率参数不一致(影子账户slippage=0.0费率正常)。回测/实走无此问题(matcher.py按bar开收价,另一套)。 修: _ref_price(security,price,market)——市价单成交基准=price_getter实时行情,行情不可得退回委托价(告警,可用性优先);限价单沿用传入价(触价语义,旧行为);buy/sell补市价转限价封顶(行情超保护上限按上限成交不追高,卖侧对称)。 +5测试(市价买卖按行情/超上限封顶/无行情退委托价/限价语义不变),trader 254绿+api 171绿。
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""影子柜台本地模拟 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)
|