Isolate CTP in worker process and improve strategy roll UX.

Split vn.py into qihuo-ctp worker with IPC client bridge, keep CTP connected during breaks with cached account fallback, speed up strategy page loads, and allow off-session breakout roll submissions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-01 12:35:47 +08:00
parent 08d55411aa
commit 9cd81a3ea7
17 changed files with 2214 additions and 227 deletions
+226
View File
@@ -0,0 +1,226 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Local HTTP client for the isolated CTP worker process."""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from typing import Any, Optional
DEFAULT_BASE_URL = "http://127.0.0.1:6601"
DEFAULT_TIMEOUT_SEC = 2.5
STATUS_TIMEOUT_SEC = 5.0
MUTATION_TIMEOUT_SEC = 8.0
class CtpWorkerUnavailable(RuntimeError):
"""Raised when the local CTP worker cannot be reached."""
def ctp_role() -> str:
return (os.getenv("QIHUO_CTP_ROLE", "client") or "client").strip().lower()
def is_worker_role() -> bool:
return ctp_role() == "worker"
def worker_base_url() -> str:
return (os.getenv("QIHUO_CTP_WORKER_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
def worker_token() -> str:
token = (os.getenv("QIHUO_CTP_WORKER_TOKEN", "") or "").strip()
if token:
return token
# Localhost-only default keeps old deployments working; PM2 sets a shared token.
return "qihuo-local-ctp"
def _request(
method: str,
path: str,
payload: Optional[dict[str, Any]] = None,
*,
timeout: float = DEFAULT_TIMEOUT_SEC,
) -> dict[str, Any]:
url = f"{worker_base_url()}{path}"
body = None
headers = {
"Accept": "application/json",
"X-Qihuo-CTP-Token": worker_token(),
}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise CtpWorkerUnavailable(f"CTP worker unavailable: {exc}") from exc
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise CtpWorkerUnavailable(f"CTP worker returned invalid JSON: {raw[:120]}") from exc
if not isinstance(data, dict):
raise CtpWorkerUnavailable("CTP worker returned non-object JSON")
if data.get("ok") is False:
raise RuntimeError(str(data.get("error") or "CTP worker request failed"))
return data
def get(path: str, *, timeout: float = DEFAULT_TIMEOUT_SEC) -> dict[str, Any]:
return _request("GET", path, timeout=timeout)
def post(
path: str,
payload: Optional[dict[str, Any]] = None,
*,
timeout: float = DEFAULT_TIMEOUT_SEC,
) -> dict[str, Any]:
return _request("POST", path, payload or {}, timeout=timeout)
def health() -> dict[str, Any]:
try:
return get("/health", timeout=1.0)
except Exception as exc:
return {
"ok": False,
"worker_online": False,
"error": str(exc),
"ts": time.time(),
}
def status(mode: str) -> dict[str, Any]:
try:
data = get(f"/ctp/status?mode={mode}", timeout=STATUS_TIMEOUT_SEC)
return dict(data.get("status") or {})
except Exception as exc:
return {
"connected": False,
"connecting": False,
"worker_online": False,
"last_error": f"CTP worker 离线或重启中:{exc}",
}
def connect(mode: str, *, force: bool = False) -> dict[str, Any]:
data = post(
"/ctp/connect",
{"mode": mode, "force": bool(force)},
timeout=MUTATION_TIMEOUT_SEC,
)
return dict(data.get("status") or data)
def start_connect(mode: str, *, force: bool = False, scheduled: bool = False) -> dict[str, Any]:
return post(
"/ctp/start_connect",
{"mode": mode, "force": bool(force), "scheduled": bool(scheduled)},
timeout=MUTATION_TIMEOUT_SEC,
)
def disconnect(*, set_disabled_hint: bool = False) -> None:
post(
"/ctp/disconnect",
{"set_disabled_hint": bool(set_disabled_hint)},
timeout=MUTATION_TIMEOUT_SEC,
)
def account(mode: str) -> dict[str, Any]:
data = get(f"/ctp/account?mode={mode}")
return dict(data.get("account") or {})
def positions(
mode: str,
*,
refresh_if_empty: bool = True,
refresh_margin: bool = False,
) -> list[dict[str, Any]]:
data = post(
"/ctp/positions",
{
"mode": mode,
"refresh_if_empty": bool(refresh_if_empty),
"refresh_margin": bool(refresh_margin),
},
)
return list(data.get("positions") or [])
def trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
data = post("/ctp/trades", {"mode": mode, "refresh": bool(refresh)})
return list(data.get("trades") or [])
def active_orders(mode: str) -> list[dict[str, Any]]:
data = get(f"/ctp/active_orders?mode={mode}")
return list(data.get("orders") or [])
def tick_price(mode: str, symbol: str) -> Optional[float]:
data = post("/ctp/tick_price", {"mode": mode, "symbol": symbol})
value = data.get("price")
return float(value) if value not in (None, "") else None
def tick_detail(mode: str, symbol: str) -> dict[str, Any]:
data = post("/ctp/tick_detail", {"mode": mode, "symbol": symbol})
return dict(data.get("detail") or {})
def estimate_margin_one_lot(
mode: str,
symbol: str,
price: float,
*,
direction: str = "long",
) -> Optional[float]:
data = post(
"/ctp/estimate_margin_one_lot",
{"mode": mode, "symbol": symbol, "price": price, "direction": direction},
)
value = data.get("margin")
return float(value) if value not in (None, "") else None
def contract_spec(mode: str, symbol: str) -> Optional[dict[str, Any]]:
data = post("/ctp/contract_spec", {"mode": mode, "symbol": symbol})
spec = data.get("spec")
return dict(spec) if isinstance(spec, dict) else None
def send_order(payload: dict[str, Any]) -> dict[str, Any]:
return post("/ctp/order", payload, timeout=MUTATION_TIMEOUT_SEC)
def cancel_order(mode: str, vt_orderid: str) -> bool:
data = post(
"/ctp/cancel",
{"mode": mode, "vt_orderid": vt_orderid},
timeout=MUTATION_TIMEOUT_SEC,
)
return bool(data.get("cancelled"))
def bridge_action(action: str, payload: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return post(
f"/ctp/bridge/{action}",
payload or {},
timeout=MUTATION_TIMEOUT_SEC,
)
+494
View File
@@ -0,0 +1,494 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Isolated local CTP worker.
This process is the only process that should instantiate vn.py / vnpy_ctp.
The Flask web app talks to it through localhost HTTP via ctp_ipc_client.py.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Any
os.environ.setdefault("QIHUO_CTP_ROLE", "worker")
from flask import Flask, jsonify, request
from ctp_ipc_client import worker_token
from db_conn import DB_PATH, commit_retry, connect_db
from fee_specs import get_setting, set_setting
from locale_fix import ensure_process_locale
from market_sessions import is_trading_session
from sl_tp_guard import check_sl_tp_on_tick, ensure_monitor_order_columns, start_sl_tp_guard_worker
from strategy.strategy_db import init_strategy_tables
from trading_context import get_account_capital, get_trading_mode, get_trailing_be_tick_buffer
from vnpy_bridge import (
_ctp_td_lock,
ctp_cancel_order,
ctp_disconnect,
ctp_estimate_margin_one_lot,
ctp_get_account,
ctp_get_tick_detail,
ctp_get_tick_price,
ctp_list_active_orders,
ctp_list_positions,
ctp_list_trades,
ctp_lookup_contract_spec,
ctp_start_connect,
ctp_status,
ctp_try_auto_reconnect,
execute_order,
get_bridge,
set_ctp_connected_callback,
set_position_refresh_callback,
set_tick_quote_callback,
set_tick_sl_tp_callback,
try_init_vnpy,
)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
_started_workers = False
_last_snapshot_ts = 0.0
_snapshot_lock = threading.Lock()
def _json_ok(**payload: Any):
return jsonify({"ok": True, **payload})
def _json_error(exc: Exception, *, status_code: int = 500):
return jsonify({"ok": False, "error": str(exc)}), status_code
def _require_token() -> None:
expected = worker_token()
got = request.headers.get("X-Qihuo-CTP-Token", "")
if expected and got != expected:
raise PermissionError("unauthorized")
@app.before_request
def _auth():
_require_token()
@app.errorhandler(Exception)
def _handle_error(exc: Exception):
code = 401 if isinstance(exc, PermissionError) else 500
logger.warning("ctp worker request failed: %s", exc)
return _json_error(exc, status_code=code)
def _mode_from_request() -> str:
data = request.get_json(silent=True) or {}
return (
data.get("mode")
or request.args.get("mode")
or get_trading_mode(get_setting)
or "simulation"
)
def _fast_status(mode: str) -> dict[str, Any]:
"""Return worker/native bridge state without slow network probing."""
from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
try:
st = dict(get_bridge().status(mode) or {})
except Exception as exc:
st = {
"connected": False,
"connecting": False,
"connected_mode": None,
"last_error": str(exc),
"mode_label": "SimNow" if mode == "simulation" else "期货公司实盘",
}
auto = is_ctp_auto_connect_enabled()
st["auto_connect_enabled"] = auto
st["worker_online"] = True
if not auto:
st["disabled_hint"] = CTP_DISABLED_HINT
if not st.get("connected") and not st.get("connecting"):
st["last_error"] = ""
st["td_reachable"] = None
return st
def _send_wechat_msg(content: str) -> None:
webhook = get_setting("wechat_webhook", "")
if not webhook:
return
try:
import requests
requests.post(
webhook,
json={"msgtype": "text", "text": {"content": f"【国内期货】\n{content}"}},
timeout=10,
)
except Exception as exc:
logger.debug("wechat notify failed: %s", exc)
def _init_worker_tables(conn) -> None:
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
def _capital(conn) -> float:
try:
return float(get_account_capital(get_setting, conn=conn) or 0)
except Exception:
return 0.0
def _persist_snapshot(mode: str) -> None:
global _last_snapshot_ts
with _snapshot_lock:
now = time.time()
if now - _last_snapshot_ts < 0.25:
return
_last_snapshot_ts = now
try:
import json
st = _fast_status(mode)
positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False)
account = ctp_get_account(mode) if st.get("connected") else {}
conn = connect_db(DB_PATH)
try:
conn.execute(
"""CREATE TABLE IF NOT EXISTS ctp_worker_snapshots (
key TEXT PRIMARY KEY,
value TEXT,
updated_at REAL
)"""
)
for key, value in (
("status", st),
("positions", positions),
("account", account),
):
conn.execute(
"""INSERT INTO ctp_worker_snapshots(key, value, updated_at)
VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at""",
(key, json.dumps(value, ensure_ascii=False), now),
)
commit_retry(conn)
finally:
conn.close()
except Exception as exc:
logger.debug("persist ctp snapshot: %s", exc)
def _on_position_refresh() -> None:
try:
_persist_snapshot(get_trading_mode(get_setting))
except Exception as exc:
logger.debug("position refresh callback: %s", exc)
def _on_tick_quote() -> None:
_on_position_refresh()
def _on_tick_sl_tp(exchange: str, symbol: str, price: float) -> None:
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return
conn = connect_db(DB_PATH)
try:
_init_worker_tables(conn)
capital = _capital(conn)
n = check_sl_tp_on_tick(
conn,
mode,
exchange,
symbol,
price,
capital=capital,
notify_fn=_send_wechat_msg,
be_tick_mult=get_trailing_be_tick_buffer(get_setting),
)
if n:
commit_retry(conn)
_persist_snapshot(mode)
except Exception as exc:
logger.warning("worker tick sl/tp: %s", exc)
finally:
conn.close()
def _on_ctp_connected(mode: str) -> None:
try:
with _ctp_td_lock:
get_bridge().request_position_snapshot(force=True)
get_bridge().calibrate_trading_state()
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker ctp connected callback: %s", exc)
def _start_background_workers() -> None:
global _started_workers
if _started_workers:
return
_started_workers = True
set_position_refresh_callback(_on_position_refresh)
set_tick_quote_callback(_on_tick_quote)
set_tick_sl_tp_callback(_on_tick_sl_tp)
set_ctp_connected_callback(_on_ctp_connected)
from ctp_fee_worker import start_ctp_fee_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker
from ctp_reconnect import start_ctp_reconnect_worker
from order_pending import reconcile_pending_orders
from pending_order_worker import start_pending_order_worker
def _mode() -> str:
return get_trading_mode(get_setting)
start_ctp_reconnect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_premarket_connect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_fee_worker(
get_mode_fn=_mode,
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
reconcile_fn=reconcile_pending_orders,
on_changed_fn=lambda: _persist_snapshot(_mode()),
)
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=_send_wechat_msg,
)
def _snapshot_loop() -> None:
time.sleep(3)
while True:
try:
mode = _mode()
if _fast_status(mode).get("connected"):
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker snapshot loop: %s", exc)
time.sleep(2 if is_trading_session() else 15)
threading.Thread(target=_snapshot_loop, daemon=True, name="ctp-worker-snapshot").start()
@app.route("/health")
def health():
mode = request.args.get("mode") or get_trading_mode(get_setting)
st = _fast_status(mode)
return _json_ok(
worker_online=True,
role=os.getenv("QIHUO_CTP_ROLE", "worker"),
mode=mode,
status=st,
ts=time.time(),
)
@app.route("/ctp/status")
def api_status():
mode = _mode_from_request()
return _json_ok(status=_fast_status(mode))
@app.route("/ctp/connect", methods=["POST"])
def api_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
info = ctp_start_connect(mode, force=bool(data.get("force")))
st = info.get("status") or _fast_status(mode)
return _json_ok(status=st, **{k: v for k, v in info.items() if k != "status"})
@app.route("/ctp/start_connect", methods=["POST"])
def api_start_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(**ctp_start_connect(
mode,
force=bool(data.get("force")),
scheduled=bool(data.get("scheduled")),
))
@app.route("/ctp/disconnect", methods=["POST"])
def api_disconnect():
data = request.get_json(silent=True) or {}
ctp_disconnect(set_disabled_hint=bool(data.get("set_disabled_hint")))
return _json_ok(disconnected=True)
@app.route("/ctp/account")
def api_account():
mode = _mode_from_request()
if not _fast_status(mode).get("connected"):
return _json_ok(account={})
return _json_ok(account=ctp_get_account(mode))
@app.route("/ctp/positions", methods=["POST"])
def api_positions():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(positions=ctp_list_positions(
mode,
refresh_if_empty=bool(data.get("refresh_if_empty", True)),
refresh_margin=bool(data.get("refresh_margin", False)),
))
@app.route("/ctp/trades", methods=["POST"])
def api_trades():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(trades=ctp_list_trades(mode, refresh=bool(data.get("refresh"))))
@app.route("/ctp/active_orders")
def api_active_orders():
mode = _mode_from_request()
return _json_ok(orders=ctp_list_active_orders(mode))
@app.route("/ctp/tick_price", methods=["POST"])
def api_tick_price():
data = request.get_json(silent=True) or {}
return _json_ok(price=ctp_get_tick_price(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/tick_detail", methods=["POST"])
def api_tick_detail():
data = request.get_json(silent=True) or {}
return _json_ok(detail=ctp_get_tick_detail(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/estimate_margin_one_lot", methods=["POST"])
def api_estimate_margin():
data = request.get_json(silent=True) or {}
return _json_ok(margin=ctp_estimate_margin_one_lot(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
float(data.get("price") or 0),
direction=data.get("direction") or "long",
))
@app.route("/ctp/contract_spec", methods=["POST"])
def api_contract_spec():
data = request.get_json(silent=True) or {}
return _json_ok(spec=ctp_lookup_contract_spec(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/order", methods=["POST"])
def api_order():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
result = execute_order(
None,
mode=mode,
offset=data.get("offset") or "open",
symbol=data.get("symbol") or "",
direction=data.get("direction") or "long",
lots=int(data.get("lots") or 1),
price=float(data.get("price") or 0),
settings=data.get("settings") or {},
order_type=data.get("order_type") or "limit",
)
_persist_snapshot(mode)
return _json_ok(**result)
@app.route("/ctp/cancel", methods=["POST"])
def api_cancel():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
cancelled = ctp_cancel_order(mode, data.get("vt_orderid") or "")
_persist_snapshot(mode)
return _json_ok(cancelled=cancelled)
@app.route("/ctp/bridge/<action>", methods=["POST"])
def api_bridge_action(action: str):
data = request.get_json(silent=True) or {}
b = get_bridge()
if action == "calibrate_trading_state":
return _json_ok(result=b.calibrate_trading_state())
if action == "request_position_snapshot":
return _json_ok(result=b.request_position_snapshot(force=bool(data.get("force"))))
if action == "subscribe_symbol":
return _json_ok(result=b.subscribe_symbol(data.get("symbol") or ""))
if action == "refresh_positions":
return _json_ok(result=b.refresh_positions())
if action == "connect_in_progress":
return _json_ok(result=b.connect_in_progress())
if action == "reconnect_after_settings_saved":
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(result=b.reconnect_after_settings_saved(mode))
if action == "query_all_commissions":
return _json_ok(result=b.query_all_commissions(
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "query_instrument_commission":
return _json_ok(result=b.query_instrument_commission(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "get_kline_bars_1m":
return _json_ok(result=b.get_kline_bars_1m(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
return _json_error(ValueError(f"unsupported bridge action: {action}"), status_code=404)
def main() -> None:
ensure_process_locale()
try_init_vnpy({})
_start_background_workers()
host = os.getenv("QIHUO_CTP_WORKER_HOST", "127.0.0.1")
port = int(os.getenv("QIHUO_CTP_WORKER_PORT", "6601") or 6601)
logger.info("starting qihuo-ctp worker on %s:%s", host, port)
app.run(host=host, port=port, debug=False, threaded=True, use_reloader=False)
if __name__ == "__main__":
main()
+12
View File
@@ -175,6 +175,18 @@ def build_dashboard_payload(
margin_used = round(max(0.0, equity - available), 2)
except Exception:
pass
else:
from trading_context import _cached_ctp_account
cached = _cached_ctp_account(mode)
balance = float(cached.get("balance") or 0)
if balance > 0:
equity = balance
avail = cached.get("available")
if avail is not None:
available = round(float(avail), 2)
if equity > 0:
margin_used = round(max(0.0, equity - available), 2)
key_rows = conn.execute(
"""
+27
View File
@@ -24,6 +24,9 @@ module.exports = {
LANG: "zh_CN.UTF-8",
LC_ALL: "zh_CN.UTF-8",
LC_CTYPE: "zh_CN.UTF-8",
QIHUO_CTP_ROLE: "client",
QIHUO_CTP_WORKER_URL: "http://127.0.0.1:6601",
QIHUO_CTP_WORKER_TOKEN: "qihuo-local-ctp",
QIHUO_STARTUP_WORKERS: "8",
QIHUO_MEMORY_MB: "8192",
},
@@ -31,5 +34,29 @@ module.exports = {
out_file: path.join(ROOT, "logs", "pm2-out.log"),
time: true,
},
{
name: "qihuo-ctp",
script: "ctp_worker.py",
cwd: ROOT,
interpreter,
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "8192M",
env: {
NODE_ENV: "production",
LANG: "zh_CN.UTF-8",
LC_ALL: "zh_CN.UTF-8",
LC_CTYPE: "zh_CN.UTF-8",
QIHUO_CTP_ROLE: "worker",
QIHUO_CTP_WORKER_HOST: "127.0.0.1",
QIHUO_CTP_WORKER_PORT: "6601",
QIHUO_CTP_WORKER_TOKEN: "qihuo-local-ctp",
QIHUO_MEMORY_MB: "8192",
},
error_file: path.join(ROOT, "logs", "pm2-ctp-error.log"),
out_file: path.join(ROOT, "logs", "pm2-ctp-out.log"),
time: true,
},
],
};
+717 -191
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -277,9 +277,11 @@ def should_keep_ctp_connected(
minutes_before: int = 30,
minutes_after: int = 30,
) -> bool:
"""是否处于应连接 CTP 的窗口:交易时段 + 盘前 + 盘后宽限。"""
"""是否处于应连接 CTP 的窗口:交易时段 + 小节/午间休盘 + 盘前 + 盘后宽限。"""
if is_trading_session(now):
return True
if is_morning_break(now) or is_lunch_break(now):
return True
if in_postmarket_grace_window(now, minutes_after=minutes_after):
return True
return in_premarket_connect_window(now, minutes_before=minutes_before)
+46
View File
@@ -0,0 +1,46 @@
"""Check qihuo web CTP status and qihuo-ctp worker health."""
from __future__ import annotations
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("192.168.8.21", username="root", password="woaini88", timeout=15)
cmds = [
'curl -s --max-time 8 -H "X-Qihuo-CTP-Token: qihuo-local-ctp" http://127.0.0.1:6601/health',
r'''python3 - <<'PY'
import http.cookiejar, json, sqlite3, urllib.parse, urllib.request
conn = sqlite3.connect("/opt/qihuo/futures.db")
row = conn.execute("SELECT value FROM settings WHERE key='admin_username'").fetchone()
conn.close()
user = row[0] if row else "admin"
pw = ""
for line in open("/opt/qihuo/.env", encoding="utf-8-sig", errors="replace"):
if line.startswith("ADMIN_PASSWORD="):
pw = line.split("=", 1)[1].strip().strip('"').strip("'")
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(
"http://127.0.0.1:6600/login",
urllib.parse.urlencode({"username": user, "password": pw}).encode(),
), timeout=8)
raw = op.open("http://127.0.0.1:6600/api/ctp/status", timeout=8).read()
print(raw.decode("utf-8", "replace")[:2000])
PY''',
"pm2 jlist | python3 -c \"import sys,json; rows=json.load(sys.stdin); print([(r.get('name'), (r.get('pm2_env') or {}).get('status'), (r.get('pm2_env') or {}).get('restart_time')) for r in rows if r.get('name') in ('qihuo','qihuo-ctp')])\"",
]
try:
for cmd in cmds:
print("===", cmd)
_, o, e = c.exec_command(cmd, timeout=30)
out = o.read().decode("utf-8", "replace")
err = e.read().decode("utf-8", "replace")
print(out[:3000])
if err.strip():
print("ERR:", err[:1000])
finally:
c.close()
+112
View File
@@ -0,0 +1,112 @@
"""Verify qihuo web process survives an isolated qihuo-ctp restart."""
from __future__ import annotations
import json
import sys
import time
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
def _connect() -> paramiko.SSHClient:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("192.168.8.21", username="root", password="woaini88", timeout=15)
return c
def _run(c: paramiko.SSHClient, cmd: str, timeout: int = 60) -> str:
_, o, e = c.exec_command(cmd, timeout=timeout)
out = o.read().decode("utf-8", "replace")
err = e.read().decode("utf-8", "replace")
return out + (("\nERR:\n" + err) if err.strip() else "")
def _pm2(c: paramiko.SSHClient) -> dict[str, dict]:
raw = _run(c, "pm2 jlist", timeout=30)
rows = json.loads(raw)
return {r.get("name"): r for r in rows}
def _restart_count(row: dict) -> int:
env = row.get("pm2_env") or {}
return int(env.get("restart_time") or 0)
def main() -> int:
c = _connect()
try:
before = _pm2(c)
for name in ("qihuo", "qihuo-ctp"):
row = before.get(name) or {}
env = row.get("pm2_env") or {}
print(
"before",
name,
"status",
env.get("status"),
"restarts",
_restart_count(row),
"pid",
row.get("pid"),
)
health = _run(
c,
'curl -s -H "X-Qihuo-CTP-Token: qihuo-local-ctp" '
"http://127.0.0.1:6601/health",
timeout=30,
)
print("worker_health", health[:1000])
web = _run(
c,
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6600/login",
timeout=30,
)
print("web_login_before", web.strip())
print("restarting qihuo-ctp only")
print(_run(c, "pm2 restart qihuo-ctp --update-env", timeout=60))
time.sleep(8)
after = _pm2(c)
for name in ("qihuo", "qihuo-ctp"):
row = after.get(name) or {}
env = row.get("pm2_env") or {}
print(
"after",
name,
"status",
env.get("status"),
"restarts",
_restart_count(row),
"pid",
row.get("pid"),
)
web_after = _run(
c,
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6600/login",
timeout=30,
)
print("web_login_after", web_after.strip())
qihuo_before = _restart_count(before.get("qihuo") or {})
qihuo_after = _restart_count(after.get("qihuo") or {})
ctp_before = _restart_count(before.get("qihuo-ctp") or {})
ctp_after = _restart_count(after.get("qihuo-ctp") or {})
ok = (
qihuo_after == qihuo_before
and ctp_after >= ctp_before + 1
and web_after.strip() == "200"
)
print("isolation_ok", ok)
return 0 if ok else 1
finally:
c.close()
if __name__ == "__main__":
raise SystemExit(main())
+72
View File
@@ -0,0 +1,72 @@
"""Deploy position display fix: stop ALTER lock + live rows from CTP."""
import sys
import time
from pathlib import Path
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
root = Path(__file__).resolve().parents[1]
files = [
"ctp_ipc_client.py",
"ctp_worker.py",
"ecosystem.config.cjs",
"market_sessions.py",
"trading_context.py",
"dashboard_lib.py",
"sl_tp_guard.py",
"install_trading.py",
"vnpy_bridge.py",
"static/js/trade.js",
"templates/strategy.html",
"templates/strategy_records.html",
"static/js/strategy.js",
"strategy/strategy_roll_lib.py",
"scripts/run_schema_migrate.py",
]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("192.168.8.21", username="root", password="woaini88", timeout=15)
sftp = c.open_sftp()
for rel in files:
sftp.put(str(root / rel), f"/opt/qihuo/{rel}")
print("uploaded", rel)
sftp.close()
cmds = [
"cd /opt/qihuo && source venv/bin/activate && python3 scripts/run_schema_migrate.py",
"cd /opt/qihuo && source venv/bin/activate && python3 -m py_compile ctp_ipc_client.py ctp_worker.py vnpy_bridge.py sl_tp_guard.py install_trading.py",
"cd /opt/qihuo && (pm2 describe qihuo-ctp >/dev/null && pm2 restart qihuo-ctp --update-env || pm2 start ecosystem.config.cjs --only qihuo-ctp)",
"cd /opt/qihuo && (pm2 describe qihuo >/dev/null && pm2 restart qihuo --update-env || pm2 start ecosystem.config.cjs --only qihuo)",
"pm2 save",
]
for cmd in cmds:
print(">>>", cmd)
_, o, e = c.exec_command(cmd, timeout=120)
out = o.read().decode("utf-8", "replace")
err = e.read().decode("utf-8", "replace")
if out.strip():
print(out)
if err.strip():
print(err)
time.sleep(25)
_, o, _ = c.exec_command(
"curl -s -o /dev/null -w 'login:%{http_code}\\n' --max-time 10 http://127.0.0.1:6600/login",
timeout=30,
)
print(o.read().decode())
# verify live vs account_snapshot
poll = root / "scripts" / "_poll_loop.py"
if poll.exists():
import subprocess
r = subprocess.run([sys.executable, str(poll)], capture_output=True, text=True, timeout=120)
print(r.stdout)
if r.stderr.strip():
print(r.stderr)
c.close()
print("done")
+40 -6
View File
@@ -60,13 +60,47 @@ MONITOR_ORDER_COLUMNS = (
TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
_MONITOR_COLUMNS_READY = False
_MONITOR_COLUMNS_LOCK = threading.Lock()
def ensure_monitor_order_columns(conn) -> None:
for sql in MONITOR_ORDER_COLUMNS:
try:
conn.execute(sql)
except Exception:
pass
def _monitor_columns_exist(conn) -> bool:
try:
rows = conn.execute("PRAGMA table_info(trade_order_monitors)").fetchall()
cols = set()
for r in rows:
if isinstance(r, dict):
cols.add(r.get("name") or "")
else:
cols.add(r[1])
return "open_fee" in cols
except Exception:
return False
def ensure_monitor_order_columns(conn, *, migrate: bool = False) -> None:
"""列齐全后不再 ALTER,避免 worker 每次请求锁 SQLite。"""
global _MONITOR_COLUMNS_READY
if _MONITOR_COLUMNS_READY:
return
with _MONITOR_COLUMNS_LOCK:
if _MONITOR_COLUMNS_READY:
return
if _monitor_columns_exist(conn):
_MONITOR_COLUMNS_READY = True
return
if not migrate:
return
for sql in MONITOR_ORDER_COLUMNS:
try:
conn.execute(sql)
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
_MONITOR_COLUMNS_READY = True
def _tick_size(ths_code: str) -> float:
+13
View File
@@ -109,6 +109,7 @@
var breakEl = document.getElementById('roll-break-price');
var execHint = document.getElementById('roll-exec-hint');
var btnExec = document.getElementById('btn-roll-exec');
var btnPreview = document.getElementById('btn-roll-preview');
if (!modeEl) return;
var mode = modeEl.value || 'market';
var isBreak = mode === 'breakout';
@@ -120,6 +121,12 @@
if (btnExec) {
btnExec.textContent = mode === 'market' ? '执行滚仓' : '提交监控';
}
if (btnPreview) {
btnPreview.disabled = !inTradingSession && !isBreak;
btnPreview.title = (!inTradingSession && !isBreak)
? '休盘期间请切换为突破加仓'
: '';
}
}
function syncRollRiskHint() {
@@ -196,6 +203,7 @@
var rollPayload = null;
var rollMonitorSel = document.getElementById('roll-monitor-select');
var rollModeSel = document.getElementById('roll-add-mode');
var inTradingSession = {{ 'true' if trading_session else 'false' }};
if (rollModeSel) rollModeSel.addEventListener('change', syncRollModeUi);
if (rollMonitorSel) rollMonitorSel.addEventListener('change', syncRollRiskHint);
@@ -214,6 +222,7 @@
}
showPreview(rollPrev, formatRoll(d.preview), true, false);
btnRollE.hidden = false;
syncRollModeUi();
}).finally(function () {
btnRollP.disabled = false;
});
@@ -224,6 +233,10 @@
var payload = rollPayload || formData(rollForm);
var mode = (payload.add_mode || 'market');
if (mode === 'market') {
if (!inTradingSession) {
alert('休盘期间请切换为「突破加仓」后提交监控');
return;
}
if (!confirm('确认执行市价滚仓?')) return;
startRollCountdown(btnRollE, payload);
return;
+61 -17
View File
@@ -34,6 +34,8 @@
var ctpConnecting = false;
var ctpAutoConnectEnabled = true;
var positionsRendered = false;
var posFastPollTimer = null;
var posFastPollCount = 0;
var lastPosRowCount = 0;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
@@ -248,6 +250,24 @@
});
}
function stopPosFastPoll() {
if (posFastPollTimer) {
clearInterval(posFastPollTimer);
posFastPollTimer = null;
}
posFastPollCount = 0;
}
function startPosFastPoll() {
if (posFastPollTimer) return;
posFastPollCount = 0;
posFastPollTimer = setInterval(function () {
pollPositions();
posFastPollCount += 1;
if (posFastPollCount >= 90) stopPosFastPoll();
}, 1000);
}
function applyPositionsData(data) {
if (!data) return;
var cap = document.getElementById('cap-display');
@@ -312,6 +332,7 @@
if (!connected) {
if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
startPosFastPoll();
return;
}
if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) {
@@ -325,8 +346,8 @@
list.innerHTML = '<div class="empty-hint text-muted">' + offHint + '</div>';
return;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
if (ctpAutoConnectEnabled) tryAutoCtpReconnect();
list.innerHTML = '<div class="empty-hint">CTP 未连接,后台自动连接中…</div>';
if (ctpAutoConnectEnabled) refreshCtpStatusPassive();
return;
}
var syncing = data.sync_state === 'syncing';
@@ -339,6 +360,7 @@
syncBadge.textContent = data.sync_label || '持仓同步中…';
syncBadge.className = 'sync-badge text-accent';
}
startPosFastPoll();
return;
}
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
@@ -347,9 +369,7 @@
return;
}
lastPosRowCount = rows.length;
if (!connected && ctpAutoConnectEnabled) {
tryAutoCtpReconnect();
}
stopPosFastPoll();
list.innerHTML = rows.map(buildPosCard).join('');
syncPositionListScroll(rows.length);
bindPendingDismiss(list);
@@ -610,8 +630,9 @@
}
if (st.connecting && Date.now() < deadline) {
syncCtpBadgeFromStatus(st);
pollPositions();
return new Promise(function (resolve) {
setTimeout(function () { resolve(tick()); }, 2000);
setTimeout(function () { resolve(tick()); }, 800);
});
}
syncCtpBadgeFromStatus(st);
@@ -795,18 +816,31 @@
});
}
function tryAutoCtpReconnect() {
if (!ctpAutoConnectEnabled) return;
if (ctpReconnecting || ctpConnectInflight) return;
/** 只读 CTP 状态;连接由 qihuo-ctp 后台 worker 负责,前端不发起 connect。 */
function refreshCtpStatusPassive() {
if (ctpConnected || ctpConnecting) return;
var now = Date.now();
if (now - lastCtpReconnectAt < 60000) return;
if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return;
if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) return;
if (now - lastCtpReconnectAt < 8000) return;
lastCtpReconnectAt = now;
ctpReconnecting = true;
requestCtpConnect(false).finally(function () {
ctpReconnecting = false;
});
fetch('/api/ctp/status')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.status || {};
syncCtpBadgeFromStatus(st);
if (st.connected) {
showCtpError('');
pollPositions();
startPosFastPoll();
} else if (st.connecting) {
updateCtpBadge(false, true);
startPosFastPoll();
} else if (st.last_error) {
showCtpError(st.last_error);
} else if (st.disabled_hint) {
showCtpError(st.disabled_hint);
}
})
.catch(function () {});
}
function showOrderMsg(text, ok) {
@@ -1911,12 +1945,22 @@
} else if (st.last_error) {
showCtpError(st.last_error);
}
if (st.connected) pollPositions();
if (st.connected) {
pollPositions();
startPosFastPoll();
} else if (st.connecting) {
startPosFastPoll();
waitForCtpConnected(90000);
} else if (ctpAutoConnectEnabled && !(st.login_cooldown_sec > 0)) {
refreshCtpStatusPassive();
startPosFastPoll();
}
})
.catch(function () {});
}
function cleanupTradePage() {
stopPosFastPoll();
if (sessionClockTickTimer) {
clearInterval(sessionClockTickTimer);
sessionClockTickTimer = null;
+11
View File
@@ -170,6 +170,7 @@ def validate_roll_geometry(
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Optional[str]:
"""几何校验。
@@ -206,6 +207,12 @@ def validate_roll_geometry(
trigger = float(breakthrough_price or 0)
if trigger <= 0:
return "须填写突破价"
if off_session_pending:
if direction == "long" and not (sl < trigger):
return "做多突破:休盘提交须满足 止损 < 突破价"
if direction == "short" and not (trigger < sl):
return "做空突破:休盘提交须满足 突破价 < 止损"
return None
if at_trigger:
if direction == "long":
if not (sl < trigger <= mark):
@@ -269,12 +276,15 @@ def preview_roll(
fib_lower: Optional[float] = None,
legs_done: int = 0,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
mark = float(mark_price or add_price or 0)
if mark <= 0 and mode == ADD_MODE_BREAKOUT and off_session_pending:
mark = float(breakthrough_price or 0)
if mark <= 0:
return None, "需要有效参考价"
sl = float(new_stop_loss)
@@ -314,6 +324,7 @@ def preview_roll(
limit_price=trigger_price if mode in FIB_MODES else None,
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
at_trigger=at_trigger,
off_session_pending=off_session_pending and is_pending,
)
if geom_err:
return None, geom_err
+10 -4
View File
@@ -136,7 +136,10 @@
<button type="button" class="btn-primary" id="btn-roll-exec" hidden {% if not roll_allowed %}disabled{% endif %}>执行滚仓</button>
</div>
<div id="roll-preview" class="strategy-preview" hidden></div>
<p class="hint" id="roll-exec-hint" hidden style="font-size:.75rem;margin-top:.45rem">市价加仓:须交易时段内确认,10 秒倒计时执行;突破加仓:任意时间可提交,开盘后再监控触价</p>
<p class="hint" id="roll-exec-hint" hidden style="font-size:.75rem;margin-top:.45rem">市价加仓:须交易时段内确认,10 秒倒计时执行;突破加仓:休盘也可提交,开盘后再监控触价</p>
{% if not trading_session %}
<p class="hint text-muted" id="roll-off-session-hint" style="font-size:.75rem;margin-top:.35rem">当前{{ session_clock.status_label or '休盘' }}:请选「突破加仓」填写突破价后预览并提交监控。</p>
{% endif %}
</form>
{% else %}
<p class="empty-hint">暂无可用持仓监控</p>
@@ -151,7 +154,7 @@
<div class="table-responsive">
<table class="strategy-preview-table">
<thead><tr>
<th>ID</th><th>品种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利(元)</th>
<th>ID</th><th>品种</th><th>方向</th><th>腿数</th><th>首仓手数</th><th>当前总手数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利(元)</th>
</tr></thead>
<tbody>
{% for g in roll_groups %}
@@ -160,6 +163,8 @@
<td>{{ g.symbol_name or g.symbol }}</td>
<td>{{ '多' if g.direction == 'long' else '空' }}</td>
<td>{{ g.leg_count or 0 }}/3</td>
<td>{{ g.first_lots if g.first_lots is not none else '—' }}</td>
<td>{{ g.total_lots if g.total_lots is not none else '—' }}</td>
<td>{{ g.initial_take_profit or '—' }}</td>
<td>{{ g.current_stop_loss or '—' }}</td>
<td>{{ g.avg_entry or '—' }}</td>
@@ -172,12 +177,12 @@
{% else %}
<p class="hint text-muted">暂无</p>
{% endif %}
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">最近滚仓</h3>
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">正在滚仓</h3>
{% if roll_legs %}
<div class="table-responsive">
<table class="strategy-preview-table">
<thead><tr>
<th>#</th><th></th><th>方式</th><th>手数</th><th>触发/限价</th><th>新SL</th><th>状态</th><th>操作</th>
<th>#</th><th></th><th>方式</th><th>手数</th><th>触发/限价</th><th>新SL</th><th>当前价</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{% for l in roll_legs %}
@@ -188,6 +193,7 @@
<td>{{ l.lots or '—' }}</td>
<td>{{ l.breakthrough_price or l.limit_price or l.fill_price or '—' }}</td>
<td>{{ l.new_stop_loss or '—' }}</td>
<td>{{ l.current_price if l.current_price is not none else '—' }}</td>
<td title="{{ l.invalidated_reason or '' }}">{{ roll_leg_status_labels.get(l.status, l.status) }}{% if l.status == 'invalidated' and l.invalidated_reason %} · {{ l.invalidated_reason[:24] }}{% endif %}</td>
<td>{% if l.status == 'pending' %}<button type="button" class="btn-link roll-cancel-leg" data-leg-id="{{ l.id }}">删除</button>{% else %}—{% endif %}</td>
</tr>
+36 -1
View File
@@ -1,6 +1,15 @@
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
{% extends "base.html" %}
{% block title %}策略记录 - 国内期货 · 交易复盘系统{% endblock %}
{% block extra_css %}
<style>
.strategy-preview{background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5}
.strategy-preview-table{width:100%;border-collapse:collapse;font-size:.72rem;min-width:520px}
.strategy-preview-table th,.strategy-preview-table td{padding:.35rem .4rem;border-bottom:1px solid var(--table-border);text-align:right;white-space:nowrap}
.strategy-preview-table th:first-child,.strategy-preview-table td:first-child{text-align:left}
.strategy-preview-table thead th{color:var(--text-muted);font-weight:600;background:var(--list-item-bg)}
</style>
{% endblock %}
{% block content %}
<div class="split-grid">
<div class="card card-scroll">
@@ -15,7 +24,33 @@
<h2>顺势加仓</h2>
{% if roll_rows %}
<ul class="list">{% for r in roll_rows %}
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
<li class="list-item">
<details style="width:100%">
<summary>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</summary>
<div class="strategy-preview" style="margin-top:.55rem">
<p>方向:{{ '多' if r.direction == 'long' else '空' }}</p>
<p>首仓手数:{{ r.detail.first_lots or '—' }} · 加仓次数:{{ r.detail.add_count or 0 }} · 加仓手数:{{ r.detail.add_lots or 0 }} · 当前总手数:{{ r.detail.total_lots or '—' }}</p>
<p>最新止损:{{ r.detail.latest_stop_loss or '—' }} · 平仓价格:{{ r.detail.close_price or '—' }} · 盈利情况:{{ r.detail.pnl if r.detail.pnl is not none else '—' }}</p>
{% if r.detail.legs %}
<table class="strategy-preview-table">
<thead><tr><th></th><th>方式</th><th>手数</th><th>成交/触发价</th><th>新SL</th><th>时间</th></tr></thead>
<tbody>
{% for l in r.detail.legs %}
<tr>
<td>{{ l.leg_index or loop.index }}</td>
<td>{{ l.add_mode }}</td>
<td>{{ l.lots or '—' }}</td>
<td>{{ l.fill_price or l.breakthrough_price or l.limit_price or '—' }}</td>
<td>{{ l.new_stop_loss or '—' }}</td>
<td>{{ l.created_at or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</details>
</li>
{% endfor %}</ul>
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
</div>
+44 -1
View File
@@ -79,8 +79,47 @@ def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int
return get_pending_order_timeout_min(get_setting) * 60
def _cached_ctp_account(mode: str) -> dict[str, float]:
"""CTP 未连接时,用最近一次 worker/持仓快照里的账户权益。"""
import json
try:
from position_stream import position_hub
snap = position_hub.get_snapshot() or {}
cap = float(snap.get("capital") or 0)
if cap > 0:
return {"balance": cap}
except Exception:
pass
try:
from db_conn import connect_db
conn = connect_db()
try:
row = conn.execute(
"SELECT value FROM ctp_worker_snapshots WHERE key='account' LIMIT 1"
).fetchone()
finally:
conn.close()
if row and row["value"]:
acc = json.loads(row["value"])
balance = float(acc.get("balance") or 0)
available = acc.get("available")
out: dict[str, float] = {}
if balance > 0:
out["balance"] = balance
if available is not None:
out["available"] = float(available)
return out
except Exception:
pass
del mode
return {}
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
"""优先 SimNow/期货公司 CTP 权益;未连接时用最近快照或设置中的参考资金。"""
del conn
mode = get_trading_mode(get_setting)
try:
@@ -93,6 +132,10 @@ def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
return float(bal)
except Exception:
pass
cached = _cached_ctp_account(mode)
balance = float(cached.get("balance") or 0)
if balance > 0:
return balance
try:
return float(get_setting("live_capital", "0") or 0)
except (TypeError, ValueError):
+290 -6
View File
@@ -14,9 +14,11 @@ import time
from collections import deque
from typing import Any, Callable, Optional
import ctp_ipc_client
from locale_fix import ensure_process_locale
ensure_process_locale()
if ctp_ipc_client.is_worker_role():
ensure_process_locale()
from ctp_settings import live_setting_dict, simnow_setting_dict
from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange
@@ -34,6 +36,10 @@ CTP_COOLDOWN_UNTIL_KEY = "ctp_login_cooldown_until"
CTP_LAST_ERROR_KEY = "ctp_last_error"
def _use_ctp_worker_client() -> bool:
return not ctp_ipc_client.is_worker_role()
def _persist_login_cooldown(seconds: float) -> None:
from fee_specs import get_setting, set_setting
@@ -163,7 +169,7 @@ def _fire_position_refresh_callback_debounced(*, min_interval: float = 0.35) ->
def _fire_position_refresh_burst() -> None:
"""连接后持仓回报可能分批到达,分多次触发快照刷新。"""
_fire_position_refresh_callback()
for delay in (1.5, 4.0, 10.0, 18.0):
for delay in (0.4, 0.9, 1.5, 3.0, 6.0, 12.0, 20.0):
threading.Timer(delay, _fire_position_refresh_callback).start()
@@ -183,10 +189,11 @@ def _schedule_after_instruments_ready(bridge: "CtpBridge") -> None:
bridge._ensure_instrument_margin_hooks()
with _ctp_td_lock:
bridge.request_position_snapshot(force=True)
time.sleep(2.0)
time.sleep(0.8)
with _ctp_td_lock:
bridge.calibrate_trading_state()
_fire_position_refresh_callback()
_fire_position_refresh_burst()
n = len(bridge._collect_positions())
logger.info("CTP 合约加载完成,持仓 %s 条,已刷新快照", n)
except Exception as exc:
@@ -217,7 +224,7 @@ _bridge: Optional["CtpBridge"] = None
_bridge_lock = threading.Lock()
_ctp_td_lock = threading.RLock()
POSITION_QUERY_MIN_INTERVAL_SEC = 5.0
POSITION_QUERY_RETRY_DELAYS_SEC = (22.0, 50.0, 95.0)
POSITION_QUERY_RETRY_DELAYS_SEC = (1.5, 4.0, 9.0, 18.0, 35.0)
TRADE_QUERY_MIN_INTERVAL_SEC = 10.0
@@ -337,6 +344,7 @@ class CtpBridge:
self._trade_query_event = threading.Event()
self._last_trade_query_ts: float = 0.0
self._last_connect_ok_ts: float = 0.0
self._connect_started_ts: float = 0.0
self._tick_hooked = False
self._position_hooked = False
self._order_hooked = False
@@ -704,6 +712,16 @@ class CtpBridge:
cooldown = self.login_cooldown_remaining()
connecting = bool(self._connect_in_progress and cooldown <= 0)
last_error = self._last_error or _load_persisted_last_error()
if (
connecting
and self._connect_started_ts > 0
and time.time() - self._connect_started_ts > CONNECT_WAIT_SEC + 10
and not last_error
):
last_error = (
f"CTP 连接进行中已超过 {CONNECT_WAIT_SEC}s"
"可能前置不可达或柜台响应慢"
)
return {
"vnpy_installed": self.available(),
"connected": self._connected_mode == mode,
@@ -746,6 +764,7 @@ class CtpBridge:
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
self._connect_in_progress = True
self._connect_started_ts = time.time()
try:
with _ctp_td_lock:
with self._connect_lock:
@@ -806,6 +825,10 @@ class CtpBridge:
self.calibrate_trading_state()
except Exception as exc:
logger.debug("post-connect calibrate: %s", exc)
try:
self.request_position_snapshot(force=True)
except Exception as exc:
logger.debug("post-connect position query: %s", exc)
self._ensure_instrument_margin_hooks()
_fire_position_refresh_burst()
_schedule_position_query_retries(self)
@@ -823,6 +846,7 @@ class CtpBridge:
raise RuntimeError(hint)
finally:
self._connect_in_progress = False
self._connect_started_ts = 0.0
def start_connect_async(
self, mode: str, *, force: bool = False, scheduled: bool = False,
@@ -859,13 +883,39 @@ class CtpBridge:
except Exception as exc:
logger.warning("CTP 后台连接失败: %s", exc)
def _watchdog() -> None:
deadline = CONNECT_WAIT_SEC + 25
time.sleep(deadline)
if not self._connect_in_progress:
return
logger.warning(
"CTP 连接 watchdog 超时 %.0fs,重置连接状态 [%s]",
deadline,
mode,
)
self._connect_in_progress = False
self._connect_started_ts = 0.0
hint = (
f"CTP 连接超时(>{deadline:.0f}s),可能前置不可达或柜台无响应。"
"请检查 SimNow 前置地址与账号,勿频繁重试。"
)
self._last_error = hint
_persist_last_error(hint)
try:
self._close_gateway()
except Exception as exc:
logger.debug("watchdog gateway close: %s", exc)
threading.Thread(target=_run, daemon=True, name="ctp-connect-async").start()
threading.Thread(target=_watchdog, daemon=True, name="ctp-connect-watchdog").start()
return {"started": True, "connecting": True, "connected": False}
def ensure_connected(self, mode: str) -> None:
if self._connected_mode == mode and self.ping():
return
self.connect(mode)
if self._connect_in_progress:
raise RuntimeError("CTP 连接中,请稍候")
raise RuntimeError("请先连接 CTP")
def require_connected(self, mode: str) -> None:
"""报单前检查:须已连接,不在此发起阻塞式 connect。"""
@@ -2125,8 +2175,170 @@ class CtpBridge:
return False
def get_bridge() -> CtpBridge:
class CtpBridgeProxy:
"""Client-side stand-in for CtpBridge, forwarding calls to qihuo-ctp."""
_engine = None
@property
def connected_mode(self) -> Optional[str]:
st = ctp_ipc_client.health().get("status") or {}
return st.get("connected_mode")
@property
def last_error(self) -> str:
st = ctp_ipc_client.health().get("status") or {}
return str(st.get("last_error") or "")
@property
def _last_connect_ok_ts(self) -> float:
st = ctp_ipc_client.health().get("status") or {}
try:
return float(st.get("last_connect_ok_ts") or 0)
except (TypeError, ValueError):
return 0.0
def available(self) -> bool:
return bool(ctp_ipc_client.health().get("worker_online"))
def status(self, mode: str) -> dict[str, Any]:
return ctp_ipc_client.status(mode)
def ping(self) -> bool:
return bool(ctp_ipc_client.health().get("worker_online"))
def connect(self, mode: str, *, force: bool = False) -> dict[str, Any]:
return ctp_ipc_client.connect(mode, force=force)
def start_connect_async(
self,
mode: str,
*,
force: bool = False,
scheduled: bool = False,
) -> dict[str, Any]:
return ctp_ipc_client.start_connect(mode, force=force, scheduled=scheduled)
def connect_in_progress(self) -> bool:
data = ctp_ipc_client.bridge_action("connect_in_progress")
return bool(data.get("result"))
def login_cooldown_remaining(self) -> int:
st = ctp_ipc_client.health().get("status") or {}
try:
return int(st.get("login_cooldown_sec") or 0)
except (TypeError, ValueError):
return 0
def ensure_connected(self, mode: str) -> None:
if not self.status(mode).get("connected"):
raise RuntimeError("CTP worker 未连接,请重连后再操作")
def require_connected(self, mode: str) -> None:
self.ensure_connected(mode)
def get_account(self) -> dict[str, Any]:
mode = self.connected_mode or "simulation"
return ctp_ipc_client.account(mode)
def list_positions(
self,
*,
refresh_if_empty: bool = True,
refresh_margin: bool = False,
) -> list[dict[str, Any]]:
mode = self.connected_mode or "simulation"
return ctp_ipc_client.positions(
mode,
refresh_if_empty=refresh_if_empty,
refresh_margin=refresh_margin,
)
def list_active_orders(self) -> list[dict[str, Any]]:
mode = self.connected_mode or "simulation"
return ctp_ipc_client.active_orders(mode)
def list_trades(self, *, refresh: bool = False) -> list[dict[str, Any]]:
mode = self.connected_mode or "simulation"
return ctp_ipc_client.trades(mode, refresh=refresh)
def get_tick_price(self, ths_code: str, *, mode: str = "") -> Optional[float]:
return ctp_ipc_client.tick_price(mode or self.connected_mode or "simulation", ths_code)
def get_tick_detail(self, ths_code: str, *, mode: str = "") -> dict[str, Any]:
return ctp_ipc_client.tick_detail(mode or self.connected_mode or "simulation", ths_code)
def estimate_margin_one_lot(
self,
ths_code: str,
price: float,
*,
direction: str = "long",
) -> Optional[float]:
return ctp_ipc_client.estimate_margin_one_lot(
self.connected_mode or "simulation",
ths_code,
price,
direction=direction,
)
def lookup_contract_spec(self, ths_code: str) -> Optional[dict]:
return ctp_ipc_client.contract_spec(self.connected_mode or "simulation", ths_code)
def send_order(self, **payload: Any) -> str:
data = ctp_ipc_client.send_order(payload)
return str(data.get("order_id") or "")
def cancel_order(self, vt_orderid: str) -> bool:
return ctp_ipc_client.cancel_order(self.connected_mode or "simulation", vt_orderid)
def calibrate_trading_state(self) -> Any:
return ctp_ipc_client.bridge_action("calibrate_trading_state").get("result")
def request_position_snapshot(self, *, force: bool = False) -> Any:
return ctp_ipc_client.bridge_action(
"request_position_snapshot",
{"force": bool(force)},
).get("result")
def subscribe_symbol(self, symbol: str) -> Any:
return ctp_ipc_client.bridge_action("subscribe_symbol", {"symbol": symbol}).get("result")
def refresh_positions(self) -> Any:
return ctp_ipc_client.bridge_action("refresh_positions").get("result")
def reconnect_after_settings_saved(self, mode: str) -> Any:
return ctp_ipc_client.bridge_action(
"reconnect_after_settings_saved",
{"mode": mode},
).get("result")
def query_all_commissions(self, *, mode: str = "") -> list[dict]:
data = ctp_ipc_client.bridge_action("query_all_commissions", {"mode": mode})
return list(data.get("result") or [])
def query_instrument_commission(self, symbol: str, *, mode: str = "") -> dict:
data = ctp_ipc_client.bridge_action(
"query_instrument_commission",
{"symbol": symbol, "mode": mode or self.connected_mode or "simulation"},
)
return dict(data.get("result") or {})
def get_kline_bars_1m(self, ths_code: str, *, mode: str) -> list[dict]:
data = ctp_ipc_client.bridge_action(
"get_kline_bars_1m",
{"symbol": ths_code, "mode": mode},
)
return list(data.get("result") or [])
def _close_gateway(self) -> None:
ctp_ipc_client.disconnect()
def get_bridge():
global _bridge
if _use_ctp_worker_client():
return CtpBridgeProxy()
with _bridge_lock:
if _bridge is None:
_bridge = CtpBridge()
@@ -2134,10 +2346,14 @@ def get_bridge() -> CtpBridge:
def try_init_vnpy(_settings: dict | None = None) -> bool:
if _use_ctp_worker_client():
return bool(ctp_ipc_client.health().get("worker_online"))
return get_bridge().available()
def vnpy_available() -> bool:
if _use_ctp_worker_client():
return bool(ctp_ipc_client.health().get("worker_online"))
return get_bridge().available()
@@ -2156,6 +2372,9 @@ def _ctp_connect_permitted(*, scheduled: bool = False) -> bool:
def ctp_disconnect(*, set_disabled_hint: bool = False) -> None:
"""主动断开 CTP 并清理内存状态。"""
if _use_ctp_worker_client():
ctp_ipc_client.disconnect(set_disabled_hint=set_disabled_hint)
return
from ctp_settings import CTP_DISABLED_HINT
b = get_bridge()
@@ -2169,6 +2388,8 @@ def ctp_disconnect(*, set_disabled_hint: bool = False) -> None:
def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
if _use_ctp_worker_client():
return ctp_ipc_client.connect(mode, force=force)
b = get_bridge()
b.connect(mode, force=force)
return b.status(mode)
@@ -2176,6 +2397,8 @@ def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
def ctp_start_connect(mode: str, *, force: bool = False, scheduled: bool = False) -> dict[str, Any]:
"""非阻塞发起连接,供 Web API 使用。"""
if _use_ctp_worker_client():
return ctp_ipc_client.start_connect(mode, force=force, scheduled=scheduled)
b = get_bridge()
info = b.start_connect_async(mode, force=force, scheduled=scheduled)
st = b.status(mode)
@@ -2184,6 +2407,13 @@ def ctp_start_connect(mode: str, *, force: bool = False, scheduled: bool = False
def ctp_try_auto_reconnect(mode: str) -> bool:
"""断线时静默异步重连;已连接且交易通道正常则不再重复 connect。"""
if _use_ctp_worker_client():
info = ctp_ipc_client.start_connect(mode, force=False, scheduled=True)
return bool(
info.get("connected")
or info.get("connecting")
or info.get("started")
)
if not _ctp_connect_permitted(scheduled=True):
return False
b = get_bridge()
@@ -2222,6 +2452,10 @@ def ctp_try_auto_reconnect(mode: str) -> bool:
def ctp_status(mode: str) -> dict[str, Any]:
from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
if _use_ctp_worker_client():
st = ctp_ipc_client.status(mode)
st["auto_connect_enabled"] = is_ctp_auto_connect_enabled()
return st
auto = is_ctp_auto_connect_enabled()
st = get_bridge().status(mode)
st["auto_connect_enabled"] = auto
@@ -2245,6 +2479,8 @@ def ctp_status(mode: str) -> dict[str, Any]:
def ctp_get_account(mode: str) -> dict[str, Any]:
if _use_ctp_worker_client():
return ctp_ipc_client.account(mode)
b = get_bridge()
b.ensure_connected(mode)
return b.get_account()
@@ -2269,6 +2505,18 @@ def ctp_sum_position_margins(
def ctp_account_margin_used(mode: str) -> Optional[float]:
"""账户实际占用保证金 ≈ 权益 − 可用(与顶栏柜台资金一致)。"""
if _use_ctp_worker_client():
try:
acc = ctp_ipc_client.account(mode)
balance = float(acc.get("balance") or 0)
available = float(acc.get("available") or 0)
if balance <= 0:
return None
used = balance - available
return round(used, 2) if used > 0 else None
except Exception as exc:
logger.debug("ctp_account_margin_used ipc: %s", exc)
return None
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return None
@@ -2291,6 +2539,12 @@ def ctp_list_positions(
refresh_if_empty: bool = True,
refresh_margin: bool = False,
) -> list[dict[str, Any]]:
if _use_ctp_worker_client():
return ctp_ipc_client.positions(
mode,
refresh_if_empty=refresh_if_empty,
refresh_margin=refresh_margin,
)
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return []
@@ -2298,18 +2552,24 @@ def ctp_list_positions(
def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]:
if _use_ctp_worker_client():
return ctp_ipc_client.active_orders(mode)
b = get_bridge()
b.ensure_connected(mode)
return b.list_active_orders()
def ctp_cancel_order(mode: str, vt_orderid: str) -> bool:
if _use_ctp_worker_client():
return ctp_ipc_client.cancel_order(mode, vt_orderid)
b = get_bridge()
b.ensure_connected(mode)
return b.cancel_order(vt_orderid)
def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
if _use_ctp_worker_client():
return ctp_ipc_client.trades(mode, refresh=refresh)
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return []
@@ -2318,6 +2578,8 @@ def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
"""CTP 柜台最新价(需已连接并订阅)。"""
if _use_ctp_worker_client():
return ctp_ipc_client.tick_price(mode, ths_code)
b = get_bridge()
if b.connected_mode != mode:
return None
@@ -2329,6 +2591,8 @@ def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]:
if _use_ctp_worker_client():
return ctp_ipc_client.tick_detail(mode, ths_code)
b = get_bridge()
if b.connected_mode != mode:
return {}
@@ -2346,6 +2610,13 @@ def ctp_estimate_margin_one_lot(
*,
direction: str = "long",
) -> Optional[float]:
if _use_ctp_worker_client():
return ctp_ipc_client.estimate_margin_one_lot(
mode,
ths_code,
price,
direction=direction,
)
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return None
@@ -2357,6 +2628,8 @@ def ctp_estimate_margin_one_lot(
def ctp_lookup_contract_spec(mode: str, ths_code: str) -> Optional[dict]:
if _use_ctp_worker_client():
return ctp_ipc_client.contract_spec(mode, ths_code)
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return None
@@ -2390,6 +2663,17 @@ def execute_order(
order_type: str = "limit",
) -> dict[str, Any]:
"""统一下单:simulation=SimNowlive=期货公司 CTP。"""
if _use_ctp_worker_client():
return ctp_ipc_client.send_order({
"mode": mode,
"offset": offset,
"symbol": symbol,
"direction": direction,
"lots": lots,
"price": price,
"settings": settings or {},
"order_type": order_type,
})
del conn, settings
if mode not in ("simulation", "live"):
raise ValueError("未知交易模式")