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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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, "开仓委托已撤销"
|
||||
@@ -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 = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user