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) + '%' : '--') + '
' +
'' +
'' + (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()