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,
)