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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user