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:
+190
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user