From 709801305fac4203d00df9067dd11110bb3d3c4a Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 10:41:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8B=E5=8D=95=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=B9=B6=E6=8E=92=E5=B8=83=E5=B1=80=EF=BC=8C=E5=93=81?= =?UTF-8?q?=E7=A7=8D=E6=8E=A8=E8=8D=90=E6=95=B0=E6=8D=AE=E5=BA=93=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=20SSE=20=E6=8E=A8=E9=80=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 期货下单与持仓监控左右并排,推荐按资金过滤存库,后台刷新并通过 EventSource 推送。 Co-authored-by: Cursor --- app.py | 2 + install_trading.py | 78 ++++++++++++- recommend_store.py | 66 +++++++++++ recommend_stream.py | 79 +++++++++++++ static/css/trade.css | 34 ++++-- static/js/trade.js | 256 +++++++++++++++++++++++++------------------ templates/trade.html | 126 +++++++++++++-------- 7 files changed, 480 insertions(+), 161 deletions(-) create mode 100644 recommend_store.py create mode 100644 recommend_stream.py diff --git a/app.py b/app.py index 612f24a..e45b937 100644 --- a/app.py +++ b/app.py @@ -309,7 +309,9 @@ def init_db(): ensure_kline_tables(conn) init_strategy_tables(conn) from risk.account_risk_lib import ensure_account_risk_schema + from recommend_store import ensure_recommend_tables ensure_account_risk_schema(conn) + ensure_recommend_tables(conn) conn.commit() conn.close() diff --git a/install_trading.py b/install_trading.py index bb74e11..34a4169 100644 --- a/install_trading.py +++ b/install_trading.py @@ -5,10 +5,11 @@ import json from datetime import datetime from typing import Any, Callable -from flask import flash, jsonify, redirect, render_template, request, url_for +from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context from contract_specs import calc_position_metrics, get_contract_spec from fee_specs import calc_fee_breakdown +from kline_stream import sse_format from position_sizing import ( MODE_FIXED, MODE_RISK, @@ -16,7 +17,8 @@ from position_sizing import ( calc_order_tick_metrics, normalize_sizing_mode, ) -from product_recommend import list_product_recommendations +from recommend_store import load_recommend_cache, refresh_recommend_cache +from recommend_stream import recommend_hub, start_recommend_worker from risk.account_risk_lib import ( assert_can_open, get_risk_status, @@ -300,6 +302,7 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe ).fetchone()["n"] conn.commit() sizing = get_sizing_mode(get_setting) + rec_cache = load_recommend_cache(conn) return render_template( "trade.html", trading_mode=mode, @@ -314,6 +317,8 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe sizing_mode=sizing, sizing_mode_label="以损定仓" if sizing == MODE_RISK else "固定张数", risk_percent=get_risk_percent(get_setting), + recommend_rows=rec_cache.get("rows") or [], + recommend_updated_at=rec_cache.get("updated_at"), ) finally: conn.close() @@ -626,10 +631,61 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe @app.route("/api/recommend/list") @login_required def api_recommend_list(): + """只读数据库缓存,不在请求时拉行情。""" conn = get_db() - capital = _capital(conn) - conn.close() - return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)}) + try: + payload = load_recommend_cache(conn) + return jsonify({"ok": True, **payload}) + finally: + conn.close() + + @app.route("/api/recommend/stream") + @login_required + def api_recommend_stream(): + from queue import Empty + + def generate(): + q = recommend_hub.subscribe() + try: + conn = get_db() + try: + payload = load_recommend_cache(conn) + finally: + conn.close() + yield sse_format("recommend", {"ok": True, **payload}) + while True: + try: + msg = q.get(timeout=25) + yield sse_format(msg["event"], msg["data"]) + except Empty: + yield ": heartbeat\n\n" + finally: + recommend_hub.unsubscribe(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/recommend/refresh", methods=["POST"]) + @login_required + def api_recommend_refresh(): + """手动触发一次后台刷新(仍写入数据库)。""" + conn = get_db() + try: + init_strategy_tables(conn) + capital = _capital(conn) + rows = refresh_recommend_cache(conn, capital, _main_price) + payload = load_recommend_cache(conn) + recommend_hub.broadcast("recommend", {"ok": True, **payload}) + return jsonify({"ok": True, "count": len(rows), **payload}) + finally: + conn.close() @app.route("/api/strategy/trend/preview", methods=["POST"]) @login_required @@ -946,3 +1002,15 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) app._risk_review_hook = hook_review_mood + + from db_conn import DB_PATH + + def _init_tables(conn): + init_strategy_tables(conn) + + start_recommend_worker( + db_path=DB_PATH, + get_capital_fn=_capital, + price_fn=_main_price, + init_tables_fn=_init_tables, + ) diff --git a/recommend_store.py b/recommend_store.py new file mode 100644 index 0000000..38a6532 --- /dev/null +++ b/recommend_store.py @@ -0,0 +1,66 @@ +"""品种推荐:计算、按资金过滤、SQLite 缓存。""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Callable, Optional + +from product_recommend import list_product_recommendations + +RECOMMEND_CACHE_SQL = """ +CREATE TABLE IF NOT EXISTS product_recommend_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + capital REAL NOT NULL DEFAULT 0, + rows_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT +) +""" + + +def ensure_recommend_tables(conn) -> None: + conn.execute(RECOMMEND_CACHE_SQL) + + +def filter_affordable_recommendations(rows: list[dict]) -> list[dict]: + """仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。""" + return [r for r in rows if r.get("status") in ("ok", "margin_ok")] + + +def refresh_recommend_cache( + conn, + capital: float, + price_fn: Callable[[str], Optional[float]], +) -> list[dict]: + """后台拉行情、筛选并写入数据库。""" + ensure_recommend_tables(conn) + all_rows = list_product_recommendations(capital, price_fn) + rows = filter_affordable_recommendations(all_rows) + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + capital=excluded.capital, + rows_json=excluded.rows_json, + updated_at=excluded.updated_at""", + (float(capital or 0), json.dumps(rows, ensure_ascii=False), now), + ) + conn.commit() + return rows + + +def load_recommend_cache(conn) -> dict: + """优先从数据库读取推荐列表。""" + ensure_recommend_tables(conn) + row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone() + if not row: + return {"capital": 0.0, "rows": [], "updated_at": None} + try: + rows = json.loads(row["rows_json"] or "[]") + except (TypeError, ValueError, json.JSONDecodeError): + rows = [] + return { + "capital": float(row["capital"] or 0), + "rows": rows if isinstance(rows, list) else [], + "updated_at": row["updated_at"], + } diff --git a/recommend_stream.py b/recommend_stream.py new file mode 100644 index 0000000..df9589c --- /dev/null +++ b/recommend_stream.py @@ -0,0 +1,79 @@ +"""品种推荐 SSE 推送与后台刷新。""" +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from typing import Callable, Optional + +from db_conn import connect_db +from kline_stream import sse_format +from recommend_store import load_recommend_cache, refresh_recommend_cache + +logger = logging.getLogger(__name__) + +REFRESH_INTERVAL_SEC = 60 + + +class RecommendStreamHub: + def __init__(self) -> None: + self._lock = threading.Lock() + self._subs: list[queue.Queue] = [] + + def subscribe(self) -> queue.Queue: + q: queue.Queue = queue.Queue(maxsize=8) + with self._lock: + self._subs.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + try: + self._subs.remove(q) + except ValueError: + pass + + def broadcast(self, event: str, data: dict) -> None: + msg = {"event": event, "data": data} + with self._lock: + subs = list(self._subs) + for q in subs: + try: + q.put_nowait(msg) + except queue.Full: + pass + + +recommend_hub = RecommendStreamHub() + + +def start_recommend_worker( + *, + db_path: str, + get_capital_fn: Callable, + price_fn: Callable[[str], Optional[float]], + init_tables_fn: Callable | None = None, + interval: int = REFRESH_INTERVAL_SEC, +) -> None: + """后台定时刷新推荐并推送给 SSE 订阅者。""" + + def _loop() -> None: + while True: + try: + conn = connect_db(db_path) + try: + if init_tables_fn: + init_tables_fn(conn) + capital = float(get_capital_fn(conn) or 0) + refresh_recommend_cache(conn, capital, price_fn) + payload = load_recommend_cache(conn) + finally: + conn.close() + recommend_hub.broadcast("recommend", {"ok": True, **payload}) + except Exception as exc: + logger.warning("recommend worker failed: %s", exc) + time.sleep(max(15, interval)) + + threading.Thread(target=_loop, daemon=True, name="recommend-worker").start() diff --git a/static/css/trade.css b/static/css/trade.css index 092ad5e..61b8841 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -1,14 +1,32 @@ -.trade-page{max-width:1100px;margin:0 auto} +.trade-page{max-width:1200px;margin:0 auto} .trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1.25rem} .trade-dashboard{display:flex;flex-direction:column;gap:1.25rem} -.trade-card{margin-bottom:0} -.trade-card h2{margin-bottom:.65rem} -.trade-order-status{display:grid;gap:.55rem;margin:.75rem 0;padding:.85rem 1rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.85rem} +.trade-row-split{display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;align-items:stretch} +.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column} +.trade-card h2{margin-bottom:.65rem;flex-shrink:0} +.trade-card .card-body{flex:1;min-height:0} +.trade-card-full{margin-bottom:0} +.trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem} +.trade-order-status-compact{margin-top:0} .trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem} -.trade-order-status .trend-active{padding-top:.35rem;border-top:1px dashed var(--card-border)} -.trade-order-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem 1rem;margin-top:1rem} -.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border);margin-top:1rem} +.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)} +.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:.75rem 0} +.trade-btn{border:none;border-radius:8px;padding:.65rem .3rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.12rem;color:#fff;font-weight:600} +.trade-btn .btn-price{font-size:1rem} +.trade-btn .btn-label{font-size:.82rem} +.trade-btn .btn-sub{font-size:.66rem;opacity:.85;font-weight:400} +.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)} +.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)} +.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)} +.trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem} .trade-footer strong{color:var(--accent)} .rec-blocked td{opacity:.55} .rec-ok td:first-child{font-weight:600} -#positions .card-body{max-height:520px} +#positions .card-body{max-height:480px;overflow-y:auto} + +@media (max-width:900px){ + .trade-row-split{grid-template-columns:1fr} + #positions .card-body{max-height:360px} + .trade-btn-row{grid-template-columns:repeat(2,1fr)} +} diff --git a/static/js/trade.js b/static/js/trade.js index 45fbe6c..340b338 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1,7 +1,15 @@ (function () { var list = document.getElementById('position-live-list'); var recommendList = document.getElementById('recommend-list'); + var symInput = document.getElementById('trade-symbol'); + var lotsInput = document.getElementById('trade-lots'); + var priceInput = document.getElementById('trade-price'); + var footer = document.getElementById('trade-footer'); + var slInput = document.getElementById('trade-sl'); + var tpInput = document.getElementById('trade-tp'); var pollTimer = null; + var recommendSource = null; + var quoteTimer = null; function runWhenReady(fn) { if (document.readyState === 'loading') { @@ -16,104 +24,120 @@ return Number(v).toFixed(digits === undefined ? 2 : digits); } + function selectedSymbol() { + return (symInput && symInput.value || '').trim(); + } + + function refreshQuote() { + var sym = selectedSymbol(); + var lots = lotsInput ? lotsInput.value : '1'; + if (!sym) return; + fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots)) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok) return; + if (priceInput && !priceInput.dataset.manual && data.price) { + priceInput.value = data.price; + } + var px = data.price != null ? data.price : '—'; + ['px-long', 'px-short'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.textContent = px; + }); + var pl = document.getElementById('pos-long'); + var ps = document.getElementById('pos-short'); + if (pl) pl.textContent = '≤' + (data.pos_long || 0); + if (ps) ps.textContent = '≤' + (data.pos_short || 0); + if (footer && data.metrics) { + var m = data.metrics; + var hint = footer.querySelector('.hint'); + var extra = + '

' + (data.name || sym) + ' 精度 ' + m.price_precision + + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)

'; + if (hint) { + hint.insertAdjacentHTML('afterend', extra); + var olds = footer.querySelectorAll('p:not(.hint):not(.text-loss)'); + for (var i = 0; i < olds.length - 1; i++) olds[i].remove(); + } + } + }).catch(function () {}); + } + + function scheduleQuote() { + clearTimeout(quoteTimer); + quoteTimer = setTimeout(refreshQuote, 400); + } + + function postOrder(offset, direction) { + var sym = selectedSymbol(); + if (!sym) { alert('请选择品种'); return; } + var body = { + symbol: sym, + offset: offset, + direction: direction, + lots: parseInt(lotsInput.value, 10) || 1, + price: parseFloat(priceInput.value) || 0, + stop_loss: slInput ? parseFloat(slInput.value) : null, + take_profit: tpInput ? parseFloat(tpInput.value) : null + }; + fetch('/api/trade/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(function (r) { return r.json(); }).then(function (data) { + if (!data.ok) { alert(data.error || '下单失败'); return; } + alert('已提交 ' + (data.lots || '') + ' 手'); + pollPositions(); + refreshQuote(); + }); + } + function buildPosCard(row) { - var pnlClass = ''; - if (row.float_pnl > 0) pnlClass = 'pnl-pos'; - if (row.float_pnl < 0) pnlClass = 'pnl-neg'; + var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); var pnlText = '--'; if (row.float_pnl != null) { var sign = row.float_pnl >= 0 ? '+' : ''; pnlText = sign + fmtNum(row.float_pnl) + '元'; - if (row.float_pct != null) { - pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)'; - } + if (row.float_pct != null) pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)'; } var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--'; var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); var closeBtn = ''; - if (row.close_url) { - closeBtn = - '
' + + closeBtn = '' + '
'; } else if (row.can_close) { - closeBtn = - ''; + closeBtn = ''; } - - var metaParts = ['来源 ' + (row.source_label || row.source) + '']; - if (row.risk_pct != null) { - metaParts.push('风险 ' + fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元'); - } - if (row.tick_value_total != null) { - metaParts.push('每跳 ' + fmtNum(row.tick_value_total) + '元'); - } - - var slTp = - '
' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '
' + - '
' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '
'; - - var footerParts = ['张数 ' + row.lots]; - if (row.margin != null) footerParts.push('保证金 ' + fmtNum(row.margin) + '元'); - if (row.position_pct != null) footerParts.push('仓位占比 ' + fmtNum(row.position_pct) + '%'); - if (openT) footerParts.push('开仓 ' + openT); - if (row.holding_duration) footerParts.push('持仓 ' + row.holding_duration); - if (row.est_fee != null) footerParts.push('手续费(估) ' + fmtNum(row.est_fee) + '元'); - return ( '
' + - '
' + - '
' + row.symbol + ' ' + dirBadge + '
' + - (row.symbol_code && row.symbol_code !== row.symbol ? '
' + row.symbol_code + '
' : '') + - '
' + closeBtn + '
' + - '
' + metaParts.join(' · ') + '
' + + '
' + row.symbol + ' ' + dirBadge + '
' + closeBtn + '
' + + '
来源 ' + (row.source_label || row.source) + '
' + '
' + '
' + fmtNum(row.entry_price) + '
' + - slTp + - '
' + rr + '
' + - '
' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '
' + - '
' + pnlText + '
' + - (row.est_fee != null ? - '
' + fmtNum(row.est_fee) + '元
' + - '
' + - '
' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '
' - : '') + - '
' + - '' + - '
' + '
' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '
' + + '
' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '
' + + '
' + pnlText + '
' + + '' ); } function closePosition(payload) { var price = payload.mark_price; - if (!price || price <= 0) { - alert('无法获取现价,请稍后重试'); - return; - } + if (!price || price <= 0) { alert('无法获取现价'); return; } if (!confirm('确认以 ' + price + ' 限价平仓 ' + payload.lots + ' 手?')) return; fetch('/api/trading/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - source: payload.source, - symbol_code: payload.symbol_code, - direction: payload.direction, - lots: payload.lots, - price: price, - monitor_id: payload.monitor_id - }) + body: JSON.stringify(payload) }).then(function (r) { return r.json(); }).then(function (d) { if (!d.ok) { alert(d.error || '平仓失败'); return; } pollPositions(); - }).catch(function () { alert('平仓请求失败'); }); + }); } function pollPositions() { @@ -140,7 +164,7 @@ } var rows = data.rows || []; if (!rows.length) { - list.innerHTML = '
暂无持仓。请通过上方「期货下单 → 策略交易」开仓,或连接 CTP 同步柜台持仓。
'; + list.innerHTML = '
暂无持仓。可在左侧下单,或通过策略交易开仓。
'; return; } list.innerHTML = rows.map(buildPosCard).join(''); @@ -152,53 +176,76 @@ }) .catch(function () { if (list.innerHTML.indexOf('pos-card') < 0) { - list.innerHTML = '
加载失败,请刷新页面
'; + list.innerHTML = '
持仓加载失败
'; } }); } function badgeClass(status) { if (status === 'ok') return 'profit'; - if (status === 'blocked') return 'loss'; return 'planned'; } - function buildRecommendRow(r) { - return ( - '' + - '' + (r.name || '') + ' ' + (r.ths || '') + '' + - '' + (r.exchange || '') + '' + - '' + (r.price != null ? r.price : '—') + '' + - '' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '' + - '' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '' + - '' + (r.status_label || '') + '' + - '' - ); + function renderRecommendations(data) { + if (!recommendList || !data) return; + var recCap = document.getElementById('rec-capital'); + if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); + var recUpd = document.getElementById('rec-updated'); + if (recUpd && data.updated_at) recUpd.textContent = '更新 ' + data.updated_at; + var rows = data.rows || []; + if (!rows.length) { + recommendList.innerHTML = '当前资金下暂无推荐品种'; + return; + } + recommendList.innerHTML = rows.map(function (r) { + return ( + '' + + '' + (r.name || '') + ' ' + (r.ths || '') + '' + + '' + (r.exchange || '') + '' + + '' + (r.price != null ? r.price : '—') + '' + + '' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '' + + '' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '' + + '' + (r.status_label || '') + '' + + '' + ); + }).join(''); } - function loadRecommendations() { - if (!recommendList) return; - fetch('/api/recommend/list') - .then(function (r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(function (data) { - if (!data.ok) throw new Error(data.error || 'load failed'); - var recCap = document.getElementById('rec-capital'); - if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); - var rows = data.rows || []; - if (!rows.length) { - recommendList.innerHTML = '暂无推荐数据'; - return; - } - recommendList.innerHTML = rows.map(buildRecommendRow).join(''); - }) - .catch(function () { - recommendList.innerHTML = '品种推荐加载失败,请刷新页面'; - }); + function connectRecommendStream() { + if (recommendSource) { + recommendSource.close(); + recommendSource = null; + } + recommendSource = new EventSource('/api/recommend/stream'); + recommendSource.addEventListener('recommend', function (ev) { + try { + renderRecommendations(JSON.parse(ev.data)); + } catch (e) { /* ignore */ } + }); + recommendSource.onerror = function () { + if (recommendSource) { + recommendSource.close(); + recommendSource = null; + } + setTimeout(connectRecommendStream, 5000); + }; } + if (symInput) symInput.addEventListener('input', scheduleQuote); + if (lotsInput) lotsInput.addEventListener('input', scheduleQuote); + if (priceInput) { + priceInput.addEventListener('input', function () { priceInput.dataset.manual = '1'; }); + } + + var btnLong = document.getElementById('btn-open-long'); + var btnShort = document.getElementById('btn-open-short'); + var btnCloseL = document.getElementById('btn-close-long'); + var btnCloseS = document.getElementById('btn-close-short'); + if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); }); + if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); }); + if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); }); + if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); }); + var btnConnect = document.getElementById('btn-ctp-connect'); if (btnConnect) { btnConnect.addEventListener('click', function () { @@ -219,7 +266,8 @@ runWhenReady(function () { pollPositions(); - loadRecommendations(); + connectRecommendStream(); pollTimer = setInterval(pollPositions, 3000); + scheduleQuote(); }); })(); diff --git a/templates/trade.html b/templates/trade.html index c9dc98f..e824922 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -19,60 +19,81 @@
-
-

期货下单

-
-

开仓、加仓由程序在「策略交易」中执行,经 CTP 自动报单至 SimNow / 期货公司柜台。

-
-
- 计仓模式 - {{ sizing_mode_label }} - {% if sizing_mode == 'risk' %} - · 单笔风险 {{ risk_percent }}% - {% endif %} +
+
+

期货下单

+
+
+
+ 计仓 + {{ sizing_mode_label }} + {% if sizing_mode == 'risk' %}· {{ risk_percent }}%{% endif %} + · 监控 {{ monitor_count }} 笔 +
-
- 风控状态 - {{ risk_status.status_label }} + +
+
+ + +
+
+
+
+ + +
+
+ + +
-
- 程序监控 - {{ monitor_count }} 笔 - {% if roll_count %}· 滚仓组 {{ roll_count }}{% endif %} + +
+
+
- {% if active_trend %} -
- 趋势回调 - #{{ active_trend.id }} {{ active_trend.symbol }} {{ '多' if active_trend.direction=='long' else '空' }} - 已开 {{ active_trend.lots_open or 0 }}/{{ active_trend.target_lots }} 手 + +
+ + + + +
+ + - {% endif %}
- {% if not ctp_status.connected %} -

请先连接 CTP,程序报单才会进入柜台。

- {% endif %} - {% if ctp_status.last_error %} -

{{ ctp_status.last_error }}

- {% endif %} - + +
+

持仓监控

+
+
加载中…
-
-

持仓监控

-
-
加载中…
-
-
- -
+

品种推荐

-

按当前权益 {{ '%.2f'|format(capital) }} 元筛选; - 灰色为保证金不足,优先展示可开 1 手且风险规则较友好的品种。

+

按权益 {{ '%.2f'|format(capital) }} 元筛选,仅显示可开 1 手的品种。 + {% if recommend_updated_at %}更新 {{ recommend_updated_at }}{% else %}后台刷新中…{% endif %} +

@@ -81,7 +102,20 @@ - + {% if recommend_rows %} + {% for r in recommend_rows %} + + + + + + + + + {% endfor %} + {% else %} + + {% endif %}
品种推荐加载中…
{{ r.name }} {{ r.ths }}{{ r.exchange }}{% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}{{ r.status_label }}
等待后台推送推荐…
@@ -91,5 +125,9 @@
{% endblock %} {% block extra_js %} + {% endblock %}