diff --git a/app.py b/app.py index 913f8ca..d531040 100644 --- a/app.py +++ b/app.py @@ -390,6 +390,8 @@ def init_db(): 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("pending_order_timeout_min"): + set_setting("pending_order_timeout_min", "5") if not get_setting("fee_source_mode"): set_setting("fee_source_mode", "ctp") set_setting("fee_source_mode", "ctp") @@ -1710,30 +1712,61 @@ def settings(): except ValueError: flash("移动保本缓冲无效") return redirect(url_for("settings")) + try: + pt = int(float(request.form.get("pending_order_timeout_min", "5") or 5)) + set_setting("pending_order_timeout_min", str(max(1, min(60, pt)))) + except ValueError: + flash("挂单超时无效") + return redirect(url_for("settings")) flash("交易模式已保存") elif action == "ctp": from ctp_settings import save_ctp_settings_from_form - save_ctp_settings_from_form(request.form, set_setting) + save_result = save_ctp_settings_from_form(request.form, set_setting) + pwd_updated = save_result.get("passwords_updated") or [] + pwd_empty = save_result.get("passwords_submitted_empty") or [] + simnow_pwd_len = len((request.form.get("simnow_password") or "").strip()) + live_pwd_len = len((request.form.get("ctp_live_password") or "").strip()) + print( + f"CTP settings save: simnow_password_len={simnow_pwd_len} " + f"live_password_len={live_pwd_len} updated={pwd_updated}", + flush=True, + ) + app.logger.info( + "CTP settings save: simnow_password_len=%s live_password_len=%s updated=%s", + simnow_pwd_len, + live_pwd_len, + pwd_updated, + ) + if "simnow_password" in pwd_updated: + pwd_note = f"SimNow 交易密码已更新({simnow_pwd_len} 位)" + elif "simnow_password" in pwd_empty: + pwd_note = "SimNow 交易密码未改:提交为空,请在「交易密码」框手打后再保存" + elif "ctp_live_password" in pwd_updated: + pwd_note = "实盘交易密码已更新" + elif "ctp_live_password" in pwd_empty: + pwd_note = "实盘交易密码未改(提交为空)" + else: + pwd_note = "" flash_msg = "CTP 配置已保存,正在使用新地址重连…" + if pwd_note: + flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…" try: from vnpy_bridge import get_bridge from trading_context import get_trading_mode b = get_bridge() - if (request.form.get("simnow_password") or "").strip() or ( - request.form.get("ctp_live_password") or "" - ).strip(): + if pwd_updated: b._clear_login_cooldown() mode = get_trading_mode(get_setting) info = b.reconnect_after_settings_saved(mode) if info.get("cooldown"): - flash_msg = "CTP 配置已保存;当前处于登录冷却,请稍后再连" + flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后再连'}" elif not info.get("started") and info.get("connected"): - flash_msg = "CTP 配置已保存,当前连接正常" + flash_msg = f"CTP 配置已保存;{pwd_note or '当前连接正常'}" except Exception as exc: app.logger.warning("CTP reconnect after settings save: %s", exc) - flash_msg = "CTP 配置已保存,请稍后在持仓监控页重连" + flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后在持仓监控页重连'}" flash(flash_msg) elif action == "nav": items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} @@ -1781,6 +1814,7 @@ def settings(): 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"), + pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), nav_items=get_nav_items(get_setting), nav_toggles=NAV_TOGGLES, ) diff --git a/ctp_settings.py b/ctp_settings.py index 3be510c..cf0ce8c 100644 --- a/ctp_settings.py +++ b/ctp_settings.py @@ -86,23 +86,39 @@ def get_ctp_settings_for_ui() -> dict[str, Any]: def save_ctp_settings_from_form( form: Any, set_setting: Callable[[str, str], None], -) -> None: - """保存 CTP 配置;密码留空表示不修改。""" +) -> dict[str, Any]: + """保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。""" + passwords_updated: list[str] = [] + passwords_submitted_empty: list[str] = [] + for db_key, _, _, default in SIMNOW_FIELDS: if db_key in PASSWORD_DB_KEYS: - val = (form.get(db_key) or "").strip() + raw = form.get(db_key) + val = (raw or "").strip() if val: set_setting(db_key, val) + passwords_updated.append(db_key) + else: + passwords_submitted_empty.append(db_key) continue val = (form.get(db_key) or "").strip() set_setting(db_key, val or default) for db_key, _, _, default in LIVE_FIELDS: if db_key in PASSWORD_DB_KEYS: - val = (form.get(db_key) or "").strip() + raw = form.get(db_key) + val = (raw or "").strip() if val: set_setting(db_key, val) + passwords_updated.append(db_key) + else: + passwords_submitted_empty.append(db_key) continue val = (form.get(db_key) or "").strip() if default or val: set_setting(db_key, val or default) + + return { + "passwords_updated": passwords_updated, + "passwords_submitted_empty": passwords_submitted_empty, + } diff --git a/install_trading.py b/install_trading.py index d0ea32a..5b661d1 100644 --- a/install_trading.py +++ b/install_trading.py @@ -32,6 +32,11 @@ from position_stream import position_hub, start_position_worker from ctp_reconnect import start_ctp_reconnect_worker from ctp_premarket_connect import start_ctp_premarket_connect_worker from ctp_fee_worker import start_ctp_fee_worker +from order_pending import ( + cancel_pending_monitor, + pending_auto_cancel_remaining, + reconcile_pending_orders, +) from db_conn import execute_retry from sl_tp_guard import ( cancel_monitor_exit_orders, @@ -65,6 +70,8 @@ from trading_context import ( get_fixed_amount, get_fixed_lots, get_max_margin_pct, + get_pending_order_timeout_min, + get_pending_order_timeout_sec, get_risk_percent, get_sizing_mode, get_trailing_be_tick_buffer, @@ -425,15 +432,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ctp_open_time: Optional[str] = None, open_time: Optional[str] = None, monitor_type: str = "manual", + status: str = "active", + vt_order_id: Optional[str] = None, + order_price: Optional[float] = None, ) -> int: ensure_monitor_order_columns(conn) codes = ths_to_codes(sym) or {} sl_f = float(sl) if sl not in (None, "") else None tp_f = float(tp) if tp not in (None, "") else None now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + status_val = status if status in ("pending", "active") else "active" + order_px = float(order_price if order_price is not None else price) existing = _find_active_monitor(conn, sym, direction) + if not existing: + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall(): + row = dict(r) + if (row.get("direction") or "long") != (direction or "long").strip().lower(): + continue + if _match_ctp_symbol(sym, row.get("symbol") or ""): + existing = row + break if existing: mid = int(existing["id"]) + existing_status = (existing.get("status") or "active").strip().lower() + if existing_status == "active" and status_val == "pending": + status_val = "active" initial_sl = existing.get("initial_stop_loss") if sl_f is None: sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None @@ -448,11 +473,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se open_time_val = open_time elif monitor_type == "ctp_sync" and ctp_open_time: open_time_val = ctp_open_time + vt_val = vt_order_id or existing.get("vt_order_id") conn.execute( """UPDATE trade_order_monitors SET symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?, stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?, - monitor_type=? + monitor_type=?, status=?, vt_order_id=?, order_price=? WHERE id=?""", ( sym, @@ -466,6 +492,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se trailing_be, open_time_val, monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"), + status_val, + vt_val, + order_px, mid, ), ) @@ -480,8 +509,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se """INSERT INTO trade_order_monitors ( symbol, symbol_name, market_code, direction, lots, entry_price, stop_loss, take_profit, initial_stop_loss, trailing_be, - open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""", + open_time, monitor_type, status, vt_order_id, order_price + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( sym, codes.get("name", sym), @@ -495,10 +524,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se trailing_be, open_time_val, monitor_type, + status_val, + vt_order_id, + order_px, ), ) mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - _close_duplicate_monitors(conn, sym, direction, mid) + if status_val == "active": + _close_duplicate_monitors(conn, sym, direction, mid) return mid def _sync_monitor_from_ctp( @@ -748,6 +781,84 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, } + def _compose_pending_row( + mon: dict, + *, + mode: str, + capital: float, + now_iso: str, + ) -> Optional[dict]: + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + lots = int(mon.get("lots") or 0) + if not sym or lots <= 0: + return None + order_price = float(mon.get("order_price") or mon.get("entry_price") or 0) + codes = ths_to_codes(sym) + sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None + tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None + pos_metrics = calc_position_metrics( + direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym, + ) + open_time = (mon.get("open_time") or "").strip() + timeout_sec = get_pending_order_timeout_sec(get_setting) + remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec) + return { + "key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}", + "order_state": "pending", + "source": "pending", + "source_label": "委托挂单中", + "sync_pending": True, + "monitor_id": mon.get("id"), + "symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") or sym), + "symbol_code": sym, + "direction": direction, + "direction_label": "做多" if direction == "long" else "做空", + "lots": lots, + "entry_price": order_price, + "order_price": order_price, + "stop_loss": sl, + "take_profit": tp, + "open_time": open_time or None, + "holding_duration": _holding_duration(open_time, now_iso) if open_time else None, + "mark_price": order_price, + "current_price": order_price, + "margin": pos_metrics.get("margin"), + "margin_source": "estimate", + "position_pct": pos_metrics.get("position_pct"), + "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, + "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, + "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, + "float_pnl": None, + "est_fee": None, + "can_close": False, + "close_allowed": False, + "can_cancel_order": True, + "auto_cancel_sec": remain, + "pending_timeout_sec": timeout_sec, + "pending_timeout_min": max(1, timeout_sec // 60), + "vt_order_id": mon.get("vt_order_id"), + "sl_order_active": False, + "tp_order_active": False, + "sl_monitoring": bool(sl is not None), + "tp_monitoring": bool(tp is not None), + "can_place_orders": False, + "pending_orders": [], + "trailing_be": bool(mon.get("trailing_be")), + "trailing_r_locked": int(mon.get("trailing_r_locked") or 0), + } + + def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None: + reconcile_pending_orders( + conn, + mode, + match_symbol_fn=_match_ctp_symbol, + sync_monitor_fn=_sync_monitor_from_ctp, + capital=capital, + list_positions_fn=_ctp_positions, + timeout_sec=get_pending_order_timeout_sec(get_setting), + ) + def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]: from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") @@ -851,16 +962,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se continue seen.add(rk) deduped.append(row) + + pending_raw = [ + dict(r) for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall() + ] + for mon in pending_raw: + try: + prow = _compose_pending_row( + mon, mode=mode, capital=capital, now_iso=now_iso, + ) + if prow: + deduped.insert(0, prow) + except Exception as exc: + logger.warning("compose pending row failed: %s", exc) return deduped def _build_trading_live_payload(conn, *, fast: bool = False) -> dict: mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) + capital = _capital(conn) + if not fast and ctp_st.get("connected"): + _reconcile_pending(conn, mode, capital=capital) if not fast: _ensure_monitors_from_ctp(conn, mode) rows = _build_trading_live_rows(conn, fast=fast) pending_orders = _build_pending_orders(conn, mode) - capital = _capital(conn) risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) return { "ok": True, @@ -871,6 +999,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, "trading_session": is_trading_session(), + "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), } def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict: @@ -969,6 +1098,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se fixed_amount=get_fixed_amount(get_setting), risk_percent=get_risk_percent(get_setting), max_margin_pct=get_max_margin_pct(get_setting), + pending_order_timeout_min=get_pending_order_timeout_min(get_setting), recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), ) @@ -1146,21 +1276,57 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn = get_db() try: init_strategy_tables(conn) + mode = get_trading_mode(get_setting) row = conn.execute( - "SELECT id FROM trade_order_monitors WHERE id=? AND status='active'", + "SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')", (monitor_id,), ).fetchone() if not row: return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404 + mon = dict(row) + if (mon.get("status") or "").strip().lower() == "pending": + ok, msg = cancel_pending_monitor(conn, mon, mode) + _push_position_snapshot_async(fast=False) + return jsonify({"ok": ok, "message": msg}) conn.execute( "UPDATE trade_order_monitors SET status='closed' WHERE id=?", (monitor_id,), ) conn.commit() + _push_position_snapshot_async(fast=False) return jsonify({"ok": True, "message": "已取消本地止盈止损监控"}) finally: conn.close() + @app.route("/api/trading/monitor/cancel-open", methods=["POST"]) + @login_required + def api_trading_monitor_cancel_open(): + """撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。""" + d = request.get_json(silent=True) or {} + try: + monitor_id = int(d.get("monitor_id") or 0) + except (TypeError, ValueError): + monitor_id = 0 + if monitor_id <= 0: + return jsonify({"ok": False, "error": "无效的委托记录"}), 400 + conn = get_db() + try: + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + if not ctp_status(mode).get("connected"): + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'", + (monitor_id,), + ).fetchone() + if not row: + return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404 + ok, msg = cancel_pending_monitor(conn, dict(row), mode) + _push_position_snapshot_async(fast=False) + return jsonify({"ok": ok, "message": msg}) + finally: + conn.close() + @app.route("/api/trading/close", methods=["POST"]) @login_required def api_trading_close(): @@ -1469,6 +1635,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se tp = d.get("take_profit") trailing_be = 1 if d.get("trailing_be") else 0 open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + vt_order_id = str(result.get("order_id") or "") mid = _upsert_open_monitor( conn, sym=sym, @@ -1480,28 +1647,60 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se trailing_be=trailing_be, open_time=open_ts, monitor_type="manual", + status="pending", + vt_order_id=vt_order_id or None, + order_price=price, ) conn.commit() - _push_position_snapshot_async(fast=True) - import time - time.sleep(1.5) - _sync_monitor_from_ctp( - conn, mid, sym, direction, mode, capital=_capital(conn), - ) - mon_row = conn.execute( - "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), + _reconcile_pending(conn, mode, capital=_capital(conn)) + st_row = conn.execute( + "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), ).fetchone() - if mon_row and (sl or tp): + filled = st_row and (st_row["status"] or "").strip().lower() == "active" + if not filled: try: - ensure_monitor_order_columns(conn) - cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode) - except Exception as exc: - logger.warning("清理旧版止盈止损挂单失败: %s", exc) + get_bridge().refresh_positions() + except Exception: + pass + _reconcile_pending(conn, mode, capital=_capital(conn)) + st_row = conn.execute( + "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + filled = st_row and (st_row["status"] or "").strip().lower() == "active" + if filled: + _sync_monitor_from_ctp( + conn, mid, sym, direction, mode, capital=_capital(conn), + ) + mon_row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + if mon_row and (sl or tp): + try: + ensure_monitor_order_columns(conn) + cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode) + except Exception as exc: + logger.warning("清理旧版止盈止损挂单失败: %s", exc) + conn.commit() + _push_position_snapshot_async(fast=False) + msg = ( + f"开仓成功 · {lots} 手" + if filled + else ( + f"委托已提交 · {lots} 手挂单中" + f"({get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)" + ) + ) conn.commit() send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") conn.close() _push_position_snapshot_async() - return jsonify({"ok": True, "result": result, "lots": lots, "message": "委托已提交柜台,限价单需成交后才会显示持仓"}) + return jsonify({ + "ok": True, + "result": result, + "lots": lots, + "message": msg if offset.startswith("open") else "委托已提交柜台", + "filled": filled if offset.startswith("open") else None, + }) except (ValueError, RuntimeError) as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 diff --git a/order_pending.py b/order_pending.py new file mode 100644 index 0000000..175df38 --- /dev/null +++ b/order_pending.py @@ -0,0 +1,174 @@ +"""开仓委托:pending 状态跟踪、成交转正、超时撤单。""" +from __future__ import annotations + +import logging +import time +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") +DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300 + + +def parse_monitor_ts(raw: str) -> Optional[float]: + s = (raw or "").strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp() + except ValueError: + continue + return None + + +def pending_age_sec(mon: dict) -> float: + ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts( + str(mon.get("created_at") or "") + ) + if ts is None: + return 0.0 + return max(0.0, time.time() - ts) + + +def pending_auto_cancel_remaining( + mon: dict, + *, + timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, +) -> int: + limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) + return max(0, int(limit - pending_age_sec(mon))) + + +def _match_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + if a and b and a.split(".")[0] == b.split(".")[0]: + return True + try: + from ctp_symbol import ths_to_vnpy_symbol + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + if a == vnpy_sym.lower(): + return True + except Exception: + pass + return False + + +def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]: + direction = (direction or "long").strip().lower() + for p in positions or []: + if int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if _match_symbol(p.get("symbol") or "", sym): + return p + return None + + +def reconcile_pending_orders( + conn, + mode: str, + *, + match_symbol_fn: Callable[[str, str], bool] | None = None, + sync_monitor_fn: Callable[..., None] | None = None, + capital: float = 0.0, + list_positions_fn: Callable[..., list] | None = None, + timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, +) -> dict[str, int]: + """同步 pending 委托:成交→active;超时/已撤→closed。""" + limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) + stats = {"promoted": 0, "cancelled": 0, "closed": 0} + if not ctp_status(mode).get("connected"): + return stats + + match = match_symbol_fn or _match_symbol + positions = ( + list_positions_fn(mode, refresh_if_empty=False, refresh_margin=False) + if list_positions_fn + else [] + ) + try: + active_orders = { + str(o.get("order_id") or ""): o + for o in ctp_list_active_orders(mode) + if o.get("order_id") + } + except Exception as exc: + logger.debug("list active orders: %s", exc) + active_orders = {} + + rows = conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC" + ).fetchall() + + for r in rows: + mon = dict(r) + mid = int(mon["id"]) + sym = mon.get("symbol") or "" + direction = mon.get("direction") or "long" + vt_oid = (mon.get("vt_order_id") or "").strip() + age = pending_age_sec(mon) + + pos = _find_ctp_position(positions, sym, direction) + if pos: + conn.execute( + "UPDATE trade_order_monitors SET status='active' WHERE id=?", + (mid,), + ) + if sync_monitor_fn: + sync_monitor_fn( + conn, mid, sym, direction, mode, ctp=pos, capital=capital, + ) + stats["promoted"] += 1 + continue + + if vt_oid and vt_oid in active_orders: + if age >= limit_sec: + if ctp_cancel_order(mode, vt_oid): + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + stats["cancelled"] += 1 + else: + logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid) + continue + + # 委托已不在活跃列表且无持仓:拒单/撤单/过期 + if age >= 8: + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + stats["closed"] += 1 + + if any(stats.values()): + conn.commit() + return stats + + +def cancel_pending_monitor( + conn, + mon: dict, + mode: str, +) -> tuple[bool, str]: + """手动撤销 pending 开仓委托。""" + mid = int(mon.get("id") or 0) + vt_oid = (mon.get("vt_order_id") or "").strip() + if vt_oid and ctp_status(mode).get("connected"): + try: + ctp_cancel_order(mode, vt_oid) + except Exception as exc: + logger.warning("cancel pending order monitor=%s: %s", mid, exc) + conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,)) + conn.commit() + return True, "开仓委托已撤销" diff --git a/sl_tp_guard.py b/sl_tp_guard.py index 4f71dd7..0d8528d 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -46,6 +46,8 @@ MONITOR_ORDER_COLUMNS = ( "ALTER TABLE trade_order_monitors ADD COLUMN position_pct REAL", "ALTER TABLE trade_order_monitors ADD COLUMN mark_price REAL", "ALTER TABLE trade_order_monitors ADD COLUMN float_pnl REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN vt_order_id TEXT", + "ALTER TABLE trade_order_monitors ADD COLUMN order_price REAL", ) TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓") diff --git a/static/css/trade.css b/static/css/trade.css index e2d19f0..f999071 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -72,6 +72,9 @@ .pos-pending-item.sl{border-left:3px solid var(--loss)} .pos-pending-item.tp{border-left:3px solid var(--profit)} .pos-pending-item.ctp{border-left:3px solid var(--accent)} +.pos-card.is-pending{border:1px dashed var(--accent);opacity:.95} +.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)} +.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color:var(--accent)} .pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px} .pos-close-btn:disabled,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)} .pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem} diff --git a/static/js/trade.js b/static/js/trade.js index da4e45c..f07e98b 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -225,6 +225,7 @@ } list.innerHTML = rows.map(buildPosCard).join(''); bindPendingDismiss(list); + bindCancelOpenButtons(list); bindSlTpButtons(list); bindPlaceOrderButtons(list); list.querySelectorAll('[data-close]').forEach(function (btn) { @@ -632,7 +633,10 @@ showOrderMsg(data.error || '下单失败', false); return; } - var msg = data.message || ('开仓成功 · ' + (data.lots || lots) + ' 手'); + var msg = data.message || ( + data.filled ? ('开仓成功 · ' + (data.lots || lots) + ' 手') : + ('委托已提交 · ' + (data.lots || lots) + ' 手挂单中') + ); showOrderMsg(msg, true); pollPositions(); refreshQuote(); @@ -666,14 +670,20 @@ return '
- 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。CTP 账号与前置在下方「CTP 连接」中配置。 + 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。 + 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。
@@ -161,8 +166,12 @@+ 与快期相同密码,保存前须在此手打;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。 +
官方第一套:180.168.146.187:10201/10211;
- 7×24:182.254.243.31:40001/40011(新账号可能需满 3 个交易日)。
+ 第二套(云服务器常用):182.254.243.31:30001/30011;
+ 7×24:182.254.243.31:40001/40011(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。
详见 docs/SIMNOW.md。
开仓后立即写入本地监控并显示;后台每秒同步 CTP 柜台更新盈亏与手数。刷新页面优先读本地缓存。
+开仓委托先显示「挂单中」,柜台成交后写入监控;超过 {{ pending_order_timeout_min }} 分钟未成交自动撤单,可手动撤单。