feat: 持仓委托改止盈止损,保证金改读CTP柜台UseMargin

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 14:50:45 +08:00
parent 63beda3c71
commit 01de8dfb69
3 changed files with 99 additions and 11 deletions
+12 -2
View File
@@ -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, direction, entry, sl if sl is not None else entry,
tp if tp is not None else entry, lots, mark, capital, sym, 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( order_st = monitor_order_status(
mon or {}, mode=mode, ths_code=sym, direction=direction, 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, "holding_duration": holding or None,
"mark_price": mark, "mark_price": mark,
"current_price": mark, "current_price": mark,
"margin": pos_metrics.get("margin"), "margin": margin,
"position_pct": pos_metrics.get("position_pct"), "margin_source": margin_source,
"position_pct": position_pct,
"risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, "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, "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, "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None,
+27 -8
View File
@@ -587,6 +587,13 @@
symbol_code: row.symbol_code, direction: row.direction, symbol_code: row.symbol_code, direction: row.direction,
lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null
})) + '">设置止盈止损</button>' : ''; })) + '">设置止盈止损</button>' : '';
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 ?
'<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : '';
var orderBtn = ''; var orderBtn = '';
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">清理旧挂单</button>'; orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">清理旧挂单</button>';
@@ -597,8 +604,8 @@
})); }));
var closeBtn = row.can_close ? var closeBtn = row.can_close ?
'<button type="button" class="pos-close-btn" data-close="' + closePayload + '">平仓</button>' : ''; '<button type="button" class="pos-close-btn" data-close="' + closePayload + '">平仓</button>' : '';
var actionBtns = (orderBtn || closeBtn) ? var actionBtns = (entrustBtn || orderBtn || closeBtn) ?
'<div class="pos-card-actions">' + orderBtn + closeBtn + '</div>' : ''; '<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : '';
var riskMeta = ''; var riskMeta = '';
if (row.rr_ratio != null) { if (row.rr_ratio != null) {
riskMeta += ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>'; riskMeta += ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>';
@@ -624,7 +631,7 @@
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' + '<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
'<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' + '<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
'<div class="cell"><label>止盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>' + '<div class="cell"><label>止盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>' +
'<div class="cell"><label>占用保证金</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' + '<div class="cell"><label>' + (row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金') + '</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' +
'<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' + '<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' + '<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
'<div class="cell"><label>预估手续费</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' + '<div class="cell"><label>预估手续费</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
@@ -679,10 +686,13 @@
}); });
} }
function promptStopTakeProfit(payload, btn) { function promptStopTakeProfit(payload, btn, btnLabel) {
var slRaw = prompt('止损价(可留空)', ''); 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; if (slRaw === null) return;
var tpRaw = prompt('止盈价(可留空)', ''); var tpRaw = prompt('止盈价(可留空)', tpDefault);
if (tpRaw === null) return; if (tpRaw === null) return;
var sl = slRaw.trim() ? parseFloat(slRaw) : null; var sl = slRaw.trim() ? parseFloat(slRaw) : null;
var tp = tpRaw.trim() ? parseFloat(tpRaw) : null; var tp = tpRaw.trim() ? parseFloat(tpRaw) : null;
@@ -726,7 +736,7 @@
alert(msg); alert(msg);
if (btn) { if (btn) {
btn.disabled = false; btn.disabled = false;
btn.textContent = '设置止盈止损'; btn.textContent = btnLabel;
} }
}); });
} }
@@ -735,7 +745,16 @@
if (!root) return; if (!root) return;
root.querySelectorAll('[data-sl-tp]').forEach(function (btn) { root.querySelectorAll('[data-sl-tp]').forEach(function (btn) {
btn.addEventListener('click', function () { 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, '委托'
);
}); });
}); });
} }
+60 -1
View File
@@ -106,6 +106,8 @@ class CtpBridge:
self._commission_hooked = False self._commission_hooked = False
self._subscribed: set[str] = set() self._subscribed: set[str] = set()
self._last_position_query_ts: float = 0.0 self._last_position_query_ts: float = 0.0
self._position_margins: dict[str, float] = {}
self._margin_hooked = False
self._tick_hooked = False self._tick_hooked = False
self._bar_generators: dict[str, Any] = {} self._bar_generators: dict[str, Any] = {}
self._bars_1m: dict[str, deque] = {} self._bars_1m: dict[str, deque] = {}
@@ -223,7 +225,12 @@ class CtpBridge:
self._connected_mode = mode self._connected_mode = mode
self._last_error = "" self._last_error = ""
logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts)) logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts))
self._install_position_margin_hook()
self._schedule_fee_sync(mode) self._schedule_fee_sync(mode)
try:
self.refresh_positions()
except Exception as exc:
logger.debug("initial position query: %s", exc)
return return
time.sleep(0.5) time.sleep(0.5)
finally: finally:
@@ -699,6 +706,52 @@ class CtpBridge:
"accountid": getattr(acc, "accountid", ""), "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]]: def _collect_positions(self) -> list[dict[str, Any]]:
if not self._engine: if not self._engine:
return [] return []
@@ -711,6 +764,7 @@ class CtpBridge:
sym = getattr(pos, "symbol", "") or "" sym = getattr(pos, "symbol", "") or ""
exchange = getattr(pos, "exchange", None) exchange = getattr(pos, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
margin = self._lookup_position_margin(sym, d)
out.append({ out.append({
"symbol": sym, "symbol": sym,
"exchange": ex_name, "exchange": ex_name,
@@ -719,6 +773,7 @@ class CtpBridge:
"avg_price": float(getattr(pos, "price", 0) or 0), "avg_price": float(getattr(pos, "price", 0) or 0),
"pnl": float(getattr(pos, "pnl", 0) or 0), "pnl": float(getattr(pos, "pnl", 0) or 0),
"frozen": int(getattr(pos, "frozen", 0) or 0), "frozen": int(getattr(pos, "frozen", 0) or 0),
"margin": round(margin, 2) if margin > 0 else None,
}) })
return out return out
@@ -731,15 +786,19 @@ class CtpBridge:
return return
self._last_position_query_ts = now self._last_position_query_ts = now
try: try:
self._install_position_margin_hook()
gw = self._engine.get_gateway(GATEWAY_NAME) gw = self._engine.get_gateway(GATEWAY_NAME)
td = getattr(gw, "td_api", None) td = getattr(gw, "td_api", None)
if td and hasattr(td, "query_position"): if td and hasattr(td, "query_position"):
self._position_margins.clear()
td.query_position() td.query_position()
time.sleep(0.4) time.sleep(0.4)
except Exception as exc: except Exception as exc:
logger.debug("refresh_positions: %s", 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() out = self._collect_positions()
if not out and refresh_if_empty: if not out and refresh_if_empty:
self.refresh_positions() self.refresh_positions()