diff --git a/install_trading.py b/install_trading.py index a44aef2..132d65a 100644 --- a/install_trading.py +++ b/install_trading.py @@ -488,6 +488,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se direction, entry, sl if sl is not None else entry, tp if tp is not None else entry, lots, mark, capital, sym, ) + ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0 + est_margin = pos_metrics.get("margin") + margin = ctp_margin if ctp_margin > 0 else est_margin + margin_source = "ctp" if ctp_margin > 0 else "estimate" + position_pct = ( + round(float(margin) / capital * 100, 2) + if capital > 0 and margin + else pos_metrics.get("position_pct") + ) order_st = monitor_order_status( mon or {}, mode=mode, ths_code=sym, direction=direction, ) @@ -529,8 +538,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "holding_duration": holding or None, "mark_price": mark, "current_price": mark, - "margin": pos_metrics.get("margin"), - "position_pct": pos_metrics.get("position_pct"), + "margin": margin, + "margin_source": margin_source, + "position_pct": position_pct, "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, "risk_pct": pos_metrics.get("risk_pct") if sl is not None else None, "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, diff --git a/static/js/trade.js b/static/js/trade.js index d55164b..9f92559 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -587,6 +587,13 @@ symbol_code: row.symbol_code, direction: row.direction, lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null })) + '">设置止盈止损' : ''; + var editPayload = encodeURIComponent(JSON.stringify({ + symbol_code: row.symbol_code, direction: row.direction, + lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null, + stop_loss: row.stop_loss, take_profit: row.take_profit + })); + var entrustBtn = row.can_close ? + '' : ''; var orderBtn = ''; if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { orderBtn = ''; @@ -597,8 +604,8 @@ })); var closeBtn = row.can_close ? '' : ''; - var actionBtns = (orderBtn || closeBtn) ? - '
' + orderBtn + closeBtn + '
' : ''; + var actionBtns = (entrustBtn || orderBtn || closeBtn) ? + '
' + entrustBtn + orderBtn + closeBtn + '
' : ''; var riskMeta = ''; if (row.rr_ratio != null) { riskMeta += ' · 盈亏比 ' + row.rr_ratio + ':1'; @@ -624,7 +631,7 @@ '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + '
' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '
' + '
' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '
' + - '
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' + + '
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' + '
' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '
' + '
' + pnlText + '
' + '
' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '
' + @@ -679,10 +686,13 @@ }); } - function promptStopTakeProfit(payload, btn) { - var slRaw = prompt('止损价(可留空)', ''); + function promptStopTakeProfit(payload, btn, btnLabel) { + btnLabel = btnLabel || '设置止盈止损'; + var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : ''; + var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : ''; + var slRaw = prompt('止损价(可留空)', slDefault); if (slRaw === null) return; - var tpRaw = prompt('止盈价(可留空)', ''); + var tpRaw = prompt('止盈价(可留空)', tpDefault); if (tpRaw === null) return; var sl = slRaw.trim() ? parseFloat(slRaw) : null; var tp = tpRaw.trim() ? parseFloat(tpRaw) : null; @@ -726,7 +736,7 @@ alert(msg); if (btn) { btn.disabled = false; - btn.textContent = '设置止盈止损'; + btn.textContent = btnLabel; } }); } @@ -735,7 +745,16 @@ if (!root) return; root.querySelectorAll('[data-sl-tp]').forEach(function (btn) { btn.addEventListener('click', function () { - promptStopTakeProfit(JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn); + promptStopTakeProfit( + JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn, '设置止盈止损' + ); + }); + }); + root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) { + btn.addEventListener('click', function () { + promptStopTakeProfit( + JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托' + ); }); }); } diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 47843d4..fa86b88 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -106,6 +106,8 @@ class CtpBridge: self._commission_hooked = False self._subscribed: set[str] = set() self._last_position_query_ts: float = 0.0 + self._position_margins: dict[str, float] = {} + self._margin_hooked = False self._tick_hooked = False self._bar_generators: dict[str, Any] = {} self._bars_1m: dict[str, deque] = {} @@ -223,7 +225,12 @@ class CtpBridge: self._connected_mode = mode self._last_error = "" logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts)) + self._install_position_margin_hook() self._schedule_fee_sync(mode) + try: + self.refresh_positions() + except Exception as exc: + logger.debug("initial position query: %s", exc) return time.sleep(0.5) finally: @@ -699,6 +706,52 @@ class CtpBridge: "accountid": getattr(acc, "accountid", ""), } + def _position_margin_key(self, sym: str, direction: str) -> str: + return f"{(sym or '').lower()}:{(direction or 'long').strip().lower()}" + + def _install_position_margin_hook(self) -> None: + """拦截 CTP 持仓回报,缓存柜台 UseMargin。""" + if self._margin_hooked or not self._engine: + return + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = getattr(gw, "td_api", None) + if not td or not hasattr(td, "onRspQryInvestorPosition"): + return + bridge = self + original = td.onRspQryInvestorPosition + + def _wrapped(data, error, reqid, last): + try: + if data and isinstance(data, dict): + sym = (data.get("InstrumentID") or "").strip() + pos_dir = str(data.get("PosiDirection") or "") + if pos_dir == "2": + d = "long" + elif pos_dir == "3": + d = "short" + else: + d = "long" if "LONG" in pos_dir.upper() else "short" + margin = float( + data.get("UseMargin") or data.get("ExchangeMargin") or 0 + ) + if sym and margin > 0: + k = bridge._position_margin_key(sym, d) + bridge._position_margins[k] = ( + bridge._position_margins.get(k, 0.0) + margin + ) + except Exception as exc: + logger.debug("margin hook row: %s", exc) + return original(data, error, reqid, last) + + td.onRspQryInvestorPosition = _wrapped + self._margin_hooked = True + except Exception as exc: + logger.debug("install margin hook: %s", exc) + + def _lookup_position_margin(self, sym: str, direction: str) -> float: + return float(self._position_margins.get(self._position_margin_key(sym, direction), 0) or 0) + def _collect_positions(self) -> list[dict[str, Any]]: if not self._engine: return [] @@ -711,6 +764,7 @@ class CtpBridge: sym = getattr(pos, "symbol", "") or "" exchange = getattr(pos, "exchange", None) ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") + margin = self._lookup_position_margin(sym, d) out.append({ "symbol": sym, "exchange": ex_name, @@ -719,6 +773,7 @@ class CtpBridge: "avg_price": float(getattr(pos, "price", 0) or 0), "pnl": float(getattr(pos, "pnl", 0) or 0), "frozen": int(getattr(pos, "frozen", 0) or 0), + "margin": round(margin, 2) if margin > 0 else None, }) return out @@ -731,15 +786,19 @@ class CtpBridge: return self._last_position_query_ts = now try: + self._install_position_margin_hook() gw = self._engine.get_gateway(GATEWAY_NAME) td = getattr(gw, "td_api", None) if td and hasattr(td, "query_position"): + self._position_margins.clear() td.query_position() time.sleep(0.4) except Exception as exc: logger.debug("refresh_positions: %s", exc) - def list_positions(self, *, refresh_if_empty: bool = True) -> list[dict[str, Any]]: + def list_positions(self, *, refresh_if_empty: bool = True, refresh_margin: bool = True) -> list[dict[str, Any]]: + if self._engine and self._connected_mode and refresh_margin: + self.refresh_positions() out = self._collect_positions() if not out and refresh_if_empty: self.refresh_positions()