diff --git a/install_trading.py b/install_trading.py index 85394c4..1804eba 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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 fee_specs import calc_fee_breakdown from kline_stream import sse_format +from market_sessions import is_trading_session from position_sizing import ( MODE_FIXED, MODE_RISK, @@ -479,6 +480,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "ctp_status": ctp_st, "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, + "trading_session": is_trading_session(), }) finally: conn.close() @@ -1411,6 +1413,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se db_path=DB_PATH, get_mode_fn=lambda: get_trading_mode(get_setting), init_tables_fn=_init_tables, + get_capital_fn=_capital, + notify_fn=send_wechat_msg, + interval=1, ) start_ctp_fee_worker( get_mode_fn=lambda: get_trading_mode(get_setting), diff --git a/sl_tp_guard.py b/sl_tp_guard.py index bae5a9e..ef78c95 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -4,10 +4,15 @@ from __future__ import annotations import logging import threading import time +from datetime import datetime 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 fee_specs import calc_round_trip_fee +from market_sessions import is_trading_session +from symbols import ths_to_codes from vnpy_bridge import ( ctp_cancel_order, ctp_get_tick_price, @@ -19,10 +24,15 @@ from vnpy_bridge import ( logger = logging.getLogger(__name__) -CHECK_INTERVAL_SEC = 5 -PLACE_COOLDOWN_SEC = 30 +TZ = ZoneInfo("Asia/Shanghai") +CHECK_INTERVAL_SEC = 1 +CLOSED_MARKET_SLEEP_SEC = 30 +DISCONNECTED_SLEEP_SEC = 5 +PLACE_COOLDOWN_SEC = 3 _last_close_attempt: dict[int, float] = {} +_closing_monitors: set[int] = set() +_closing_lock = threading.Lock() MONITOR_ORDER_COLUMNS = ( "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: + from contract_specs import get_contract_spec 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() +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: buf = max(tick * 0.01, 1e-9) if direction == "long": @@ -216,6 +326,8 @@ def _execute_local_close( mode: str, mark: float, reason: str, + capital: float = 0.0, + notify_fn: Callable[[str], None] | None = None, ) -> None: sym = (mon.get("symbol") or "").strip() direction = (mon.get("direction") or "long").strip().lower() @@ -237,19 +349,41 @@ def _execute_local_close( price=mark, 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.commit() + label = "止盈" if reason == "take_profit" else "止损" logger.info( "止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s", 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: - """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻平仓。""" +def check_monitors_locally( + conn, + mode: str, + *, + capital: float = 0.0, + notify_fn: Callable[[str], None] | None = None, +) -> int: + """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。""" ensure_monitor_order_columns(conn) if not ctp_status(mode).get("connected"): return 0 + if not is_trading_session(): + return 0 reconcile_monitors_without_position(conn, mode) closed = 0 rows = conn.execute( @@ -293,12 +427,26 @@ def check_monitors_locally(conn, mode: str) -> int: continue if mid > 0 and not _can_close_now(mid): continue + if mid > 0 and not _try_acquire_close(mid): + continue try: - _mark_close_attempt(mid) - _execute_local_close(conn, mon, mode=mode, mark=mark, reason=reason) + _execute_local_close( + conn, + mon, + mode=mode, + mark=mark, + reason=reason, + capital=capital, + notify_fn=notify_fn, + ) + if mid > 0: + _mark_close_attempt(mid) closed += 1 except Exception as exc: logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc) + finally: + if mid > 0: + _release_close(mid) return closed @@ -358,6 +506,8 @@ def start_sl_tp_guard_worker( db_path: str, get_mode_fn: Callable[[], str], init_tables_fn: Callable | None = None, + get_capital_fn: Callable | None = None, + notify_fn: Callable[[str], None] | None = None, interval: int = CHECK_INTERVAL_SEC, ) -> None: from db_conn import connect_db @@ -365,20 +515,45 @@ def start_sl_tp_guard_worker( def _loop() -> None: time.sleep(8) while True: + sleep_sec = max(1, interval) try: + if not is_trading_session(): + time.sleep(CLOSED_MARKET_SLEEP_SEC) + continue mode = get_mode_fn() - if ctp_status(mode).get("connected"): - conn = connect_db(db_path) - try: - if init_tables_fn: - init_tables_fn(conn) - n = check_monitors_locally(conn, mode) + if not ctp_status(mode).get("connected"): + time.sleep(DISCONNECTED_SLEEP_SEC) + continue + conn = connect_db(db_path) + try: + 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: logger.info("止盈止损本地监控: 触发平仓 %d 笔", n) - finally: - conn.close() + finally: + conn.close() except Exception as 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() diff --git a/static/css/trade.css b/static/css/trade.css index c9cb196..5ebf781 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -25,6 +25,7 @@ .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} +.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0} .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.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} diff --git a/static/js/trade.js b/static/js/trade.js index 7ebcca4..53a9d7d 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -19,6 +19,13 @@ var priceType = 'limit'; var lastCtpReconnectAt = 0; 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) { if (document.readyState === 'loading') { @@ -52,6 +59,63 @@ 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() { if (priceType === 'market') return lastQuotePrice; return parseFloat(priceInput && priceInput.value) || 0; @@ -201,6 +265,7 @@ if (!sym || !entry || !sl) { lotsCalc.value = ''; lotsCalc.placeholder = '填写止损后自动计算'; + checkLotsLimit(); return; } lotsCalc.placeholder = '计算中…'; @@ -223,6 +288,7 @@ } lotsCalc.value = data.lots; lotsCalc.placeholder = '填写止损后自动计算'; + checkLotsLimit(); scheduleQuote(); }).catch(function () { lotsCalc.placeholder = '计算失败'; @@ -273,6 +339,11 @@ showOrderMsg('请填写手数', false); return; } + var maxLots = maxLotsForSymbol(sym); + if (maxLots > 0 && lots > maxLots) { + showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false); + return; + } } var btnOpen = document.getElementById('btn-open'); if (btnOpen) { @@ -562,6 +633,8 @@ if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); var connected = data.ctp_status && data.ctp_status.connected; var connecting = data.ctp_status && data.ctp_status.connecting; + ctpConnected = !!connected; + isTradingSession = !!data.trading_session; updateCtpBadge(!!connected, !!connecting); var riskBadge = document.getElementById('risk-badge'); if (riskBadge && data.risk_status) { @@ -569,6 +642,10 @@ riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); } var rows = data.rows || []; + hasSlTpMonitoring = rows.some(function (row) { + return row.stop_loss != null || row.take_profit != null; + }); + schedulePositionPoll(); if (!connected) { if (connecting) { list.innerHTML = '
数据来自 CTP 柜台(交易所回报),浮盈等为柜台实际值。
+数据来自 CTP 柜台;设止盈/止损后程序在开盘期间每秒监控,触发即市价平仓并记入交易记录。