Files
sanguo_vnpy_v2/sanguo_trader/shadow/broker.py
T
claude_dev e2dc473682 fix(shadow): 影子市价单成交基准改实时行情——旧按委托保护价记账恒差1.5%,08-24双轨4/6红根因 [vps]
根因(代码级实锤): 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绿。
2026-08-24 23:07:12 +08:00

360 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""影子柜台本地模拟 brokerP1docs/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)