e5a586f903
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
6.4 KiB
Python
227 lines
6.4 KiB
Python
# 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,
|
|
)
|