diff --git a/.env.example b/.env.example index 6924276..66d0efd 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ TRADING_MODE=simulation POSITION_SIZING_MODE=risk RISK_PERCENT=1 +# CTP 断线后后台自动重连(true/false) +CTP_AUTO_RECONNECT=true + # —— SimNow 模拟盘(注册见 docs/SIMNOW.md)—— SIMNOW_USER= SIMNOW_PASSWORD= diff --git a/ctp_reconnect.py b/ctp_reconnect.py new file mode 100644 index 0000000..494588b --- /dev/null +++ b/ctp_reconnect.py @@ -0,0 +1,40 @@ +"""CTP 断线自动重连(后台线程)。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Callable + +from vnpy_bridge import ctp_try_auto_reconnect + +logger = logging.getLogger(__name__) + +RECONNECT_INTERVAL_SEC = 30 + + +def _auto_reconnect_enabled() -> bool: + return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None: + """定时检测 CTP 连接,断线后自动重连。""" + + def _loop() -> None: + time.sleep(5) + while True: + try: + if _auto_reconnect_enabled(): + mode = get_mode_fn() + if ctp_try_auto_reconnect(mode): + logger.debug("CTP 连接正常 [%s]", mode) + except Exception as exc: + logger.warning("CTP reconnect worker: %s", exc) + time.sleep(max(15, interval)) + + threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start() diff --git a/install_trading.py b/install_trading.py index e706b1f..e070d53 100644 --- a/install_trading.py +++ b/install_trading.py @@ -19,6 +19,7 @@ from position_sizing import ( ) from recommend_store import load_recommend_cache, refresh_recommend_cache from recommend_stream import recommend_hub, start_recommend_worker +from ctp_reconnect import start_ctp_reconnect_worker from risk.account_risk_lib import ( assert_can_open, get_risk_status, @@ -918,3 +919,4 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe price_fn=_main_price, init_tables_fn=_init_tables, ) + start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) diff --git a/static/css/trade.css b/static/css/trade.css index 53162d8..dc3141c 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -10,19 +10,17 @@ .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-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem .65rem;margin-bottom:.85rem} .trade-form-grid .span-2{grid-column:span 2} -.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)} -.trade-field select,.trade-field input{width:100%} +.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)} +.trade-field select,.trade-field input{width:100%;box-sizing:border-box} +.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default} .price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem} -.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.25rem .65rem;border-radius:6px;font-size:.75rem;cursor:pointer} -.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600} +.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center} +.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} .market-hint{font-size:.7rem;margin-top:.25rem} -.calc-lots-row{display:flex;gap:.4rem} -.calc-lots-row input{flex:1} -.calc-lots-row .btn-secondary{padding:.35rem .6rem;font-size:.75rem;white-space:nowrap} -.trade-action-row{display:flex;gap:.65rem;margin:.75rem 0 .5rem} -.trade-action-row .btn-open{flex:1;padding:.65rem} +.trade-action-row{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin:.85rem 0 .55rem} +.trade-action-row .btn-open,.trade-action-row .btn-secondary{padding:.6rem .75rem;font-size:.88rem;width:100%} .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} diff --git a/static/js/trade.js b/static/js/trade.js index 4473f70..ff261f8 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -14,8 +14,11 @@ var pollTimer = null; var recommendSource = null; var quoteTimer = null; + var calcTimer = null; var lastQuotePrice = null; var priceType = 'limit'; + var lastCtpReconnectAt = 0; + var ctpReconnecting = false; function runWhenReady(fn) { if (document.readyState === 'loading') { @@ -63,6 +66,18 @@ if (marketHint) marketHint.hidden = priceType !== 'market'; } + function updateCtpBadge(connected) { + var ctpBadge = document.getElementById('ctp-badge'); + var btnConnect = document.getElementById('btn-ctp-connect'); + if (ctpBadge) { + ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接'; + ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned'); + } + if (btnConnect && connected) { + btnConnect.textContent = '重连 CTP'; + } + } + function refreshQuote() { var sym = selectedSymbol(); var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1'); @@ -83,6 +98,7 @@ '' + (data.name || sym) + ' 精度 ' + m.price_precision + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; } + scheduleAutoCalc(); }).catch(function () {}); } @@ -91,14 +107,23 @@ quoteTimer = setTimeout(refreshQuote, 400); } - function calcLotsPreview() { + function scheduleAutoCalc() { + if (!isRiskMode()) return; + clearTimeout(calcTimer); + calcTimer = setTimeout(autoCalcLots, 450); + } + + function autoCalcLots() { + if (!isRiskMode() || !lotsCalc) return; var sym = selectedSymbol(); var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0; if (!sym || !entry || !sl) { - alert('请填写品种、入场价与止损'); + lotsCalc.value = ''; + lotsCalc.placeholder = '填写止损后自动计算'; return; } + lotsCalc.placeholder = '计算中…'; fetch('/api/trade/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -111,12 +136,47 @@ take_profit: parseFloat(tpInput && tpInput.value) || 0 }) }).then(function (r) { return r.json(); }).then(function (data) { - if (!data.ok) { alert(data.error || '计算失败'); return; } - if (lotsCalc) lotsCalc.value = data.lots; + if (!data.ok) { + lotsCalc.value = ''; + lotsCalc.placeholder = data.error || '无法计算'; + return; + } + lotsCalc.value = data.lots; + lotsCalc.placeholder = '填写止损后自动计算'; scheduleQuote(); + }).catch(function () { + lotsCalc.placeholder = '计算失败'; }); } + function tryAutoCtpReconnect() { + if (ctpReconnecting) return; + var now = Date.now(); + if (now - lastCtpReconnectAt < 30000) return; + lastCtpReconnectAt = now; + ctpReconnecting = true; + fetch('/api/ctp/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto: true }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.ok && d.status && d.status.connected) { + updateCtpBadge(true); + var avail = document.getElementById('avail-display'); + if (avail && d.account && d.account.available != null) { + avail.textContent = Number(d.account.available).toFixed(2); + } + pollPositions(); + } + }) + .catch(function () { /* ignore */ }) + .finally(function () { + ctpReconnecting = false; + }); + } + function postOrder(offset) { var sym = selectedSymbol(); if (!sym) { alert('请选择品种'); return; } @@ -129,7 +189,7 @@ var lots = effectiveLots(); if (offset === 'open') { if (isRiskMode() && lots <= 0) { - alert('请先点击「计算」得到手数'); + alert('请填写止损,系统将自动计算手数'); return; } if (!isRiskMode() && lots <= 0) { @@ -216,14 +276,12 @@ .then(function (data) { var cap = document.getElementById('cap-display'); if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); - var ctpBadge = document.getElementById('ctp-badge'); - if (ctpBadge && data.ctp_status) { - ctpBadge.textContent = data.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接'; - ctpBadge.className = 'badge ' + (data.ctp_status.connected ? 'profit' : 'planned'); - } + var connected = data.ctp_status && data.ctp_status.connected; + updateCtpBadge(!!connected); var rows = data.rows || []; - if (!data.ctp_status || !data.ctp_status.connected) { - list.innerHTML = '