diff --git a/app.py b/app.py index c3341c9..bad79d1 100644 --- a/app.py +++ b/app.py @@ -377,6 +377,8 @@ def init_db(): set_setting("risk_percent", "1") if not get_setting("max_margin_pct"): set_setting("max_margin_pct", "30") + if not get_setting("trailing_be_tick_buffer"): + set_setting("trailing_be_tick_buffer", "2") if not get_setting("fee_source_mode"): set_setting("fee_source_mode", "ctp") set_setting("fee_source_mode", "ctp") @@ -1654,6 +1656,12 @@ def settings(): except ValueError: flash("保证金比例无效") return redirect(url_for("settings")) + try: + tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2)) + set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb)))) + except ValueError: + flash("移动保本缓冲无效") + return redirect(url_for("settings")) flash("交易模式已保存") elif action == "nav": items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} @@ -1694,6 +1702,7 @@ def settings(): position_sizing_mode=get_setting("position_sizing_mode", "risk"), risk_percent=get_setting("risk_percent", "1"), max_margin_pct=get_setting("max_margin_pct", "30"), + trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), nav_items=get_nav_items(get_setting), nav_toggles=NAV_TOGGLES, ) diff --git a/install_trading.py b/install_trading.py index 1804eba..830a009 100644 --- a/install_trading.py +++ b/install_trading.py @@ -38,6 +38,7 @@ from sl_tp_guard import ( place_monitor_exit_orders, reconcile_monitors_without_position, start_sl_tp_guard_worker, + write_manual_close_trade_log, ) from risk.account_risk_lib import ( assert_can_open, @@ -61,6 +62,7 @@ from trading_context import ( get_max_margin_pct, get_risk_percent, get_sizing_mode, + get_trailing_be_tick_buffer, get_trading_mode, trading_mode_label, ) @@ -375,6 +377,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "tick_size": tick.get("tick_size"), "can_close": True, "pending_orders": pending_for_row, + "trailing_be": bool(mon.get("trailing_be")) if mon else False, + "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, }) return rows @@ -535,19 +539,35 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se break codes = ths_to_codes(sym) now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if "trailing_be" in d: + trailing_be = 1 if d.get("trailing_be") else 0 + elif mon: + trailing_be = int(mon.get("trailing_be") or 0) + else: + trailing_be = 0 + ensure_monitor_order_columns(conn) if mon: + initial_sl = mon.get("initial_stop_loss") + if sl is not None and initial_sl is None: + initial_sl = sl conn.execute( - """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=? + """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?, + initial_stop_loss=?, trailing_be=? WHERE id=?""", - (sl, tp, lots, entry or mon.get("entry_price"), mon["id"]), + ( + sl, tp, lots, entry or mon.get("entry_price"), + initial_sl, trailing_be, + mon["id"], + ), ) mid = mon["id"] else: conn.execute( """INSERT INTO trade_order_monitors ( symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + stop_loss, take_profit, initial_stop_loss, trailing_be, + open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""", ( sym, codes.get("name", sym) if codes else sym, @@ -557,6 +577,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se entry, sl, tp, + sl, + trailing_be, now_s, "manual", ), @@ -667,21 +689,67 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.close() return jsonify({"ok": False, "error": "品种或价格无效"}), 400 offset = "close_long" if direction == "long" else "close_short" + capital = _capital(conn) + mon = None + mid = int(d.get("monitor_id") or 0) + if mid: + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", + (mid,), + ).fetchone() + if row: + mon = dict(row) + if not mon: + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active'" + ).fetchall(): + row = dict(r) + if row.get("direction") != direction: + continue + if _match_ctp_symbol(sym, row.get("symbol") or ""): + mon = row + mid = int(row["id"]) + break + entry = float(mon.get("entry_price") or 0) if mon else 0.0 + if entry <= 0: + for p in _ctp_positions(mode): + if int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if _match_ctp_symbol(p.get("symbol") or "", sym): + entry = float(p.get("avg_price") or price) + break try: execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), + order_type="market", ) - if source == "program": - mid = int(d.get("monitor_id") or 0) - if mid: - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (mid,), - ) + write_manual_close_trade_log( + conn, + mon, + symbol=sym, + direction=direction, + lots=lots, + close_price=price, + entry_price=entry or price, + trading_mode=mode, + capital=capital, + stop_loss=float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None, + take_profit=float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None, + open_time=(mon.get("open_time") or "") if mon else "", + symbol_name=(mon.get("symbol_name") or "") if mon else "", + market_code=(mon.get("market_code") or "") if mon else "", + ) + if mid: + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) conn.commit() conn.close() - return jsonify({"ok": True}) + return jsonify({"ok": True, "message": "已平仓并记入交易记录(手动平仓)"}) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 @@ -830,6 +898,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se mode = get_trading_mode(get_setting) if offset.startswith("open"): _sync_trade_monitors_with_ctp(conn, mode) + if not is_trading_session(): + conn.close() + return jsonify({"ok": False, "error": "不在交易时间段"}), 403 + if d.get("trailing_be") and not d.get("stop_loss"): + conn.close() + return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode)) if err: conn.close() @@ -886,9 +960,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se settings=_settings_dict(), order_type=order_type, ) + if offset.startswith("open") and d.get("trailing_be") and not d.get("stop_loss"): + conn.close() + return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 if offset.startswith("open"): sl = d.get("stop_loss") tp = d.get("take_profit") + trailing_be = 1 if d.get("trailing_be") else 0 import time time.sleep(2.0) actual_lots = lots @@ -904,11 +982,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se break if has_pos: codes = ths_to_codes(sym) + sl_f = float(sl) if sl else None + ensure_monitor_order_columns(conn) conn.execute( """INSERT INTO trade_order_monitors ( symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + stop_loss, take_profit, initial_stop_loss, trailing_be, + open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""", ( sym, codes.get("name", sym) if codes else sym, @@ -916,8 +997,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se direction, actual_lots, price, - float(sl) if sl else None, + sl_f, float(tp) if tp else None, + sl_f, + trailing_be, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "manual", ), @@ -1414,6 +1497,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se get_mode_fn=lambda: get_trading_mode(get_setting), init_tables_fn=_init_tables, get_capital_fn=_capital, + get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), notify_fn=send_wechat_msg, interval=1, ) diff --git a/sl_tp_guard.py b/sl_tp_guard.py index ef78c95..083718e 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -37,8 +37,13 @@ _closing_lock = threading.Lock() MONITOR_ORDER_COLUMNS = ( "ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT", "ALTER TABLE trade_order_monitors ADD COLUMN tp_vt_order_id TEXT", + "ALTER TABLE trade_order_monitors ADD COLUMN trailing_be INTEGER DEFAULT 0", + "ALTER TABLE trade_order_monitors ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN trailing_r_locked INTEGER DEFAULT 0", ) +TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓") + def ensure_monitor_order_columns(conn) -> None: for sql in MONITOR_ORDER_COLUMNS: @@ -139,31 +144,55 @@ def _monitor_type_label(raw: str) -> str: return mapping.get(raw or "", raw or "程序监控") -def _write_trade_log( +def _result_for_close(mon: dict, reason: str) -> str: + """平仓结果:止损 / 止盈 / 移动止盈 / 保本止盈 / 手动平仓。""" + if reason == "manual": + return "手动平仓" + if reason == "take_profit": + return "止盈" + if not mon.get("trailing_be"): + return "止损" + locked = int(mon.get("trailing_r_locked") or 0) + if locked >= 2: + return "移动止盈" + if locked >= 1: + return "保本止盈" + return "止损" + + +def write_trade_log( conn, - mon: dict, *, + symbol: str, + direction: str, + entry_price: float, close_price: float, - reason: str, + lots: float, + result: str, trading_mode: str, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + open_time: str = "", + symbol_name: str = "", + market_code: str = "", + sina_code: str = "", + monitor_type: 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() + """写入 trade_logs(程序平仓 / 手动平仓)。""" + sym = (symbol or "").strip() + direction = (direction or "long").strip().lower() + entry = float(entry_price or close_price) + sl = float(stop_loss) if stop_loss is not None else entry + tp = float(take_profit) if take_profit is not None else entry 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 "" + if not sina_code or not market_code: + codes = ths_to_codes(sym) or {} + sina_code = sina_code or codes.get("sina_code") or "" + market_code = market_code or codes.get("market_code") or "" + if not symbol_name: + symbol_name = sym metrics = calc_position_metrics( direction, entry, sl, tp, lots, close_price, capital, sym, @@ -173,7 +202,6 @@ def _write_trade_log( 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 @@ -192,11 +220,11 @@ def _write_trade_log( symbol_name, market_code, sina_code, - _monitor_type_label(mon.get("monitor_type") or ""), + monitor_type, direction, entry, - sl_raw if sl_raw is not None else sl, - tp_raw if tp_raw is not None else tp, + stop_loss if stop_loss is not None else sl, + take_profit if take_profit is not None else tp, close_price, lots, metrics.get("margin"), @@ -206,14 +234,169 @@ def _write_trade_log( pnl, fee, pnl_net, - result, + result if result in TRADE_RESULTS else "手动平仓", ), ) 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) + logger.debug("stats refresh after close: %s", exc) + + +def _write_trade_log( + conn, + mon: dict, + *, + close_price: float, + reason: str, + trading_mode: str, + capital: float = 0.0, +) -> None: + sym = (mon.get("symbol") or "").strip() + sl_raw = mon.get("stop_loss") + tp_raw = mon.get("take_profit") + initial_sl = mon.get("initial_stop_loss") + write_trade_log( + conn, + symbol=sym, + direction=mon.get("direction") or "long", + entry_price=float(mon.get("entry_price") or close_price), + close_price=close_price, + lots=float(mon.get("lots") or 1), + result=_result_for_close(mon, reason), + trading_mode=trading_mode, + stop_loss=float(initial_sl) if initial_sl is not None else ( + float(sl_raw) if sl_raw is not None else None + ), + take_profit=float(tp_raw) if tp_raw is not None else None, + open_time=(mon.get("open_time") or "").strip(), + symbol_name=mon.get("symbol_name") or sym, + market_code=mon.get("market_code") or "", + monitor_type=_monitor_type_label(mon.get("monitor_type") or ""), + capital=capital, + ) + + +def write_manual_close_trade_log( + conn, + mon: Optional[dict], + *, + symbol: str, + direction: str, + lots: float, + close_price: float, + entry_price: float, + trading_mode: str, + capital: float = 0.0, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + open_time: str = "", + symbol_name: str = "", + market_code: str = "", +) -> None: + """程序内点击平仓按钮 → 手动平仓。""" + if mon: + write_trade_log( + conn, + symbol=(mon.get("symbol") or symbol).strip(), + direction=mon.get("direction") or direction, + entry_price=float(mon.get("entry_price") or entry_price), + close_price=close_price, + lots=float(mon.get("lots") or lots), + result="手动平仓", + trading_mode=trading_mode, + stop_loss=float(mon["initial_stop_loss"]) if mon.get("initial_stop_loss") is not None else ( + float(mon["stop_loss"]) if mon.get("stop_loss") is not None else stop_loss + ), + take_profit=float(mon["take_profit"]) if mon.get("take_profit") is not None else take_profit, + open_time=(mon.get("open_time") or open_time).strip(), + symbol_name=mon.get("symbol_name") or symbol_name, + market_code=mon.get("market_code") or market_code, + monitor_type=_monitor_type_label(mon.get("monitor_type") or ""), + capital=capital, + ) + return + write_trade_log( + conn, + symbol=symbol, + direction=direction, + entry_price=entry_price, + close_price=close_price, + lots=lots, + result="手动平仓", + trading_mode=trading_mode, + stop_loss=stop_loss, + take_profit=take_profit, + open_time=open_time, + symbol_name=symbol_name, + market_code=market_code, + capital=capital, + ) + + +def _update_trailing_stop_loss( + conn, + mon: dict, + mark: float, + *, + be_tick_mult: int, +) -> dict: + """达 1R 移保本(开仓±N跳),达 2R 移 1R,依次类推。""" + if not mon.get("trailing_be"): + return mon + entry = float(mon.get("entry_price") or 0) + initial_sl = mon.get("initial_stop_loss") + if initial_sl is None: + initial_sl = mon.get("stop_loss") + try: + initial_sl_f = float(initial_sl) if initial_sl is not None else None + except (TypeError, ValueError): + return mon + if not entry or initial_sl_f is None: + return mon + + direction = (mon.get("direction") or "long").strip().lower() + sym = (mon.get("symbol") or "").strip() + tick = _tick_size(sym) + r = abs(entry - initial_sl_f) + if r < tick * 0.5: + return mon + + profit_r = (mark - entry) / r if direction == "long" else (entry - mark) / r + if profit_r < 1.0: + return mon + + level = int(profit_r) + locked = int(mon.get("trailing_r_locked") or 0) + if level <= locked: + return mon + + if level == 1: + new_sl = entry + be_tick_mult * tick if direction == "long" else entry - be_tick_mult * tick + else: + new_sl = entry + (level - 1) * r if direction == "long" else entry - (level - 1) * r + new_sl = round(new_sl, 4) + + try: + current_sl = float(mon.get("stop_loss") or 0) + except (TypeError, ValueError): + current_sl = 0.0 + if direction == "long" and new_sl <= current_sl + tick * 0.01: + return mon + if direction == "short" and new_sl >= current_sl - tick * 0.01: + return mon + + mid = mon.get("id") + conn.execute( + "UPDATE trade_order_monitors SET stop_loss=?, trailing_r_locked=? WHERE id=?", + (new_sl, level, mid), + ) + conn.commit() + mon["stop_loss"] = new_sl + mon["trailing_r_locked"] = level + logger.info("移动保本 monitor=%s %dR 止损→%s", mid, level, new_sl) + return mon def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool: @@ -359,14 +542,14 @@ def _execute_local_close( ) conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) conn.commit() - label = "止盈" if reason == "take_profit" else "止损" + result_label = _result_for_close(mon, reason) logger.info( - "止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s", - mon.get("id"), reason, sym, direction, lots, mark, + "止盈止损本地触发 monitor=%s result=%s %s %s %d手 @%s", + mon.get("id"), result_label, sym, direction, lots, mark, ) if notify_fn: try: - notify_fn(f"{label}平仓 {sym} {direction} {lots}手 @{mark},已记入交易记录") + notify_fn(f"{result_label} {sym} {direction} {lots}手 @{mark},已记入交易记录") except Exception as exc: logger.debug("SL/TP notify failed: %s", exc) @@ -377,6 +560,7 @@ def check_monitors_locally( *, capital: float = 0.0, notify_fn: Callable[[str], None] | None = None, + be_tick_mult: int = 2, ) -> int: """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。""" ensure_monitor_order_columns(conn) @@ -417,6 +601,13 @@ def check_monitors_locally( continue tick = _tick_size(sym) + if mon.get("trailing_be"): + mon = _update_trailing_stop_loss(conn, mon, mark, be_tick_mult=be_tick_mult) + try: + sl_f = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else sl_f + except (TypeError, ValueError): + pass + reason = None if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): reason = "take_profit" @@ -507,6 +698,7 @@ def start_sl_tp_guard_worker( get_mode_fn: Callable[[], str], init_tables_fn: Callable | None = None, get_capital_fn: Callable | None = None, + get_be_tick_buffer_fn: Callable[[], int] | None = None, notify_fn: Callable[[str], None] | None = None, interval: int = CHECK_INTERVAL_SEC, ) -> None: @@ -547,6 +739,9 @@ def start_sl_tp_guard_worker( mode, capital=capital, notify_fn=notify_fn, + be_tick_mult=( + get_be_tick_buffer_fn() if get_be_tick_buffer_fn else 2 + ), ) if n: logger.info("止盈止损本地监控: 触发平仓 %d 笔", n) diff --git a/static/css/trade.css b/static/css/trade.css index 5ebf781..40dd1b0 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -32,7 +32,11 @@ .market-hint{font-size:.7rem;margin-top:.25rem} .trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem} .trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%} -.trade-action-row .btn-open:disabled{opacity:.65;cursor:wait} +.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)} +.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} +.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} +.trailing-be-toggle input{width:auto;margin:0} +.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} .trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} .trade-order-msg.ok{color:var(--profit)} .trade-order-msg.err{color:var(--loss)} diff --git a/static/js/trade.js b/static/js/trade.js index 53a9d7d..b718126 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -116,6 +116,18 @@ } } + function updateSessionUi() { + var btnOpen = document.getElementById('btn-open'); + var sessionHint = document.getElementById('session-hint'); + if (btnOpen) { + btnOpen.disabled = !isTradingSession; + btnOpen.classList.toggle('btn-session-off', !isTradingSession); + } + if (sessionHint) { + sessionHint.hidden = !!isTradingSession; + } + } + function entryPrice() { if (priceType === 'market') return lastQuotePrice; return parseFloat(priceInput && priceInput.value) || 0; @@ -330,7 +342,17 @@ return; } var lots = effectiveLots(); + var trailingBeEl = document.getElementById('trailing-be'); if (offset === 'open') { + if (!isTradingSession) { + showOrderMsg('不在交易时间段', false); + return; + } + var trailingOn = !!(trailingBeEl && trailingBeEl.checked); + if (trailingOn && !(slInput && slInput.value)) { + showOrderMsg('开启移动保本须填写止损价', false); + return; + } if (isRiskMode() && lots <= 0) { showOrderMsg('请填写止损,系统将自动计算手数', false); return; @@ -359,7 +381,8 @@ price: price, order_type: priceType, stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, - take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null + take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null, + trailing_be: !!(trailingBeEl && trailingBeEl.checked) }; fetch('/api/trade/order', { method: 'POST', @@ -379,8 +402,8 @@ showOrderMsg('网络错误,请重试', false); }).finally(function () { if (btnOpen) { - btnOpen.disabled = false; btnOpen.textContent = '开仓'; + updateSessionUi(); } }); } @@ -471,7 +494,9 @@ '
' + + (row.tp_order_active ? ' · 止盈监控中' : '') + + (row.trailing_be ? ' · 移动保本' + + (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '' : '') + '' + '
- 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。在 .env 配置 SIMNOW_USER,于「持仓监控」连接 CTP;权益与行情优先来自柜台。
+ 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳(玉米 N=2 即 +2 点,棉花 N=2 即 +10 点);达 2R 移至 1R,依次类推。在 .env 配置 SIMNOW_USER,于「持仓监控」连接 CTP。
不在交易时间段