feat: 止盈止损秒级监控市价平仓记交易记录,并加手数超限提醒。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 13:16:19 +08:00
parent f05362ea74
commit 598a1407e1
5 changed files with 299 additions and 21 deletions
+5
View File
@@ -11,6 +11,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for, R
from contract_specs import calc_position_metrics, get_contract_spec from contract_specs import calc_position_metrics, get_contract_spec
from fee_specs import calc_fee_breakdown from fee_specs import calc_fee_breakdown
from kline_stream import sse_format from kline_stream import sse_format
from market_sessions import is_trading_session
from position_sizing import ( from position_sizing import (
MODE_FIXED, MODE_FIXED,
MODE_RISK, MODE_RISK,
@@ -479,6 +480,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"ctp_status": ctp_st, "ctp_status": ctp_st,
"trading_mode_label": trading_mode_label(get_setting), "trading_mode_label": trading_mode_label(get_setting),
"risk_status": risk, "risk_status": risk,
"trading_session": is_trading_session(),
}) })
finally: finally:
conn.close() conn.close()
@@ -1411,6 +1413,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
db_path=DB_PATH, db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting), get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables, init_tables_fn=_init_tables,
get_capital_fn=_capital,
notify_fn=send_wechat_msg,
interval=1,
) )
start_ctp_fee_worker( start_ctp_fee_worker(
get_mode_fn=lambda: get_trading_mode(get_setting), get_mode_fn=lambda: get_trading_mode(get_setting),
+191 -16
View File
@@ -4,10 +4,15 @@ from __future__ import annotations
import logging import logging
import threading import threading
import time import time
from datetime import datetime
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from contract_specs import get_contract_spec from contract_specs import calc_position_metrics
from ctp_symbol import ths_to_vnpy_symbol from ctp_symbol import ths_to_vnpy_symbol
from fee_specs import calc_round_trip_fee
from market_sessions import is_trading_session
from symbols import ths_to_codes
from vnpy_bridge import ( from vnpy_bridge import (
ctp_cancel_order, ctp_cancel_order,
ctp_get_tick_price, ctp_get_tick_price,
@@ -19,10 +24,15 @@ from vnpy_bridge import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 5 TZ = ZoneInfo("Asia/Shanghai")
PLACE_COOLDOWN_SEC = 30 CHECK_INTERVAL_SEC = 1
CLOSED_MARKET_SLEEP_SEC = 30
DISCONNECTED_SLEEP_SEC = 5
PLACE_COOLDOWN_SEC = 3
_last_close_attempt: dict[int, float] = {} _last_close_attempt: dict[int, float] = {}
_closing_monitors: set[int] = set()
_closing_lock = threading.Lock()
MONITOR_ORDER_COLUMNS = ( MONITOR_ORDER_COLUMNS = (
"ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT", "ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT",
@@ -39,6 +49,7 @@ def ensure_monitor_order_columns(conn) -> None:
def _tick_size(ths_code: str) -> float: def _tick_size(ths_code: str) -> float:
from contract_specs import get_contract_spec
return float(get_contract_spec(ths_code).get("tick_size") or 1.0) return float(get_contract_spec(ths_code).get("tick_size") or 1.0)
@@ -106,6 +117,105 @@ def _mark_close_attempt(monitor_id: int) -> None:
_last_close_attempt[monitor_id] = time.time() _last_close_attempt[monitor_id] = time.time()
def _try_acquire_close(monitor_id: int) -> bool:
with _closing_lock:
if monitor_id in _closing_monitors:
return False
_closing_monitors.add(monitor_id)
return True
def _release_close(monitor_id: int) -> None:
with _closing_lock:
_closing_monitors.discard(monitor_id)
def _monitor_type_label(raw: str) -> str:
mapping = {
"manual": "期货下单",
"trend": "趋势回调",
"roll": "顺势加仓",
}
return mapping.get(raw or "", raw or "程序监控")
def _write_trade_log(
conn,
mon: dict,
*,
close_price: float,
reason: str,
trading_mode: str,
capital: float = 0.0,
) -> None:
"""止盈/止损触发平仓后写入 trade_logs。"""
sym = (mon.get("symbol") or "").strip()
direction = (mon.get("direction") or "long").strip().lower()
entry = float(mon.get("entry_price") or close_price)
sl_raw = mon.get("stop_loss")
tp_raw = mon.get("take_profit")
sl = float(sl_raw) if sl_raw is not None else entry
tp = float(tp_raw) if tp_raw is not None else entry
lots = float(mon.get("lots") or 1)
open_time = (mon.get("open_time") or "").strip()
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
codes = ths_to_codes(sym) or {}
sina_code = codes.get("sina_code") or ""
symbol_name = mon.get("symbol_name") or sym
market_code = mon.get("market_code") or codes.get("market_code") or ""
metrics = calc_position_metrics(
direction, entry, sl, tp, lots, close_price, capital, sym,
)
pnl = metrics.get("float_pnl") or 0.0
fee = calc_round_trip_fee(
sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode,
)
pnl_net = round(pnl - fee, 2)
result = "止盈" if reason == "take_profit" else "止损"
try:
from app import holding_to_minutes
minutes = holding_to_minutes(open_time, close_time)
except Exception:
minutes = 0
conn.execute(
"""INSERT INTO trade_logs
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
entry_price, stop_loss, take_profit, close_price, lots, margin,
holding_minutes, open_time, close_time, pnl, fee, pnl_net, result)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym,
symbol_name,
market_code,
sina_code,
_monitor_type_label(mon.get("monitor_type") or ""),
direction,
entry,
sl_raw if sl_raw is not None else sl,
tp_raw if tp_raw is not None else tp,
close_price,
lots,
metrics.get("margin"),
minutes,
open_time,
close_time,
pnl,
fee,
pnl_net,
result,
),
)
try:
from stats_engine import refresh_stats_cache
refresh_stats_cache(conn, capital)
except Exception as exc:
logger.debug("stats refresh after SL/TP close: %s", exc)
def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool: def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool:
buf = max(tick * 0.01, 1e-9) buf = max(tick * 0.01, 1e-9)
if direction == "long": if direction == "long":
@@ -216,6 +326,8 @@ def _execute_local_close(
mode: str, mode: str,
mark: float, mark: float,
reason: str, reason: str,
capital: float = 0.0,
notify_fn: Callable[[str], None] | None = None,
) -> None: ) -> None:
sym = (mon.get("symbol") or "").strip() sym = (mon.get("symbol") or "").strip()
direction = (mon.get("direction") or "long").strip().lower() direction = (mon.get("direction") or "long").strip().lower()
@@ -237,19 +349,41 @@ def _execute_local_close(
price=mark, price=mark,
order_type="market", order_type="market",
) )
_write_trade_log(
conn,
mon,
close_price=mark,
reason=reason,
trading_mode=mode,
capital=capital,
)
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
conn.commit() conn.commit()
label = "止盈" if reason == "take_profit" else "止损"
logger.info( logger.info(
"止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s", "止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s",
mon.get("id"), reason, sym, direction, lots, mark, mon.get("id"), reason, sym, direction, lots, mark,
) )
if notify_fn:
try:
notify_fn(f"{label}平仓 {sym} {direction} {lots}手 @{mark},已记入交易记录")
except Exception as exc:
logger.debug("SL/TP notify failed: %s", exc)
def check_monitors_locally(conn, mode: str) -> int: def check_monitors_locally(
"""扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻平仓。""" conn,
mode: str,
*,
capital: float = 0.0,
notify_fn: Callable[[str], None] | None = None,
) -> int:
"""扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。"""
ensure_monitor_order_columns(conn) ensure_monitor_order_columns(conn)
if not ctp_status(mode).get("connected"): if not ctp_status(mode).get("connected"):
return 0 return 0
if not is_trading_session():
return 0
reconcile_monitors_without_position(conn, mode) reconcile_monitors_without_position(conn, mode)
closed = 0 closed = 0
rows = conn.execute( rows = conn.execute(
@@ -293,12 +427,26 @@ def check_monitors_locally(conn, mode: str) -> int:
continue continue
if mid > 0 and not _can_close_now(mid): if mid > 0 and not _can_close_now(mid):
continue continue
if mid > 0 and not _try_acquire_close(mid):
continue
try: try:
_mark_close_attempt(mid) _execute_local_close(
_execute_local_close(conn, mon, mode=mode, mark=mark, reason=reason) conn,
mon,
mode=mode,
mark=mark,
reason=reason,
capital=capital,
notify_fn=notify_fn,
)
if mid > 0:
_mark_close_attempt(mid)
closed += 1 closed += 1
except Exception as exc: except Exception as exc:
logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc) logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc)
finally:
if mid > 0:
_release_close(mid)
return closed return closed
@@ -358,6 +506,8 @@ def start_sl_tp_guard_worker(
db_path: str, db_path: str,
get_mode_fn: Callable[[], str], get_mode_fn: Callable[[], str],
init_tables_fn: Callable | None = None, init_tables_fn: Callable | None = None,
get_capital_fn: Callable | None = None,
notify_fn: Callable[[str], None] | None = None,
interval: int = CHECK_INTERVAL_SEC, interval: int = CHECK_INTERVAL_SEC,
) -> None: ) -> None:
from db_conn import connect_db from db_conn import connect_db
@@ -365,20 +515,45 @@ def start_sl_tp_guard_worker(
def _loop() -> None: def _loop() -> None:
time.sleep(8) time.sleep(8)
while True: while True:
sleep_sec = max(1, interval)
try: try:
if not is_trading_session():
time.sleep(CLOSED_MARKET_SLEEP_SEC)
continue
mode = get_mode_fn() mode = get_mode_fn()
if ctp_status(mode).get("connected"): if not ctp_status(mode).get("connected"):
conn = connect_db(db_path) time.sleep(DISCONNECTED_SLEEP_SEC)
try: continue
if init_tables_fn: conn = connect_db(db_path)
init_tables_fn(conn) try:
n = check_monitors_locally(conn, mode) if init_tables_fn:
init_tables_fn(conn)
has_monitors = conn.execute(
"""SELECT COUNT(*) AS n FROM trade_order_monitors
WHERE status='active'
AND (stop_loss IS NOT NULL OR take_profit IS NOT NULL)"""
).fetchone()["n"]
if not has_monitors:
sleep_sec = max(sleep_sec, 5)
else:
capital = 0.0
if get_capital_fn:
try:
capital = float(get_capital_fn(conn) or 0)
except Exception:
capital = 0.0
n = check_monitors_locally(
conn,
mode,
capital=capital,
notify_fn=notify_fn,
)
if n: if n:
logger.info("止盈止损本地监控: 触发平仓 %d", n) logger.info("止盈止损本地监控: 触发平仓 %d", n)
finally: finally:
conn.close() conn.close()
except Exception as exc: except Exception as exc:
logger.warning("sl_tp_guard worker: %s", exc) logger.warning("sl_tp_guard worker: %s", exc)
time.sleep(max(3, interval)) time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start() threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start()
+1
View File
@@ -25,6 +25,7 @@
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;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%;box-sizing:border-box} .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} .trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0}
.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:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto} .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;width:auto}
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} .price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
+100 -4
View File
@@ -19,6 +19,13 @@
var priceType = 'limit'; var priceType = 'limit';
var lastCtpReconnectAt = 0; var lastCtpReconnectAt = 0;
var ctpReconnecting = false; var ctpReconnecting = false;
var isTradingSession = false;
var hasSlTpMonitoring = false;
var ctpConnected = false;
var pollIntervalMs = 0;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
function runWhenReady(fn) { function runWhenReady(fn) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -52,6 +59,63 @@
return parseInt(lotsInput && lotsInput.value, 10) || 1; return parseInt(lotsInput && lotsInput.value, 10) || 1;
} }
function updateRecommendMaxMaps(data) {
recommendMaxByProduct = {};
recommendMaxByCode = {};
(data && data.rows || []).forEach(function (r) {
if (!r || r.max_lots <= 0) return;
if (r.status !== 'ok' && r.status !== 'margin_ok') return;
if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots;
if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots;
});
checkLotsLimit();
}
function maxLotsForSymbol(sym) {
if (selectedMaxLots > 0) return selectedMaxLots;
var code = (sym || '').trim().toLowerCase();
if (!code) return 0;
if (recommendMaxByCode[code]) return recommendMaxByCode[code];
var m = code.match(/^([a-z]+)/i);
if (m && recommendMaxByProduct[m[1].toLowerCase()]) {
return recommendMaxByProduct[m[1].toLowerCase()];
}
return 0;
}
function checkLotsLimit() {
var warn = document.getElementById('lots-warn');
if (!warn) return;
var sym = selectedSymbol();
var maxLots = maxLotsForSymbol(sym);
var lots = effectiveLots();
if (maxLots > 0 && lots > maxLots) {
warn.hidden = false;
warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数';
} else {
warn.hidden = true;
warn.textContent = '';
}
}
function schedulePositionPoll() {
var nextMs = 0;
if (hasSlTpMonitoring && isTradingSession) {
nextMs = 1000;
} else if (!ctpConnected) {
nextMs = 5000;
}
if (nextMs === pollIntervalMs && pollTimer) return;
pollIntervalMs = nextMs;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (nextMs > 0) {
pollTimer = setInterval(pollPositions, nextMs);
}
}
function entryPrice() { function entryPrice() {
if (priceType === 'market') return lastQuotePrice; if (priceType === 'market') return lastQuotePrice;
return parseFloat(priceInput && priceInput.value) || 0; return parseFloat(priceInput && priceInput.value) || 0;
@@ -201,6 +265,7 @@
if (!sym || !entry || !sl) { if (!sym || !entry || !sl) {
lotsCalc.value = ''; lotsCalc.value = '';
lotsCalc.placeholder = '填写止损后自动计算'; lotsCalc.placeholder = '填写止损后自动计算';
checkLotsLimit();
return; return;
} }
lotsCalc.placeholder = '计算中…'; lotsCalc.placeholder = '计算中…';
@@ -223,6 +288,7 @@
} }
lotsCalc.value = data.lots; lotsCalc.value = data.lots;
lotsCalc.placeholder = '填写止损后自动计算'; lotsCalc.placeholder = '填写止损后自动计算';
checkLotsLimit();
scheduleQuote(); scheduleQuote();
}).catch(function () { }).catch(function () {
lotsCalc.placeholder = '计算失败'; lotsCalc.placeholder = '计算失败';
@@ -273,6 +339,11 @@
showOrderMsg('请填写手数', false); showOrderMsg('请填写手数', false);
return; return;
} }
var maxLots = maxLotsForSymbol(sym);
if (maxLots > 0 && lots > maxLots) {
showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false);
return;
}
} }
var btnOpen = document.getElementById('btn-open'); var btnOpen = document.getElementById('btn-open');
if (btnOpen) { if (btnOpen) {
@@ -562,6 +633,8 @@
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 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;
ctpConnected = !!connected;
isTradingSession = !!data.trading_session;
updateCtpBadge(!!connected, !!connecting); updateCtpBadge(!!connected, !!connecting);
var riskBadge = document.getElementById('risk-badge'); var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) { if (riskBadge && data.risk_status) {
@@ -569,6 +642,10 @@
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
} }
var rows = data.rows || []; var rows = data.rows || [];
hasSlTpMonitoring = rows.some(function (row) {
return row.stop_loss != null || row.take_profit != null;
});
schedulePositionPoll();
if (!connected) { if (!connected) {
if (connecting) { if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>'; list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
@@ -618,6 +695,7 @@
function renderRecommendations(data) { function renderRecommendations(data) {
if (!recommendList || !data) return; if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
var recCap = document.getElementById('rec-capital'); var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var recUpdated = document.getElementById('rec-updated'); var recUpdated = document.getElementById('rec-updated');
@@ -666,13 +744,25 @@
}); });
if (symInput) { if (symInput) {
symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); }); symInput.addEventListener('input', function () {
symInput.addEventListener('symbol-selected', function () { selectedMaxLots = null;
scheduleQuote(); scheduleQuote();
scheduleAutoCalc(); scheduleAutoCalc();
checkLotsLimit();
});
symInput.addEventListener('symbol-selected', function (ev) {
var item = ev.detail || {};
selectedMaxLots = item.max_lots > 0 ? item.max_lots : null;
scheduleQuote();
scheduleAutoCalc();
checkLotsLimit();
}); });
} }
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote); if (lotsInput) lotsInput.addEventListener('input', function () {
scheduleQuote();
checkLotsLimit();
});
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
if (slInput) slInput.addEventListener('input', scheduleAutoCalc); if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc); if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc); if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
@@ -697,7 +787,13 @@
setPriceType('limit'); setPriceType('limit');
pollPositions(); pollPositions();
connectRecommendStream(); connectRecommendStream();
pollTimer = setInterval(pollPositions, 3000); fetch('/api/recommend/list')
.then(function (r) { return r.json(); })
.then(function (data) { if (data.ok) renderRecommendations(data); })
.catch(function () {});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') pollPositions();
});
scheduleQuote(); scheduleQuote();
}); });
})(); })();
+2 -1
View File
@@ -55,6 +55,7 @@
<label class="text-label">手数</label> <label class="text-label">手数</label>
<input type="number" id="trade-lots" min="1" step="1" value="1" {% if sizing_mode == 'risk' %}hidden{% endif %}> <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 %}> <input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
</div> </div>
</div> </div>
@@ -97,7 +98,7 @@
<div class="card trade-card" id="positions"> <div class="card trade-card" id="positions">
<h2>持仓监控</h2> <h2>持仓监控</h2>
<p class="hint pos-hint">数据来自 CTP 柜台(交易所回报),浮盈等为柜台实际值</p> <p class="hint pos-hint">数据来自 CTP 柜台;设止盈/止损后程序在开盘期间每秒监控,触发即市价平仓并记入交易记录</p>
<div class="card-body card-scroll" id="position-live-list"> <div class="card-body card-scroll" id="position-live-list">
<div class="empty-hint">{% if ctp_status.connected %}加载中…{% else %}请先连接 CTP 查看柜台持仓{% endif %}</div> <div class="empty-hint">{% if ctp_status.connected %}加载中…{% else %}请先连接 CTP 查看柜台持仓{% endif %}</div>
</div> </div>