feat: CTP 断线重连、下单卡片优化、手数自动计算

后台每 30s 检测并重连;以损定仓填止损后自动算手数;开仓/平仓按钮并排对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 11:56:20 +08:00
parent 36e26973fb
commit b4250171d5
7 changed files with 177 additions and 45 deletions
+3
View File
@@ -19,6 +19,9 @@ TRADING_MODE=simulation
POSITION_SIZING_MODE=risk POSITION_SIZING_MODE=risk
RISK_PERCENT=1 RISK_PERCENT=1
# CTP 断线后后台自动重连(true/false)
CTP_AUTO_RECONNECT=true
# —— SimNow 模拟盘(注册见 docs/SIMNOW.md)—— # —— SimNow 模拟盘(注册见 docs/SIMNOW.md)——
SIMNOW_USER= SIMNOW_USER=
SIMNOW_PASSWORD= SIMNOW_PASSWORD=
+40
View File
@@ -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()
+2
View File
@@ -19,6 +19,7 @@ from position_sizing import (
) )
from recommend_store import load_recommend_cache, refresh_recommend_cache from recommend_store import load_recommend_cache, refresh_recommend_cache
from recommend_stream import recommend_hub, start_recommend_worker from recommend_stream import recommend_hub, start_recommend_worker
from ctp_reconnect import start_ctp_reconnect_worker
from risk.account_risk_lib import ( from risk.account_risk_lib import (
assert_can_open, assert_can_open,
get_risk_status, get_risk_status,
@@ -918,3 +919,4 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe
price_fn=_main_price, price_fn=_main_price,
init_tables_fn=_init_tables, init_tables_fn=_init_tables,
) )
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
+8 -10
View File
@@ -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{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-compact{margin-top:0}
.trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem} .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-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 label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
.trade-field select,.trade-field input{width:100%} .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-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{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} .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} .market-hint{font-size:.7rem;margin-top:.25rem}
.calc-lots-row{display:flex;gap:.4rem} .trade-action-row{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin:.85rem 0 .55rem}
.calc-lots-row input{flex:1} .trade-action-row .btn-open,.trade-action-row .btn-secondary{padding:.6rem .75rem;font-size:.88rem;width:100%}
.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-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{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)} .trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55} .rec-blocked td{opacity:.55}
+78 -21
View File
@@ -14,8 +14,11 @@
var pollTimer = null; var pollTimer = null;
var recommendSource = null; var recommendSource = null;
var quoteTimer = null; var quoteTimer = null;
var calcTimer = null;
var lastQuotePrice = null; var lastQuotePrice = null;
var priceType = 'limit'; var priceType = 'limit';
var lastCtpReconnectAt = 0;
var ctpReconnecting = false;
function runWhenReady(fn) { function runWhenReady(fn) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -63,6 +66,18 @@
if (marketHint) marketHint.hidden = priceType !== 'market'; 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() { function refreshQuote() {
var sym = selectedSymbol(); var sym = selectedSymbol();
var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1'); var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1');
@@ -83,6 +98,7 @@
'<strong>' + (data.name || sym) + '</strong> 精度 ' + m.price_precision + '<strong>' + (data.name || sym) + '</strong> 精度 ' + m.price_precision +
' 位 · 每跳 <strong class="text-accent">' + m.tick_value_total + '</strong> 元(' + lots + ' 手)'; ' 位 · 每跳 <strong class="text-accent">' + m.tick_value_total + '</strong> 元(' + lots + ' 手)';
} }
scheduleAutoCalc();
}).catch(function () {}); }).catch(function () {});
} }
@@ -91,14 +107,23 @@
quoteTimer = setTimeout(refreshQuote, 400); 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 sym = selectedSymbol();
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
var sl = parseFloat(slInput && slInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0;
if (!sym || !entry || !sl) { if (!sym || !entry || !sl) {
alert('请填写品种、入场价与止损'); lotsCalc.value = '';
lotsCalc.placeholder = '填写止损后自动计算';
return; return;
} }
lotsCalc.placeholder = '计算中…';
fetch('/api/trade/preview', { fetch('/api/trade/preview', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -111,12 +136,47 @@
take_profit: parseFloat(tpInput && tpInput.value) || 0 take_profit: parseFloat(tpInput && tpInput.value) || 0
}) })
}).then(function (r) { return r.json(); }).then(function (data) { }).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) { alert(data.error || '计算失败'); return; } if (!data.ok) {
if (lotsCalc) lotsCalc.value = data.lots; lotsCalc.value = '';
lotsCalc.placeholder = data.error || '无法计算';
return;
}
lotsCalc.value = data.lots;
lotsCalc.placeholder = '填写止损后自动计算';
scheduleQuote(); 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) { function postOrder(offset) {
var sym = selectedSymbol(); var sym = selectedSymbol();
if (!sym) { alert('请选择品种'); return; } if (!sym) { alert('请选择品种'); return; }
@@ -129,7 +189,7 @@
var lots = effectiveLots(); var lots = effectiveLots();
if (offset === 'open') { if (offset === 'open') {
if (isRiskMode() && lots <= 0) { if (isRiskMode() && lots <= 0) {
alert('请先点击「计算」得到手数'); alert('请填写止损,系统将自动计算手数');
return; return;
} }
if (!isRiskMode() && lots <= 0) { if (!isRiskMode() && lots <= 0) {
@@ -216,14 +276,12 @@
.then(function (data) { .then(function (data) {
var cap = document.getElementById('cap-display'); var cap = document.getElementById('cap-display');
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
var ctpBadge = document.getElementById('ctp-badge'); var connected = data.ctp_status && data.ctp_status.connected;
if (ctpBadge && data.ctp_status) { updateCtpBadge(!!connected);
ctpBadge.textContent = data.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (data.ctp_status.connected ? 'profit' : 'planned');
}
var rows = data.rows || []; var rows = data.rows || [];
if (!data.ctp_status || !data.ctp_status.connected) { if (!connected) {
list.innerHTML = '<div class="empty-hint">请先连接 CTP,持仓将显示柜台实际数据。</div>'; list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return; return;
} }
if (!rows.length) { if (!rows.length) {
@@ -286,20 +344,18 @@
}); });
}); });
if (symInput) symInput.addEventListener('input', scheduleQuote); if (symInput) symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); });
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote); if (lotsInput) lotsInput.addEventListener('input', scheduleQuote);
if (slInput) slInput.addEventListener('input', function () { if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
if (isRiskMode() && lotsCalc) lotsCalc.value = ''; if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
}); if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
if (priceInput) { if (priceInput) {
priceInput.addEventListener('input', function () { priceInput.addEventListener('input', function () {
if (priceType === 'limit') priceInput.dataset.manual = '1'; if (priceType === 'limit') priceInput.dataset.manual = '1';
scheduleAutoCalc();
}); });
} }
var btnCalc = document.getElementById('btn-calc-lots');
if (btnCalc) btnCalc.addEventListener('click', calcLotsPreview);
var btnOpen = document.getElementById('btn-open'); var btnOpen = document.getElementById('btn-open');
var btnClose = document.getElementById('btn-close-pos'); var btnClose = document.getElementById('btn-close-pos');
if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); }); if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); });
@@ -314,11 +370,12 @@
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (d) { .then(function (d) {
if (!d.ok) { alert(d.error || '连接失败'); return; } if (!d.ok) { alert(d.error || '连接失败'); return; }
location.reload(); updateCtpBadge(true);
pollPositions();
}) })
.finally(function () { .finally(function () {
btnConnect.disabled = false; btnConnect.disabled = false;
btnConnect.textContent = '连 CTP'; btnConnect.textContent = '连 CTP';
}); });
}); });
} }
+5 -11
View File
@@ -15,7 +15,8 @@
{% if ctp_account.available is defined and ctp_status.connected %} {% if ctp_account.available is defined and ctp_status.connected %}
<span class="text-muted">可用 <strong id="avail-display">{{ '%.2f'|format(ctp_account.available) }}</strong></span> <span class="text-muted">可用 <strong id="avail-display">{{ '%.2f'|format(ctp_account.available) }}</strong></span>
{% endif %} {% endif %}
<button type="button" class="btn-primary" id="btn-ctp-connect" style="padding:.4rem .9rem;font-size:.8rem">连接 CTP</button> <button type="button" class="btn-primary" id="btn-ctp-connect" style="padding:.4rem .9rem;font-size:.8rem">{% if ctp_status.connected %}重连 CTP{% else %}连接 CTP{% endif %}</button>
<span class="text-muted" style="font-size:.72rem">断线自动重连</span>
</div> </div>
<div class="trade-dashboard"> <div class="trade-dashboard">
@@ -46,9 +47,10 @@
</select> </select>
</div> </div>
<div class="trade-field" id="field-lots" {% if sizing_mode == 'risk' %}hidden{% endif %}> <div class="trade-field" id="field-lots">
<label class="text-label">手数</label> <label class="text-label">手数</label>
<input type="number" id="trade-lots" min="1" step="1" value="1"> <input type="number" id="trade-lots" min="1" step="1" value="1" {% if sizing_mode == 'risk' %}hidden{% endif %}>
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
</div> </div>
<div class="trade-field span-2"> <div class="trade-field span-2">
@@ -69,14 +71,6 @@
<label class="text-label">止盈</label> <label class="text-label">止盈</label>
<input type="number" id="trade-tp" step="any"> <input type="number" id="trade-tp" step="any">
</div> </div>
<div class="trade-field" id="field-calc-lots" {% if sizing_mode != 'risk' %}hidden{% endif %}>
<label class="text-label">计算手数</label>
<div class="calc-lots-row">
<input type="text" id="trade-lots-calc" readonly placeholder="填写止损后计算">
<button type="button" class="btn-secondary" id="btn-calc-lots">计算</button>
</div>
</div>
</div> </div>
<div class="trade-action-row"> <div class="trade-action-row">
+41 -3
View File
@@ -115,6 +115,8 @@ class CtpBridge:
return self._connected_mode return self._connected_mode
def status(self, mode: str) -> dict[str, Any]: def status(self, mode: str) -> dict[str, Any]:
if self._connected_mode == mode:
self.ping()
st = _setting_for_mode(mode) st = _setting_for_mode(mode)
missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)] missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)]
return { return {
@@ -132,7 +134,9 @@ class CtpBridge:
if not self._engine: if not self._engine:
raise RuntimeError(self._last_error or "vnpy 引擎未初始化") raise RuntimeError(self._last_error or "vnpy 引擎未初始化")
if self._connected_mode == mode and not force: if self._connected_mode == mode and not force:
return if self.ping():
return
self._connected_mode = None
setting = _setting_for_mode(mode) setting = _setting_for_mode(mode)
if not setting.get("用户名") or not setting.get("密码"): if not setting.get("用户名") or not setting.get("密码"):
raise ValueError( raise ValueError(
@@ -190,8 +194,24 @@ class CtpBridge:
raise RuntimeError(hint) raise RuntimeError(hint)
def ensure_connected(self, mode: str) -> None: def ensure_connected(self, mode: str) -> None:
if self._connected_mode != mode: if self._connected_mode == mode and self.ping():
self.connect(mode) return
self.connect(mode)
def ping(self) -> bool:
"""检测连接是否仍有效;无效则清除 connected 状态。"""
if not self._engine or not self._connected_mode:
return False
try:
if self._engine.get_all_accounts():
return True
except Exception as exc:
logger.debug("CTP ping failed: %s", exc)
self._connected_mode = None
return False
def mark_disconnected(self) -> None:
self._connected_mode = None
def get_account(self) -> dict[str, Any]: def get_account(self) -> dict[str, Any]:
if not self._engine: if not self._engine:
@@ -310,6 +330,24 @@ def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
return b.status(mode) return b.status(mode)
def ctp_try_auto_reconnect(mode: str) -> bool:
"""断线时静默重连;已连接且 ping 正常则直接返回 True。"""
b = get_bridge()
if not b.available():
return False
st = _setting_for_mode(mode)
if not st.get("用户名") or not st.get("密码") or not st.get("交易服务器"):
return False
if b.connected_mode == mode and b.ping():
return True
try:
b.connect(mode, force=True)
return b.connected_mode == mode
except Exception as exc:
logger.info("CTP 自动重连失败: %s", exc)
return False
def ctp_status(mode: str) -> dict[str, Any]: def ctp_status(mode: str) -> dict[str, Any]:
return get_bridge().status(mode) return get_bridge().status(mode)