Implement CTP-authoritative trading UI with event-driven state.

Add in-memory order/position books fed by CTP events, split active orders above positions in the UI, tick-triggered local SL/TP, and 30-second full calibration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 18:12:11 +08:00
parent 4ef33a367f
commit 3a150dd3d6
8 changed files with 1056 additions and 210 deletions
+190 -9
View File
@@ -73,6 +73,7 @@ def _load_persisted_last_error() -> str:
return (get_setting(CTP_LAST_ERROR_KEY, "") or "").strip()
_position_refresh_callback: Optional[Callable[[], None]] = None
_tick_sl_tp_callback: Optional[Callable[[str, str, float], None]] = None
_position_refresh_debounce_lock = threading.Lock()
_position_refresh_debounce_ts: float = 0.0
@@ -82,6 +83,12 @@ def set_position_refresh_callback(fn: Optional[Callable[[], None]]) -> None:
_position_refresh_callback = fn
def set_tick_sl_tp_callback(fn: Optional[Callable[[str, str, float], None]]) -> None:
"""注册 tick 回调:exchange, symbol, last_price → 本地 SL/TP 触发。"""
global _tick_sl_tp_callback
_tick_sl_tp_callback = fn
def _fire_position_refresh_callback() -> None:
fn = _position_refresh_callback
if not fn:
@@ -223,6 +230,8 @@ class CtpBridge:
self._last_connect_ok_ts: float = 0.0
self._tick_hooked = False
self._position_hooked = False
self._order_hooked = False
self._trade_hooked = False
self._bar_generators: dict[str, Any] = {}
self._bars_1m: dict[str, deque] = {}
self._init_engine()
@@ -238,6 +247,8 @@ class CtpBridge:
self._engine = MainEngine(self._ee)
self._engine.add_gateway(CtpGateway)
self._ensure_position_event_hook()
self._ensure_order_event_hook()
self._ensure_trade_event_hook()
except ImportError:
self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp"
except Exception as exc:
@@ -253,17 +264,29 @@ class CtpBridge:
def _on_position(event) -> None:
try:
from ctp_trading_state import trading_state
pos = event.data
vol = int(getattr(pos, "volume", 0) or 0)
if vol <= 0:
return
row = self._position_row_from_vnpy(pos)
if row:
trading_state.upsert_position(row, notify=False)
sym = getattr(pos, "symbol", "") or ""
d = "long" if _is_long_direction(getattr(pos, "direction", None)) else "short"
for attr in ("margin", "use_margin", "UseMargin"):
raw = float(getattr(pos, attr, 0) or 0)
if raw > 0:
self._position_margins[self._position_margin_key(sym, d)] = raw
break
vol = int(getattr(pos, "volume", 0) or 0)
if vol <= 0:
exchange = getattr(pos, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
from ctp_trading_state import position_key
trading_state.remove_position(
position_key(ex_name, sym, d), notify=False,
)
else:
for attr in ("margin", "use_margin", "UseMargin"):
raw = float(getattr(pos, attr, 0) or 0)
if raw > 0:
self._position_margins[self._position_margin_key(sym, d)] = raw
break
except Exception as exc:
logger.debug("position margin cache: %s", exc)
_fire_position_refresh_callback_debounced()
@@ -271,6 +294,137 @@ class CtpBridge:
self._ee.register(EVENT_POSITION, _on_position)
self._position_hooked = True
def _ensure_order_event_hook(self) -> None:
if self._order_hooked or not self._ee:
return
try:
from vnpy.trader.event import EVENT_ORDER
except ImportError:
return
def _on_order(event) -> None:
try:
from ctp_trading_state import trading_state
order = event.data
row = self._order_row_from_vnpy(order)
if not row:
return
status_s = str(row.get("status") or "")
terminal = any(
x in status_s
for x in ("ALLTRADED", "CANCELLED", "REJECTED", "全部成交", "已撤销", "拒单")
)
oid = str(row.get("order_id") or row.get("vt_order_id") or "")
if terminal or int(row.get("lots") or 0) <= 0:
trading_state.remove_order(oid, notify=False)
else:
trading_state.upsert_order(row, notify=False)
except Exception as exc:
logger.debug("order event: %s", exc)
_fire_position_refresh_callback_debounced(min_interval=0.2)
self._ee.register(EVENT_ORDER, _on_order)
self._order_hooked = True
def _ensure_trade_event_hook(self) -> None:
if self._trade_hooked or not self._ee:
return
try:
from vnpy.trader.event import EVENT_TRADE
except ImportError:
return
def _on_trade(event) -> None:
try:
trade = event.data
row = self._trade_row_from_vnpy(trade)
if row and row.get("offset") == "open":
sym = row.get("symbol") or ""
pd = row.get("position_direction") or "long"
dt = row.get("datetime") or ""
if sym and dt:
self._position_open_times[self._position_margin_key(sym, pd)] = dt
except Exception as exc:
logger.debug("trade event: %s", exc)
_fire_position_refresh_callback_debounced(min_interval=0.2)
self._ee.register(EVENT_TRADE, _on_trade)
self._trade_hooked = True
def _order_row_from_vnpy(self, order: Any) -> Optional[dict[str, Any]]:
try:
status = getattr(order, "status", None)
status_s = str(status)
vol = int(getattr(order, "volume", 0) or 0)
traded = int(getattr(order, "traded", 0) or 0)
remain = max(0, vol - traded)
direction = getattr(order, "direction", None)
d = "long"
if direction is not None and str(direction).endswith("SHORT"):
d = "short"
offset = getattr(order, "offset", None)
sym = getattr(order, "symbol", "") or ""
exchange = getattr(order, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
vt_oid = str(getattr(order, "vt_orderid", "") or "")
order_id = str(getattr(order, "orderid", "") or "")
return {
"symbol": sym,
"exchange": ex_name,
"direction": d,
"lots": remain,
"price": float(getattr(order, "price", 0) or 0),
"offset": str(offset or ""),
"order_id": vt_oid or order_id,
"vt_order_id": vt_oid,
"status": status_s,
}
except Exception as exc:
logger.debug("order_row_from_vnpy: %s", exc)
return None
def _position_row_from_vnpy(self, pos: Any) -> Optional[dict[str, Any]]:
try:
vol = int(getattr(pos, "volume", 0) or 0)
d = "long" if _is_long_direction(getattr(pos, "direction", None)) else "short"
sym = getattr(pos, "symbol", "") or ""
exchange = getattr(pos, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
price = float(getattr(pos, "price", 0) or 0)
yd = int(getattr(pos, "yd_volume", 0) or 0)
td = max(0, vol - yd)
margin = self.estimate_position_margin(sym, ex_name, d, vol, price, pos=pos)
open_time = self._lookup_position_open_time(sym, d) or None
return {
"symbol": sym,
"exchange": ex_name,
"direction": d,
"lots": vol,
"avg_price": price,
"pnl": float(getattr(pos, "pnl", 0) or 0),
"frozen": int(getattr(pos, "frozen", 0) or 0),
"margin": margin,
"open_time": open_time,
"yd_volume": yd,
"td_volume": td,
}
except Exception as exc:
logger.debug("position_row_from_vnpy: %s", exc)
return None
def calibrate_trading_state(self) -> None:
"""全量校准内存簿(读 vnpy 缓存,不 query 柜台)。"""
try:
from ctp_trading_state import trading_state
with _ctp_td_lock:
orders = self.list_active_orders()
positions = self._collect_positions()
trading_state.calibrate_from_lists(orders, positions)
except Exception as exc:
logger.debug("calibrate trading state: %s", exc)
def available(self) -> bool:
return self._engine is not None
@@ -336,6 +490,12 @@ class CtpBridge:
except Exception as exc:
logger.debug("gateway close: %s", exc)
self._connected_mode = None
try:
from ctp_trading_state import trading_state
trading_state.clear()
except Exception:
pass
time.sleep(0.6)
def _login_rejected(self, ctp_logs: list[str]) -> bool:
@@ -463,6 +623,10 @@ class CtpBridge:
mode, self._td_logged_in(),
len(self._engine.get_all_accounts() or []))
self._schedule_fee_sync(mode)
try:
self.calibrate_trading_state()
except Exception as exc:
logger.debug("post-connect calibrate: %s", exc)
_fire_position_refresh_burst()
return
finally:
@@ -844,6 +1008,20 @@ class CtpBridge:
sym = (getattr(tick, "symbol", "") or "").lower()
te = getattr(tick, "exchange", None)
ex_s = str(te.value if hasattr(te, "value") else te or "").upper()
price = self._price_from_tick(tick)
if price and price > 0:
try:
from ctp_trading_state import trading_state
trading_state.set_tick_price(ex_s, sym, price)
except Exception:
pass
fn = _tick_sl_tp_callback
if fn:
try:
fn(ex_s, sym, float(price))
except Exception as exc:
logger.debug("tick sl/tp callback: %s", exc)
key = self._tick_key(sym, ex_s)
bg = self._bar_generators.get(key)
if not bg:
@@ -1329,6 +1507,8 @@ class CtpBridge:
sym = getattr(order, "symbol", "") or ""
exchange = getattr(order, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
vt_oid = str(getattr(order, "vt_orderid", "") or "")
order_id = str(getattr(order, "orderid", "") or "")
out.append({
"symbol": sym,
"exchange": ex_name,
@@ -1336,7 +1516,8 @@ class CtpBridge:
"lots": remain,
"price": float(getattr(order, "price", 0) or 0),
"offset": offset_s,
"order_id": str(getattr(order, "orderid", "") or ""),
"order_id": vt_oid or order_id,
"vt_order_id": vt_oid,
"status": status_s,
})
return out