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,
|
||||||
|
)
|
||||||
+494
@@ -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()
|
||||||
@@ -175,6 +175,18 @@ def build_dashboard_payload(
|
|||||||
margin_used = round(max(0.0, equity - available), 2)
|
margin_used = round(max(0.0, equity - available), 2)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
key_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ module.exports = {
|
|||||||
LANG: "zh_CN.UTF-8",
|
LANG: "zh_CN.UTF-8",
|
||||||
LC_ALL: "zh_CN.UTF-8",
|
LC_ALL: "zh_CN.UTF-8",
|
||||||
LC_CTYPE: "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_STARTUP_WORKERS: "8",
|
||||||
QIHUO_MEMORY_MB: "8192",
|
QIHUO_MEMORY_MB: "8192",
|
||||||
},
|
},
|
||||||
@@ -31,5 +34,29 @@ module.exports = {
|
|||||||
out_file: path.join(ROOT, "logs", "pm2-out.log"),
|
out_file: path.join(ROOT, "logs", "pm2-out.log"),
|
||||||
time: true,
|
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
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -277,9 +277,11 @@ def should_keep_ctp_connected(
|
|||||||
minutes_before: int = 30,
|
minutes_before: int = 30,
|
||||||
minutes_after: int = 30,
|
minutes_after: int = 30,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""是否处于应连接 CTP 的窗口:交易时段 + 盘前 + 盘后宽限。"""
|
"""是否处于应连接 CTP 的窗口:交易时段 + 小节/午间休盘 + 盘前 + 盘后宽限。"""
|
||||||
if is_trading_session(now):
|
if is_trading_session(now):
|
||||||
return True
|
return True
|
||||||
|
if is_morning_break(now) or is_lunch_break(now):
|
||||||
|
return True
|
||||||
if in_postmarket_grace_window(now, minutes_after=minutes_after):
|
if in_postmarket_grace_window(now, minutes_after=minutes_after):
|
||||||
return True
|
return True
|
||||||
return in_premarket_connect_window(now, minutes_before=minutes_before)
|
return in_premarket_connect_window(now, minutes_before=minutes_before)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -60,13 +60,47 @@ MONITOR_ORDER_COLUMNS = (
|
|||||||
|
|
||||||
TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
|
TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
|
||||||
|
|
||||||
|
_MONITOR_COLUMNS_READY = False
|
||||||
|
_MONITOR_COLUMNS_LOCK = threading.Lock()
|
||||||
|
|
||||||
def ensure_monitor_order_columns(conn) -> None:
|
|
||||||
for sql in MONITOR_ORDER_COLUMNS:
|
def _monitor_columns_exist(conn) -> bool:
|
||||||
try:
|
try:
|
||||||
conn.execute(sql)
|
rows = conn.execute("PRAGMA table_info(trade_order_monitors)").fetchall()
|
||||||
except Exception:
|
cols = set()
|
||||||
pass
|
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:
|
def _tick_size(ths_code: str) -> float:
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
var breakEl = document.getElementById('roll-break-price');
|
var breakEl = document.getElementById('roll-break-price');
|
||||||
var execHint = document.getElementById('roll-exec-hint');
|
var execHint = document.getElementById('roll-exec-hint');
|
||||||
var btnExec = document.getElementById('btn-roll-exec');
|
var btnExec = document.getElementById('btn-roll-exec');
|
||||||
|
var btnPreview = document.getElementById('btn-roll-preview');
|
||||||
if (!modeEl) return;
|
if (!modeEl) return;
|
||||||
var mode = modeEl.value || 'market';
|
var mode = modeEl.value || 'market';
|
||||||
var isBreak = mode === 'breakout';
|
var isBreak = mode === 'breakout';
|
||||||
@@ -120,6 +121,12 @@
|
|||||||
if (btnExec) {
|
if (btnExec) {
|
||||||
btnExec.textContent = mode === 'market' ? '执行滚仓' : '提交监控';
|
btnExec.textContent = mode === 'market' ? '执行滚仓' : '提交监控';
|
||||||
}
|
}
|
||||||
|
if (btnPreview) {
|
||||||
|
btnPreview.disabled = !inTradingSession && !isBreak;
|
||||||
|
btnPreview.title = (!inTradingSession && !isBreak)
|
||||||
|
? '休盘期间请切换为突破加仓'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRollRiskHint() {
|
function syncRollRiskHint() {
|
||||||
@@ -196,6 +203,7 @@
|
|||||||
var rollPayload = null;
|
var rollPayload = null;
|
||||||
var rollMonitorSel = document.getElementById('roll-monitor-select');
|
var rollMonitorSel = document.getElementById('roll-monitor-select');
|
||||||
var rollModeSel = document.getElementById('roll-add-mode');
|
var rollModeSel = document.getElementById('roll-add-mode');
|
||||||
|
var inTradingSession = {{ 'true' if trading_session else 'false' }};
|
||||||
|
|
||||||
if (rollModeSel) rollModeSel.addEventListener('change', syncRollModeUi);
|
if (rollModeSel) rollModeSel.addEventListener('change', syncRollModeUi);
|
||||||
if (rollMonitorSel) rollMonitorSel.addEventListener('change', syncRollRiskHint);
|
if (rollMonitorSel) rollMonitorSel.addEventListener('change', syncRollRiskHint);
|
||||||
@@ -214,6 +222,7 @@
|
|||||||
}
|
}
|
||||||
showPreview(rollPrev, formatRoll(d.preview), true, false);
|
showPreview(rollPrev, formatRoll(d.preview), true, false);
|
||||||
btnRollE.hidden = false;
|
btnRollE.hidden = false;
|
||||||
|
syncRollModeUi();
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
btnRollP.disabled = false;
|
btnRollP.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -224,6 +233,10 @@
|
|||||||
var payload = rollPayload || formData(rollForm);
|
var payload = rollPayload || formData(rollForm);
|
||||||
var mode = (payload.add_mode || 'market');
|
var mode = (payload.add_mode || 'market');
|
||||||
if (mode === 'market') {
|
if (mode === 'market') {
|
||||||
|
if (!inTradingSession) {
|
||||||
|
alert('休盘期间请切换为「突破加仓」后提交监控');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirm('确认执行市价滚仓?')) return;
|
if (!confirm('确认执行市价滚仓?')) return;
|
||||||
startRollCountdown(btnRollE, payload);
|
startRollCountdown(btnRollE, payload);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+61
-17
@@ -34,6 +34,8 @@
|
|||||||
var ctpConnecting = false;
|
var ctpConnecting = false;
|
||||||
var ctpAutoConnectEnabled = true;
|
var ctpAutoConnectEnabled = true;
|
||||||
var positionsRendered = false;
|
var positionsRendered = false;
|
||||||
|
var posFastPollTimer = null;
|
||||||
|
var posFastPollCount = 0;
|
||||||
var lastPosRowCount = 0;
|
var lastPosRowCount = 0;
|
||||||
var selectedMaxLots = null;
|
var selectedMaxLots = null;
|
||||||
var recommendMaxByProduct = {};
|
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) {
|
function applyPositionsData(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
var cap = document.getElementById('cap-display');
|
var cap = document.getElementById('cap-display');
|
||||||
@@ -312,6 +332,7 @@
|
|||||||
if (!connected) {
|
if (!connected) {
|
||||||
if (connecting) {
|
if (connecting) {
|
||||||
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
|
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
|
||||||
|
startPosFastPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) {
|
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>';
|
list.innerHTML = '<div class="empty-hint text-muted">' + offHint + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
|
list.innerHTML = '<div class="empty-hint">CTP 未连接,后台自动连接中…</div>';
|
||||||
if (ctpAutoConnectEnabled) tryAutoCtpReconnect();
|
if (ctpAutoConnectEnabled) refreshCtpStatusPassive();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var syncing = data.sync_state === 'syncing';
|
var syncing = data.sync_state === 'syncing';
|
||||||
@@ -339,6 +360,7 @@
|
|||||||
syncBadge.textContent = data.sync_label || '持仓同步中…';
|
syncBadge.textContent = data.sync_label || '持仓同步中…';
|
||||||
syncBadge.className = 'sync-badge text-accent';
|
syncBadge.className = 'sync-badge text-accent';
|
||||||
}
|
}
|
||||||
|
startPosFastPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
|
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
|
||||||
@@ -347,9 +369,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastPosRowCount = rows.length;
|
lastPosRowCount = rows.length;
|
||||||
if (!connected && ctpAutoConnectEnabled) {
|
stopPosFastPoll();
|
||||||
tryAutoCtpReconnect();
|
|
||||||
}
|
|
||||||
list.innerHTML = rows.map(buildPosCard).join('');
|
list.innerHTML = rows.map(buildPosCard).join('');
|
||||||
syncPositionListScroll(rows.length);
|
syncPositionListScroll(rows.length);
|
||||||
bindPendingDismiss(list);
|
bindPendingDismiss(list);
|
||||||
@@ -610,8 +630,9 @@
|
|||||||
}
|
}
|
||||||
if (st.connecting && Date.now() < deadline) {
|
if (st.connecting && Date.now() < deadline) {
|
||||||
syncCtpBadgeFromStatus(st);
|
syncCtpBadgeFromStatus(st);
|
||||||
|
pollPositions();
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
setTimeout(function () { resolve(tick()); }, 2000);
|
setTimeout(function () { resolve(tick()); }, 800);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncCtpBadgeFromStatus(st);
|
syncCtpBadgeFromStatus(st);
|
||||||
@@ -795,18 +816,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryAutoCtpReconnect() {
|
/** 只读 CTP 状态;连接由 qihuo-ctp 后台 worker 负责,前端不发起 connect。 */
|
||||||
if (!ctpAutoConnectEnabled) return;
|
function refreshCtpStatusPassive() {
|
||||||
if (ctpReconnecting || ctpConnectInflight) return;
|
if (ctpConnected || ctpConnecting) return;
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
if (now - lastCtpReconnectAt < 60000) return;
|
if (now - lastCtpReconnectAt < 8000) return;
|
||||||
if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return;
|
|
||||||
if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) return;
|
|
||||||
lastCtpReconnectAt = now;
|
lastCtpReconnectAt = now;
|
||||||
ctpReconnecting = true;
|
fetch('/api/ctp/status')
|
||||||
requestCtpConnect(false).finally(function () {
|
.then(function (r) { return r.json(); })
|
||||||
ctpReconnecting = false;
|
.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) {
|
function showOrderMsg(text, ok) {
|
||||||
@@ -1911,12 +1945,22 @@
|
|||||||
} else if (st.last_error) {
|
} else if (st.last_error) {
|
||||||
showCtpError(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 () {});
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupTradePage() {
|
function cleanupTradePage() {
|
||||||
|
stopPosFastPoll();
|
||||||
if (sessionClockTickTimer) {
|
if (sessionClockTickTimer) {
|
||||||
clearInterval(sessionClockTickTimer);
|
clearInterval(sessionClockTickTimer);
|
||||||
sessionClockTickTimer = null;
|
sessionClockTickTimer = null;
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ def validate_roll_geometry(
|
|||||||
limit_price: Optional[float] = None,
|
limit_price: Optional[float] = None,
|
||||||
breakthrough_price: Optional[float] = None,
|
breakthrough_price: Optional[float] = None,
|
||||||
at_trigger: bool = False,
|
at_trigger: bool = False,
|
||||||
|
off_session_pending: bool = False,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""几何校验。
|
"""几何校验。
|
||||||
|
|
||||||
@@ -206,6 +207,12 @@ def validate_roll_geometry(
|
|||||||
trigger = float(breakthrough_price or 0)
|
trigger = float(breakthrough_price or 0)
|
||||||
if trigger <= 0:
|
if trigger <= 0:
|
||||||
return "须填写突破价"
|
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 at_trigger:
|
||||||
if direction == "long":
|
if direction == "long":
|
||||||
if not (sl < trigger <= mark):
|
if not (sl < trigger <= mark):
|
||||||
@@ -269,12 +276,15 @@ def preview_roll(
|
|||||||
fib_lower: Optional[float] = None,
|
fib_lower: Optional[float] = None,
|
||||||
legs_done: int = 0,
|
legs_done: int = 0,
|
||||||
at_trigger: bool = False,
|
at_trigger: bool = False,
|
||||||
|
off_session_pending: bool = False,
|
||||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
direction = (direction or "long").strip().lower()
|
direction = (direction or "long").strip().lower()
|
||||||
if legs_done >= max_roll_legs(direction):
|
if legs_done >= max_roll_legs(direction):
|
||||||
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
||||||
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
|
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
|
||||||
mark = float(mark_price or add_price or 0)
|
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:
|
if mark <= 0:
|
||||||
return None, "需要有效参考价"
|
return None, "需要有效参考价"
|
||||||
sl = float(new_stop_loss)
|
sl = float(new_stop_loss)
|
||||||
@@ -314,6 +324,7 @@ def preview_roll(
|
|||||||
limit_price=trigger_price if mode in FIB_MODES else None,
|
limit_price=trigger_price if mode in FIB_MODES else None,
|
||||||
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
|
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
|
||||||
at_trigger=at_trigger,
|
at_trigger=at_trigger,
|
||||||
|
off_session_pending=off_session_pending and is_pending,
|
||||||
)
|
)
|
||||||
if geom_err:
|
if geom_err:
|
||||||
return None, geom_err
|
return None, geom_err
|
||||||
|
|||||||
+10
-4
@@ -136,7 +136,10 @@
|
|||||||
<button type="button" class="btn-primary" id="btn-roll-exec" hidden {% if not roll_allowed %}disabled{% endif %}>执行滚仓</button>
|
<button type="button" class="btn-primary" id="btn-roll-exec" hidden {% if not roll_allowed %}disabled{% endif %}>执行滚仓</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="roll-preview" class="strategy-preview" hidden></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>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-hint">暂无可用持仓监控</p>
|
<p class="empty-hint">暂无可用持仓监控</p>
|
||||||
@@ -151,7 +154,7 @@
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="strategy-preview-table">
|
<table class="strategy-preview-table">
|
||||||
<thead><tr>
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for g in roll_groups %}
|
{% for g in roll_groups %}
|
||||||
@@ -160,6 +163,8 @@
|
|||||||
<td>{{ g.symbol_name or g.symbol }}</td>
|
<td>{{ g.symbol_name or g.symbol }}</td>
|
||||||
<td>{{ '多' if g.direction == 'long' else '空' }}</td>
|
<td>{{ '多' if g.direction == 'long' else '空' }}</td>
|
||||||
<td>{{ g.leg_count or 0 }}/3</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.initial_take_profit or '—' }}</td>
|
||||||
<td>{{ g.current_stop_loss or '—' }}</td>
|
<td>{{ g.current_stop_loss or '—' }}</td>
|
||||||
<td>{{ g.avg_entry or '—' }}</td>
|
<td>{{ g.avg_entry or '—' }}</td>
|
||||||
@@ -172,12 +177,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="hint text-muted">暂无</p>
|
<p class="hint text-muted">暂无</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">最近滚仓腿</h3>
|
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">正在滚仓</h3>
|
||||||
{% if roll_legs %}
|
{% if roll_legs %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="strategy-preview-table">
|
<table class="strategy-preview-table">
|
||||||
<thead><tr>
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for l in roll_legs %}
|
{% for l in roll_legs %}
|
||||||
@@ -188,6 +193,7 @@
|
|||||||
<td>{{ l.lots or '—' }}</td>
|
<td>{{ l.lots or '—' }}</td>
|
||||||
<td>{{ l.breakthrough_price or l.limit_price or l.fill_price 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.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 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>
|
<td>{% if l.status == 'pending' %}<button type="button" class="btn-link roll-cancel-leg" data-leg-id="{{ l.id }}">删除</button>{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}策略记录 - 国内期货 · 交易复盘系统{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="split-grid">
|
<div class="split-grid">
|
||||||
<div class="card card-scroll">
|
<div class="card card-scroll">
|
||||||
@@ -15,7 +24,33 @@
|
|||||||
<h2>顺势加仓</h2>
|
<h2>顺势加仓</h2>
|
||||||
{% if roll_rows %}
|
{% if roll_rows %}
|
||||||
<ul class="list">{% for r in 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>
|
{% endfor %}</ul>
|
||||||
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+44
-1
@@ -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
|
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:
|
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
||||||
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
|
"""优先 SimNow/期货公司 CTP 权益;未连接时用最近快照或设置中的参考资金。"""
|
||||||
del conn
|
del conn
|
||||||
mode = get_trading_mode(get_setting)
|
mode = get_trading_mode(get_setting)
|
||||||
try:
|
try:
|
||||||
@@ -93,6 +132,10 @@ def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
|||||||
return float(bal)
|
return float(bal)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
cached = _cached_ctp_account(mode)
|
||||||
|
balance = float(cached.get("balance") or 0)
|
||||||
|
if balance > 0:
|
||||||
|
return balance
|
||||||
try:
|
try:
|
||||||
return float(get_setting("live_capital", "0") or 0)
|
return float(get_setting("live_capital", "0") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|||||||
+290
-6
@@ -14,9 +14,11 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import ctp_ipc_client
|
||||||
from locale_fix import ensure_process_locale
|
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_settings import live_setting_dict, simnow_setting_dict
|
||||||
from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange
|
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"
|
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:
|
def _persist_login_cooldown(seconds: float) -> None:
|
||||||
from fee_specs import get_setting, set_setting
|
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:
|
def _fire_position_refresh_burst() -> None:
|
||||||
"""连接后持仓回报可能分批到达,分多次触发快照刷新。"""
|
"""连接后持仓回报可能分批到达,分多次触发快照刷新。"""
|
||||||
_fire_position_refresh_callback()
|
_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()
|
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()
|
bridge._ensure_instrument_margin_hooks()
|
||||||
with _ctp_td_lock:
|
with _ctp_td_lock:
|
||||||
bridge.request_position_snapshot(force=True)
|
bridge.request_position_snapshot(force=True)
|
||||||
time.sleep(2.0)
|
time.sleep(0.8)
|
||||||
with _ctp_td_lock:
|
with _ctp_td_lock:
|
||||||
bridge.calibrate_trading_state()
|
bridge.calibrate_trading_state()
|
||||||
_fire_position_refresh_callback()
|
_fire_position_refresh_callback()
|
||||||
|
_fire_position_refresh_burst()
|
||||||
n = len(bridge._collect_positions())
|
n = len(bridge._collect_positions())
|
||||||
logger.info("CTP 合约加载完成,持仓 %s 条,已刷新快照", n)
|
logger.info("CTP 合约加载完成,持仓 %s 条,已刷新快照", n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -217,7 +224,7 @@ _bridge: Optional["CtpBridge"] = None
|
|||||||
_bridge_lock = threading.Lock()
|
_bridge_lock = threading.Lock()
|
||||||
_ctp_td_lock = threading.RLock()
|
_ctp_td_lock = threading.RLock()
|
||||||
POSITION_QUERY_MIN_INTERVAL_SEC = 5.0
|
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
|
TRADE_QUERY_MIN_INTERVAL_SEC = 10.0
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +344,7 @@ class CtpBridge:
|
|||||||
self._trade_query_event = threading.Event()
|
self._trade_query_event = threading.Event()
|
||||||
self._last_trade_query_ts: float = 0.0
|
self._last_trade_query_ts: float = 0.0
|
||||||
self._last_connect_ok_ts: float = 0.0
|
self._last_connect_ok_ts: float = 0.0
|
||||||
|
self._connect_started_ts: float = 0.0
|
||||||
self._tick_hooked = False
|
self._tick_hooked = False
|
||||||
self._position_hooked = False
|
self._position_hooked = False
|
||||||
self._order_hooked = False
|
self._order_hooked = False
|
||||||
@@ -704,6 +712,16 @@ class CtpBridge:
|
|||||||
cooldown = self.login_cooldown_remaining()
|
cooldown = self.login_cooldown_remaining()
|
||||||
connecting = bool(self._connect_in_progress and cooldown <= 0)
|
connecting = bool(self._connect_in_progress and cooldown <= 0)
|
||||||
last_error = self._last_error or _load_persisted_last_error()
|
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 {
|
return {
|
||||||
"vnpy_installed": self.available(),
|
"vnpy_installed": self.available(),
|
||||||
"connected": self._connected_mode == mode,
|
"connected": self._connected_mode == mode,
|
||||||
@@ -746,6 +764,7 @@ class CtpBridge:
|
|||||||
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
|
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
|
||||||
|
|
||||||
self._connect_in_progress = True
|
self._connect_in_progress = True
|
||||||
|
self._connect_started_ts = time.time()
|
||||||
try:
|
try:
|
||||||
with _ctp_td_lock:
|
with _ctp_td_lock:
|
||||||
with self._connect_lock:
|
with self._connect_lock:
|
||||||
@@ -806,6 +825,10 @@ class CtpBridge:
|
|||||||
self.calibrate_trading_state()
|
self.calibrate_trading_state()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("post-connect calibrate: %s", 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()
|
self._ensure_instrument_margin_hooks()
|
||||||
_fire_position_refresh_burst()
|
_fire_position_refresh_burst()
|
||||||
_schedule_position_query_retries(self)
|
_schedule_position_query_retries(self)
|
||||||
@@ -823,6 +846,7 @@ class CtpBridge:
|
|||||||
raise RuntimeError(hint)
|
raise RuntimeError(hint)
|
||||||
finally:
|
finally:
|
||||||
self._connect_in_progress = False
|
self._connect_in_progress = False
|
||||||
|
self._connect_started_ts = 0.0
|
||||||
|
|
||||||
def start_connect_async(
|
def start_connect_async(
|
||||||
self, mode: str, *, force: bool = False, scheduled: bool = False,
|
self, mode: str, *, force: bool = False, scheduled: bool = False,
|
||||||
@@ -859,13 +883,39 @@ class CtpBridge:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("CTP 后台连接失败: %s", 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=_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}
|
return {"started": True, "connecting": True, "connected": False}
|
||||||
|
|
||||||
def ensure_connected(self, mode: str) -> None:
|
def ensure_connected(self, mode: str) -> None:
|
||||||
if self._connected_mode == mode and self.ping():
|
if self._connected_mode == mode and self.ping():
|
||||||
return
|
return
|
||||||
self.connect(mode)
|
if self._connect_in_progress:
|
||||||
|
raise RuntimeError("CTP 连接中,请稍候")
|
||||||
|
raise RuntimeError("请先连接 CTP")
|
||||||
|
|
||||||
def require_connected(self, mode: str) -> None:
|
def require_connected(self, mode: str) -> None:
|
||||||
"""报单前检查:须已连接,不在此发起阻塞式 connect。"""
|
"""报单前检查:须已连接,不在此发起阻塞式 connect。"""
|
||||||
@@ -2125,8 +2175,170 @@ class CtpBridge:
|
|||||||
return False
|
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
|
global _bridge
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return CtpBridgeProxy()
|
||||||
with _bridge_lock:
|
with _bridge_lock:
|
||||||
if _bridge is None:
|
if _bridge is None:
|
||||||
_bridge = CtpBridge()
|
_bridge = CtpBridge()
|
||||||
@@ -2134,10 +2346,14 @@ def get_bridge() -> CtpBridge:
|
|||||||
|
|
||||||
|
|
||||||
def try_init_vnpy(_settings: dict | None = None) -> bool:
|
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()
|
return get_bridge().available()
|
||||||
|
|
||||||
|
|
||||||
def vnpy_available() -> bool:
|
def vnpy_available() -> bool:
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return bool(ctp_ipc_client.health().get("worker_online"))
|
||||||
return get_bridge().available()
|
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:
|
def ctp_disconnect(*, set_disabled_hint: bool = False) -> None:
|
||||||
"""主动断开 CTP 并清理内存状态。"""
|
"""主动断开 CTP 并清理内存状态。"""
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
ctp_ipc_client.disconnect(set_disabled_hint=set_disabled_hint)
|
||||||
|
return
|
||||||
from ctp_settings import CTP_DISABLED_HINT
|
from ctp_settings import CTP_DISABLED_HINT
|
||||||
|
|
||||||
b = get_bridge()
|
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]:
|
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 = get_bridge()
|
||||||
b.connect(mode, force=force)
|
b.connect(mode, force=force)
|
||||||
return b.status(mode)
|
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]:
|
def ctp_start_connect(mode: str, *, force: bool = False, scheduled: bool = False) -> dict[str, Any]:
|
||||||
"""非阻塞发起连接,供 Web API 使用。"""
|
"""非阻塞发起连接,供 Web API 使用。"""
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return ctp_ipc_client.start_connect(mode, force=force, scheduled=scheduled)
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
info = b.start_connect_async(mode, force=force, scheduled=scheduled)
|
info = b.start_connect_async(mode, force=force, scheduled=scheduled)
|
||||||
st = b.status(mode)
|
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:
|
def ctp_try_auto_reconnect(mode: str) -> bool:
|
||||||
"""断线时静默异步重连;已连接且交易通道正常则不再重复 connect。"""
|
"""断线时静默异步重连;已连接且交易通道正常则不再重复 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):
|
if not _ctp_connect_permitted(scheduled=True):
|
||||||
return False
|
return False
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
@@ -2222,6 +2452,10 @@ def ctp_try_auto_reconnect(mode: str) -> bool:
|
|||||||
def ctp_status(mode: str) -> dict[str, Any]:
|
def ctp_status(mode: str) -> dict[str, Any]:
|
||||||
from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
|
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()
|
auto = is_ctp_auto_connect_enabled()
|
||||||
st = get_bridge().status(mode)
|
st = get_bridge().status(mode)
|
||||||
st["auto_connect_enabled"] = auto
|
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]:
|
def ctp_get_account(mode: str) -> dict[str, Any]:
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return ctp_ipc_client.account(mode)
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
b.ensure_connected(mode)
|
b.ensure_connected(mode)
|
||||||
return b.get_account()
|
return b.get_account()
|
||||||
@@ -2269,6 +2505,18 @@ def ctp_sum_position_margins(
|
|||||||
|
|
||||||
def ctp_account_margin_used(mode: str) -> Optional[float]:
|
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()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode or not b.ping():
|
if b.connected_mode != mode or not b.ping():
|
||||||
return None
|
return None
|
||||||
@@ -2291,6 +2539,12 @@ def ctp_list_positions(
|
|||||||
refresh_if_empty: bool = True,
|
refresh_if_empty: bool = True,
|
||||||
refresh_margin: bool = False,
|
refresh_margin: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode or not b.ping():
|
if b.connected_mode != mode or not b.ping():
|
||||||
return []
|
return []
|
||||||
@@ -2298,18 +2552,24 @@ def ctp_list_positions(
|
|||||||
|
|
||||||
|
|
||||||
def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]:
|
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 = get_bridge()
|
||||||
b.ensure_connected(mode)
|
b.ensure_connected(mode)
|
||||||
return b.list_active_orders()
|
return b.list_active_orders()
|
||||||
|
|
||||||
|
|
||||||
def ctp_cancel_order(mode: str, vt_orderid: str) -> bool:
|
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 = get_bridge()
|
||||||
b.ensure_connected(mode)
|
b.ensure_connected(mode)
|
||||||
return b.cancel_order(vt_orderid)
|
return b.cancel_order(vt_orderid)
|
||||||
|
|
||||||
|
|
||||||
def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
|
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()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode or not b.ping():
|
if b.connected_mode != mode or not b.ping():
|
||||||
return []
|
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]:
|
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
|
||||||
"""CTP 柜台最新价(需已连接并订阅)。"""
|
"""CTP 柜台最新价(需已连接并订阅)。"""
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return ctp_ipc_client.tick_price(mode, ths_code)
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode:
|
if b.connected_mode != mode:
|
||||||
return None
|
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]:
|
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()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode:
|
if b.connected_mode != mode:
|
||||||
return {}
|
return {}
|
||||||
@@ -2346,6 +2610,13 @@ def ctp_estimate_margin_one_lot(
|
|||||||
*,
|
*,
|
||||||
direction: str = "long",
|
direction: str = "long",
|
||||||
) -> Optional[float]:
|
) -> Optional[float]:
|
||||||
|
if _use_ctp_worker_client():
|
||||||
|
return ctp_ipc_client.estimate_margin_one_lot(
|
||||||
|
mode,
|
||||||
|
ths_code,
|
||||||
|
price,
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode or not b.ping():
|
if b.connected_mode != mode or not b.ping():
|
||||||
return None
|
return None
|
||||||
@@ -2357,6 +2628,8 @@ def ctp_estimate_margin_one_lot(
|
|||||||
|
|
||||||
|
|
||||||
def ctp_lookup_contract_spec(mode: str, ths_code: str) -> Optional[dict]:
|
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()
|
b = get_bridge()
|
||||||
if b.connected_mode != mode or not b.ping():
|
if b.connected_mode != mode or not b.ping():
|
||||||
return None
|
return None
|
||||||
@@ -2390,6 +2663,17 @@ def execute_order(
|
|||||||
order_type: str = "limit",
|
order_type: str = "limit",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""统一下单:simulation=SimNow,live=期货公司 CTP。"""
|
"""统一下单:simulation=SimNow,live=期货公司 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
|
del conn, settings
|
||||||
if mode not in ("simulation", "live"):
|
if mode not in ("simulation", "live"):
|
||||||
raise ValueError("未知交易模式")
|
raise ValueError("未知交易模式")
|
||||||
|
|||||||
Reference in New Issue
Block a user