Track open orders as pending until CTP fill, with cancel and timeout.

Add configurable pending timeout in settings and clearer CTP password save feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 00:05:45 +08:00
parent 7ea8fb6301
commit a23f2c80ca
10 changed files with 567 additions and 41 deletions
+41 -7
View File
@@ -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,
)
+20 -4
View File
@@ -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,
}
+219 -20
View File
@@ -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
+174
View File
@@ -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, "开仓委托已撤销"
+2
View File
@@ -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 = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
+3
View File
@@ -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}
+63 -5
View File
@@ -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 '<div class="pos-pending-orders"><div class="pending-title">止盈止损监控</div>' + rows + '</div>';
}
function dismissMonitor(monitorId, btn) {
function dismissMonitor(monitorId, btn, opts) {
opts = opts || {};
if (!monitorId) return;
if (!confirm('取消该本地止盈止损监控?(不影响柜台委托)')) return;
var isPending = !!opts.pending;
var confirmMsg = isPending
? '撤销该开仓委托?(将向柜台发送撤单)'
: '取消该本地止盈止损监控?(不影响柜台委托)';
if (!confirm(confirmMsg)) return;
if (btn) {
btn.disabled = true;
btn.textContent = '取消中…';
}
fetch('/api/trading/monitor/dismiss', {
var url = isPending ? '/api/trading/monitor/cancel-open' : '/api/trading/monitor/dismiss';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_id: monitorId })
@@ -687,11 +697,20 @@
alert(e.message || '取消失败');
if (btn) {
btn.disabled = false;
btn.textContent = '取消';
btn.textContent = isPending ? '撤单' : '取消';
}
});
}
function bindCancelOpenButtons(root) {
if (!root) return;
root.querySelectorAll('[data-cancel-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true });
});
});
}
function bindPendingDismiss(root) {
if (!root) return;
root.querySelectorAll('[data-monitor-id]').forEach(function (btn) {
@@ -725,7 +744,46 @@
return '<span class="text-muted">未开启</span>';
}
function buildPendingOrderCard(row) {
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var orderPx = row.order_price != null ? row.order_price : row.entry_price;
var remainMin = row.pending_timeout_min != null
? row.pending_timeout_min
: (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5);
var cancelBtn = row.can_cancel_order ?
'<button type="button" class="pos-close-btn" data-cancel-open="' + row.monitor_id + '">撤单</button>' : '';
var metaLine =
'状态 <strong class="text-accent">挂单中</strong>' +
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
(row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
' · ' + slTpStatusHtml(row) +
' · 移动保本 ' + trailingStatusHtml(row) +
' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>';
return (
'<div class="pos-card is-pending">' +
'<div class="pos-card-head"><div><div class="title">' + row.symbol +
' <span class="badge dir">' + dirBadge + '</span>' +
' <span class="badge pending">挂单中</span></div>' +
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</div></div>' +
'<div class="pos-card-actions">' + cancelBtn + '</div></div>' +
'<div class="pos-card-meta pos-card-meta-line">' + metaLine + '</div>' +
'<div class="pos-metrics">' +
'<div class="cell"><label>委托手数</label><div><strong>' + row.lots + ' 手</strong></div></div>' +
'<div class="cell"><label>委托价</label><div>' + fmtNum(orderPx) + '</div></div>' +
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
'<div class="cell pnl-pending"><label>状态</label><div class="text-accent">等待成交</div></div>' +
'<div class="cell"><label>提交时间</label><div>' + (openT || '--') + '</div></div>' +
'</div>' +
buildPendingHtml(row.pending_orders) +
'</div>'
);
}
function buildPosCard(row) {
if (row.order_state === 'pending') {
return buildPendingOrderCard(row);
}
var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : '');
var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--';
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
+32 -4
View File
@@ -118,10 +118,15 @@
<label>移动保本缓冲(最小变动价位倍数)</label>
<input name="trailing_be_tick_buffer" type="number" step="1" min="1" max="20" value="{{ trailing_be_tick_buffer }}">
</div>
<div class="field">
<label>开仓挂单超时(分钟)</label>
<input name="pending_order_timeout_min" type="number" step="1" min="1" max="60" value="{{ pending_order_timeout_min }}">
</div>
</div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。CTP 账号与前置在下方「CTP 连接」中配置。
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。
</p>
</form>
</div>
@@ -161,8 +166,12 @@
</div>
<div class="field">
<label>交易密码</label>
<input name="simnow_password" type="password" autocomplete="new-password"
placeholder="{% if ctp_cfg.simnow_password_set %}已设置,留空不修改{% else %}SimNow 密码{% endif %}">
<input id="simnow_password" name="simnow_password" type="password"
autocomplete="off" spellcheck="false"
placeholder="{% if ctp_cfg.simnow_password_set %}已设置:须重新输入才会更新{% else %}SimNow 交易密码(必填){% endif %}">
<p class="hint" style="margin:.25rem 0 0;font-size:.75rem">
与快期相同密码,保存前须在此<strong>手打</strong>;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。
</p>
</div>
<div class="field">
<label>经纪商代码</label>
@@ -249,7 +258,8 @@
<button type="submit" class="btn-primary">保存 CTP 配置</button>
<p class="settings-ctp-status">
官方第一套:<code>180.168.146.187:10201/10211</code>
7×24<code>182.254.243.31:40001/40011</code>(新账号可能需满 3 个交易日)。
第二套(云服务器常用)<code>182.254.243.31:30001/30011</code>
7×24<code>182.254.243.31:40001/40011</code>(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。
详见 <code>docs/SIMNOW.md</code>
</p>
</form>
@@ -373,6 +383,24 @@
});
});
loadCtpFoldState();
var ctpForm = document.getElementById('ctp-settings-form');
if (ctpForm) {
ctpForm.addEventListener('submit', function (ev) {
var simnowFold = document.querySelector('[data-ctp-fold="simnow"]');
if (simnowFold) setCtpFold(simnowFold, false);
var pwd = document.getElementById('simnow_password');
var pwdVal = pwd && pwd.value ? pwd.value.trim() : '';
var pwdWasSet = {{ 'true' if ctp_cfg.simnow_password_set else 'false' }};
if (pwdWasSet && !pwdVal) {
var ok = window.confirm(
'SimNow 交易密码为空,保存后不会更新密码(仍用旧密码)。\n\n'
+ '若快期已改密,请取消后在「交易密码」框手打新密码再保存。\n\n仍要保存其他项?'
);
if (!ok) ev.preventDefault();
}
});
}
})();
</script>
{% endblock %}
+1 -1
View File
@@ -106,7 +106,7 @@
<div class="card trade-card" id="positions">
<h2>持仓监控</h2>
<p class="hint pos-hint">开仓后立即写入本地监控并显示;后台每秒同步 CTP 柜台更新盈亏与手数。刷新页面优先读本地缓存</p>
<p class="hint pos-hint">开仓委托先显示「挂单中」,柜台成交后写入监控;超过 <strong>{{ pending_order_timeout_min }}</strong> 分钟未成交自动撤单,可手动撤单</p>
<div class="card-body card-scroll" id="position-live-list">
<div class="empty-hint" id="position-placeholder">加载本地持仓…</div>
</div>
+12
View File
@@ -54,6 +54,18 @@ def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
return 2
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
try:
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
except (TypeError, ValueError):
return 5
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
return get_pending_order_timeout_min(get_setting) * 60
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
del conn