From 63beda3c71f65ba3b3a60495394edf011475c34c Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 14:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=88=E4=BA=8F=E6=AF=94=E4=B8=8E?= =?UTF-8?q?=E4=BA=8F=E6=8D=9F=E9=A2=9D=E5=BA=A6=E5=B1=95=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=B8=82=E4=BB=B7FAK=E6=8A=A5=E5=8D=95=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=AD=A2=E7=9B=88=E6=AD=A2=E6=8D=9F=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- install_trading.py | 118 +++++++++++++++---------------------------- static/css/trade.css | 1 + static/js/trade.js | 85 ++++++++++++++++++++++++++++--- templates/trade.html | 3 +- vnpy_bridge.py | 6 ++- 5 files changed, 125 insertions(+), 88 deletions(-) diff --git a/install_trading.py b/install_trading.py index 477d528..a44aef2 100644 --- a/install_trading.py +++ b/install_trading.py @@ -531,6 +531,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "current_price": mark, "margin": pos_metrics.get("margin"), "position_pct": pos_metrics.get("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, "float_pnl": float_pnl, "est_fee": fee_info["total_fee"], "est_fee_open": fee_info["open_fee"], @@ -818,7 +821,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se @app.route("/api/trading/monitor/upsert", methods=["POST"]) @login_required def api_trading_monitor_upsert(): - """为已有 CTP 持仓补充/更新本地止盈止损监控。""" + """为已有持仓补充/更新本地止盈止损监控。""" d = request.get_json(silent=True) or {} sym = (d.get("symbol_code") or d.get("symbol") or "").strip() direction = (d.get("direction") or "long").strip().lower() @@ -834,87 +837,46 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if sl is None and tp is None: return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400 mode = get_trading_mode(get_setting) - if not ctp_status(mode).get("connected"): - return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 - 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 - lots = int(p.get("lots") or lots) - entry = float(p.get("avg_price") or entry or 0) - sym = (p.get("symbol") or sym).strip() - break - if not has_pos: - return jsonify({"ok": False, "error": "柜台无对应持仓"}), 400 conn = get_db() try: init_strategy_tables(conn) - mon = None - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active'" - ).fetchall(): - row = dict(r) - if row.get("direction") != direction: - continue - if _match_ctp_symbol(sym, row.get("symbol") or ""): - mon = row - break - codes = ths_to_codes(sym) - now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if "trailing_be" in d: - trailing_be = 1 if d.get("trailing_be") else 0 - elif mon: - trailing_be = int(mon.get("trailing_be") or 0) - else: - trailing_be = 0 - ensure_monitor_order_columns(conn) - if mon: - initial_sl = mon.get("initial_stop_loss") - if sl is not None and initial_sl is None: - initial_sl = sl - conn.execute( - """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?, - initial_stop_loss=?, trailing_be=? - WHERE id=?""", - ( - sl, tp, lots, entry or mon.get("entry_price"), - initial_sl, trailing_be, - mon["id"], - ), - ) - mid = mon["id"] - else: - conn.execute( - """INSERT INTO trade_order_monitors ( - symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, initial_stop_loss, trailing_be, - open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""", - ( - sym, - codes.get("name", sym) if codes else sym, - codes.get("market_code", "") if codes else "", - direction, - lots, - entry, - sl, - tp, - sl, - trailing_be, - now_s, - "manual", - ), - ) - mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + mon = _find_active_monitor(conn, sym, direction) + has_pos = bool(mon) + ths_sym = sym + if ctp_status(mode).get("connected"): + for p in _ctp_positions(mode, refresh_if_empty=False): + 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 + lots = int(p.get("lots") or lots) + entry = float(p.get("avg_price") or entry or 0) + ths_sym = _ctp_pos_to_ths_code(p) or sym + break + if not has_pos: + return jsonify({"ok": False, "error": "未找到对应持仓"}), 400 + trailing_be = 1 if d.get("trailing_be") else ( + int(mon.get("trailing_be") or 0) if mon else 0 + ) + mid = _upsert_open_monitor( + conn, + sym=ths_sym, + direction=direction, + lots=lots, + price=entry, + sl=sl, + tp=tp, + trailing_be=trailing_be, + ) conn.commit() - mon_row = conn.execute( - "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), - ).fetchone() - return jsonify({"ok": True, "monitor_id": mid, "message": "止盈止损已保存,程序本地监控"}) + _push_position_snapshot_async() + return jsonify({ + "ok": True, + "monitor_id": mid, + "message": "止盈止损已保存,程序本地监控", + }) finally: conn.close() diff --git a/static/css/trade.css b/static/css/trade.css index 40dd1b0..da822c7 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -36,6 +36,7 @@ .trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} .trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} .trailing-be-toggle input{width:auto;margin:0} +.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0} .session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} .trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} .trade-order-msg.ok{color:var(--profit)} diff --git a/static/js/trade.js b/static/js/trade.js index 6e7d8f9..d55164b 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -208,6 +208,43 @@ return parseFloat(priceInput && priceInput.value) || 0; } + function calcRR(direction, entry, sl, tp) { + entry = parseFloat(entry); + sl = parseFloat(sl); + tp = parseFloat(tp); + if (!entry || !sl || !tp) return null; + var risk, reward; + if (direction === 'long') { + risk = entry - sl; + reward = tp - entry; + } else if (direction === 'short') { + risk = sl - entry; + reward = entry - tp; + } else { + return null; + } + if (risk <= 0 || reward <= 0) return null; + return (reward / risk).toFixed(2); + } + + function updateRRDisplay() { + var el = document.getElementById('trade-rr-hint'); + if (!el) return; + var dir = dirSelect ? dirSelect.value : 'long'; + var entry = entryPrice(); + var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; + var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0; + var rr = calcRR(dir, entry, sl, tp); + if (rr) { + el.textContent = '盈亏比 ' + rr + ':1'; + el.hidden = false; + } else { + el.textContent = ''; + el.hidden = true; + } + } + + function setPriceType(type) { priceType = type === 'market' ? 'market' : 'limit'; document.querySelectorAll('.price-tab').forEach(function (btn) { @@ -218,6 +255,7 @@ if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice; } if (marketHint) marketHint.hidden = priceType !== 'market'; + updateRRDisplay(); } function updateCtpBadge(connected, connecting) { @@ -547,7 +585,7 @@ '' : ''; var orderBtn = ''; if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { @@ -561,12 +599,19 @@ '' : ''; var actionBtns = (orderBtn || closeBtn) ? '
' + orderBtn + closeBtn + '
' : ''; + var riskMeta = ''; + if (row.rr_ratio != null) { + riskMeta += ' · 盈亏比 ' + row.rr_ratio + ':1'; + } + if (row.risk_amount != null) { + riskMeta += ' · 亏损额度 ' + fmtNum(row.risk_amount) + ' 元'; + } return ( '
' + '
' + row.symbol + ' ' + dirBadge + '
' + '
' + (row.symbol_code || '') + '
' + actionBtns + '
' + - '
来源 ' + (row.source_label || 'CTP') + '' + + '
来源 ' + (row.source_label || 'CTP') + '' + riskMeta + (row.sync_pending ? ' · 同步柜台中…' : '') + ' · 浮盈' + (slTpBtn ? ' · ' + slTpBtn : '') + @@ -652,22 +697,33 @@ fetch('/api/trading/monitor/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ symbol_code: payload.symbol_code, direction: payload.direction, lots: payload.lots, entry_price: payload.entry_price, + monitor_id: payload.monitor_id || null, stop_loss: sl, take_profit: tp }) }) - .then(function (r) { return r.json(); }) + .then(function (r) { + if (!r.ok) { + return r.json().catch(function () { return {}; }).then(function (d) { + throw new Error(d.error || ('HTTP ' + r.status)); + }); + } + return r.json(); + }) .then(function (d) { if (!d.ok) throw new Error(d.error || '保存失败'); pollPositions(); }) .catch(function (e) { - alert(e.message || '保存失败'); + var msg = e.message || '保存失败'; + if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行'; + alert(msg); if (btn) { btn.disabled = false; btn.textContent = '设置止盈止损'; @@ -830,13 +886,27 @@ checkLotsLimit(); }); if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit); - if (slInput) slInput.addEventListener('input', scheduleAutoCalc); - if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc); - if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc); + if (slInput) { + slInput.addEventListener('input', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); + } + if (tpInput) { + tpInput.addEventListener('input', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); + } + if (dirSelect) dirSelect.addEventListener('change', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); if (priceInput) { priceInput.addEventListener('input', function () { if (priceType === 'limit') priceInput.dataset.manual = '1'; scheduleAutoCalc(); + updateRRDisplay(); }); } @@ -869,6 +939,7 @@ } }); updateSessionUi(); + updateRRDisplay(); scheduleQuote(); }); })(); diff --git a/templates/trade.html b/templates/trade.html index 233dca5..4b99b6a 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -67,7 +67,7 @@
- +
@@ -85,6 +85,7 @@ 移动保本 + diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 5341878..47843d4 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -825,10 +825,11 @@ class CtpBridge: raise ValueError(f"未知开平: {offset}") use_market = (order_type or "limit").lower() == "market" - ot = OrderType.LIMIT if use_market: + ot = OrderType.FAK price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) else: + ot = OrderType.LIMIT price = round_to_tick(float(price), tick) if price <= 0: raise ValueError("委托价格无效,请检查行情或手动填写价格") @@ -929,7 +930,8 @@ def ctp_get_account(mode: str) -> dict[str, Any]: def ctp_list_positions(mode: str, *, refresh_if_empty: bool = True) -> list[dict[str, Any]]: b = get_bridge() - b.ensure_connected(mode) + if b.connected_mode != mode or not b.ping(): + return [] return b.list_positions(refresh_if_empty=refresh_if_empty)