fix: 清理幽灵止盈止损监控并修正仓位上限冻结误触发

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 14:09:42 +08:00
parent 5af04ef661
commit 23d0f1d6fa
4 changed files with 176 additions and 30 deletions
+99 -6
View File
@@ -125,6 +125,49 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
except Exception: except Exception:
return "" return ""
def _ctp_position_keys(mode: str) -> set[tuple[str, str]]:
keys: set[tuple[str, str]] = set()
for p in _ctp_positions(mode):
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
sym = (p.get("symbol") or "").lower()
direction = p.get("direction") or "long"
keys.add((sym, direction))
return keys
def _monitor_matches_ctp_position(mon: dict, position_keys: set[tuple[str, str]]) -> bool:
ms = mon.get("symbol") or ""
md = mon.get("direction") or "long"
for ps, pd in position_keys:
if pd != md:
continue
if _match_ctp_symbol(ps, ms):
return True
return False
def _sync_trade_monitors_with_ctp(conn, mode: str) -> int:
"""关闭无对应 CTP 持仓的 active 监控(委托被拒或未成交的幽灵记录)。"""
if not ctp_status(mode).get("connected"):
return 0
position_keys = _ctp_position_keys(mode)
closed = 0
for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall():
mon = dict(r)
if _monitor_matches_ctp_position(mon, position_keys):
continue
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
closed += 1
return closed
def _effective_active_position_count(conn, mode: str) -> int:
if ctp_status(mode).get("connected"):
return len(_ctp_position_keys(mode))
row = conn.execute(
"SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'"
).fetchone()
return int(row["n"] or 0)
def _build_pending_orders(conn, mode: str) -> list[dict]: def _build_pending_orders(conn, mode: str) -> list[dict]:
pending: list[dict] = [] pending: list[dict] = []
for r in conn.execute( for r in conn.execute(
@@ -141,6 +184,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"direction_label": "做多" if direction == "long" else "做空", "direction_label": "做多" if direction == "long" else "做空",
"lots": lots, "lots": lots,
"source": "monitor", "source": "monitor",
"monitor_id": mon.get("id"),
} }
sl = mon.get("stop_loss") sl = mon.get("stop_loss")
tp = mon.get("take_profit") tp = mon.get("take_profit")
@@ -235,6 +279,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"price": sl, "price": sl,
"lots": lots, "lots": lots,
"source": "monitor", "source": "monitor",
"monitor_id": mon["id"] if mon else None,
}) })
if tp is not None: if tp is not None:
pending_for_row.append({ pending_for_row.append({
@@ -243,6 +288,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"price": tp, "price": tp,
"lots": lots, "lots": lots,
"source": "monitor", "source": "monitor",
"monitor_id": mon["id"] if mon else None,
}) })
rows.append({ rows.append({
"key": f"ctp:{sym.lower()}:{direction}", "key": f"ctp:{sym.lower()}:{direction}",
@@ -280,8 +326,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
init_strategy_tables(conn) init_strategy_tables(conn)
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode) ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, mode)
capital = _capital(conn) capital = _capital(conn)
risk = get_risk_status(conn) risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
active_trend = conn.execute( active_trend = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
@@ -328,10 +375,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
init_strategy_tables(conn) init_strategy_tables(conn)
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode) ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, mode)
rows = _build_trading_live_rows(conn) rows = _build_trading_live_rows(conn)
pending_orders = _build_pending_orders(conn, mode) pending_orders = _build_pending_orders(conn, mode)
capital = _capital(conn) capital = _capital(conn)
risk = get_risk_status(conn) risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
conn.commit() conn.commit()
return jsonify({ return jsonify({
"rows": rows, "rows": rows,
@@ -344,6 +392,34 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
finally: finally:
conn.close() conn.close()
@app.route("/api/trading/monitor/dismiss", methods=["POST"])
@login_required
def api_trading_monitor_dismiss():
d = request.get_json(silent=True) or {}
try:
monitor_id = int(d.get("monitor_id") or 0)
except (TypeError, ValueError):
monitor_id = 0
if monitor_id <= 0:
return jsonify({"ok": False, "error": "无效的监控记录"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
row = conn.execute(
"SELECT id FROM trade_order_monitors WHERE id=? AND status='active'",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(monitor_id,),
)
conn.commit()
return jsonify({"ok": True, "message": "已取消本地止盈止损监控"})
finally:
conn.close()
@app.route("/api/trading/close", methods=["POST"]) @app.route("/api/trading/close", methods=["POST"])
@login_required @login_required
def api_trading_close(): def api_trading_close():
@@ -523,12 +599,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return jsonify({"ok": False, "error": "品种或价格无效"}), 400 return jsonify({"ok": False, "error": "品种或价格无效"}), 400
conn = get_db() conn = get_db()
init_strategy_tables(conn) init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if offset.startswith("open"): if offset.startswith("open"):
err = assert_can_open(conn) _sync_trade_monitors_with_ctp(conn, mode)
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
if err: if err:
conn.close() conn.close()
return jsonify({"ok": False, "error": err}), 403 return jsonify({"ok": False, "error": err}), 403
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode) ctp_st = ctp_status(mode)
if not ctp_st.get("connected"): if not ctp_st.get("connected"):
conn.close() conn.close()
@@ -569,6 +646,21 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if offset.startswith("open"): if offset.startswith("open"):
sl = d.get("stop_loss") sl = d.get("stop_loss")
tp = d.get("take_profit") tp = d.get("take_profit")
if sl or tp:
import time
time.sleep(2.0)
actual_lots = lots
has_pos = False
for p in _ctp_positions(mode):
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_ctp_symbol(p.get("symbol") or "", sym):
has_pos = True
actual_lots = int(p.get("lots") or lots)
break
if has_pos:
codes = ths_to_codes(sym) codes = ths_to_codes(sym)
conn.execute( conn.execute(
"""INSERT INTO trade_order_monitors ( """INSERT INTO trade_order_monitors (
@@ -580,7 +672,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
codes.get("name", sym) if codes else sym, codes.get("name", sym) if codes else sym,
codes.get("market_code", "") if codes else "", codes.get("market_code", "") if codes else "",
direction, direction,
lots, actual_lots,
price, price,
float(sl) if sl else None, float(sl) if sl else None,
float(tp) if tp else None, float(tp) if tp else None,
@@ -643,8 +735,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
init_strategy_tables(conn) init_strategy_tables(conn)
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode) ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, mode)
capital = _capital(conn) capital = _capital(conn)
risk = get_risk_status(conn) risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
conn.commit() conn.commit()
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
positions = _ctp_positions(mode) if ctp_st.get("connected") else [] positions = _ctp_positions(mode) if ctp_st.get("connected") else []
+4 -4
View File
@@ -227,7 +227,7 @@ def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[dateti
) )
def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict: def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict:
def _load() -> dict: def _load() -> dict:
ensure_account_risk_schema(conn) ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
@@ -244,7 +244,7 @@ def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict:
now_ms = _now_ms(now) now_ms = _now_ms(now)
daily = int(_row_get(row, "daily_frozen") or 0) == 1 daily = int(_row_get(row, "daily_frozen") or 0) == 1
until = _row_get(row, "cooloff_until_ms") until = _row_get(row, "cooloff_until_ms")
active = count_active_trade_monitors(conn) active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
mx = max_active_positions() mx = max_active_positions()
pos_limit = active >= mx pos_limit = active >= mx
@@ -295,8 +295,8 @@ def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict:
return _db_retry(_load) return _db_retry(_load)
def assert_can_open(conn) -> Optional[str]: def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]:
rs = get_risk_status(conn) rs = get_risk_status(conn, active_count=active_count)
if not rs.get("can_trade"): if not rs.get("can_trade"):
return rs.get("reason") or "当前不可开仓" return rs.get("reason") or "当前不可开仓"
return None return None
+3
View File
@@ -43,6 +43,9 @@
.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} .pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)}
.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} .pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem}
.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)} .pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)}
.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0}
.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3}
.pos-dismiss-btn:disabled{opacity:.55;cursor:wait}
.pos-pending-item.sl{border-left:3px solid var(--loss)} .pos-pending-item.sl{border-left:3px solid var(--loss)}
.pos-pending-item.tp{border-left:3px solid var(--profit)} .pos-pending-item.tp{border-left:3px solid var(--profit)}
.pos-pending-item.ctp{border-left:3px solid var(--accent)} .pos-pending-item.ctp{border-left:3px solid var(--accent)}
+52 -2
View File
@@ -318,16 +318,56 @@
if (!items || !items.length) return ''; if (!items || !items.length) return '';
var rows = items.map(function (p) { var rows = items.map(function (p) {
var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp'); var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp');
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
return ( return (
'<div class="pos-pending-item ' + cls + '">' + '<div class="pos-pending-item ' + cls + '">' +
'<span>' + (p.label || '挂单') + '</span>' + '<span>' + (p.label || '挂单') + '</span>' +
'<span><strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手</span>' + '<span class="pos-pending-right">' +
'<strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手' +
dismissBtn +
'</span>' +
'</div>' '</div>'
); );
}).join(''); }).join('');
return '<div class="pos-pending-orders"><div class="pending-title">止盈止损挂单</div>' + rows + '</div>'; return '<div class="pos-pending-orders"><div class="pending-title">止盈止损挂单</div>' + rows + '</div>';
} }
function dismissMonitor(monitorId, btn) {
if (!monitorId) return;
if (!confirm('取消该本地止盈止损监控?(不影响柜台委托)')) return;
if (btn) {
btn.disabled = true;
btn.textContent = '取消中…';
}
fetch('/api/trading/monitor/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_id: monitorId })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) throw new Error(d.error || '取消失败');
pollPositions();
})
.catch(function (e) {
alert(e.message || '取消失败');
if (btn) {
btn.disabled = false;
btn.textContent = '取消';
}
});
}
function bindPendingDismiss(root) {
if (!root) return;
root.querySelectorAll('[data-monitor-id]').forEach(function (btn) {
btn.addEventListener('click', function () {
dismissMonitor(parseInt(btn.getAttribute('data-monitor-id'), 10), btn);
});
});
}
function buildPosCard(row) { function buildPosCard(row) {
var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : '');
var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--'; var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--';
@@ -405,6 +445,11 @@
var connected = data.ctp_status && data.ctp_status.connected; var connected = data.ctp_status && data.ctp_status.connected;
var connecting = data.ctp_status && data.ctp_status.connecting; var connecting = data.ctp_status && data.ctp_status.connecting;
updateCtpBadge(!!connected, !!connecting); updateCtpBadge(!!connected, !!connecting);
var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) {
riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
}
var rows = data.rows || []; var rows = data.rows || [];
if (!connected) { if (!connected) {
if (connecting) { if (connecting) {
@@ -420,19 +465,24 @@
if (pendingOnly.length) { if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">柜台暂无持仓</div>' + list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">柜台暂无持仓</div>' +
pendingOnly.map(function (p) { pendingOnly.map(function (p) {
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
return ( return (
'<div class="pos-pending-item ' + '<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) + (p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' + '"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span><strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手</span></div>' '<span class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + dismissBtn + '</span></div>'
); );
}).join(''); }).join('');
bindPendingDismiss(list);
} else { } else {
list.innerHTML = '<div class="empty-hint">柜台暂无持仓。</div>'; list.innerHTML = '<div class="empty-hint">柜台暂无持仓。</div>';
} }
return; return;
} }
list.innerHTML = rows.map(buildPosCard).join(''); list.innerHTML = rows.map(buildPosCard).join('');
bindPendingDismiss(list);
list.querySelectorAll('[data-close]').forEach(function (btn) { list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn); closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);