Compare commits
28 Commits
02ca76ea7c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8a775b98 | |||
| 96c78b2ed1 | |||
| e2a28e6c94 | |||
| 18d195849c | |||
| 3130a12825 | |||
| ab47fb6142 | |||
| b81b999090 | |||
| 1c3ea77db8 | |||
| 88337ecdaf | |||
| f2605b1e8f | |||
| c7edf0114b | |||
| 5ffcee3f56 | |||
| 1c68493aa0 | |||
| 05661f3f30 | |||
| 0e3fffef44 | |||
| 1735c031ea | |||
| 85299c7b33 | |||
| fa395cfa35 | |||
| 4ecabd414c | |||
| da1bae8751 | |||
| e2a61a202e | |||
| 747ec62e34 | |||
| a835e3aeec | |||
| cf347e7b6b | |||
| 2dcb191c2f | |||
| 7fd1e7aefd | |||
| 0327f49f4e | |||
| 470c6ba261 |
@@ -7,6 +7,12 @@ FLASK_SECRET_KEY=change-me-to-long-random
|
||||
DB_PATH=hub.db
|
||||
HUB_PULL_INTERVAL_SEC=10
|
||||
HUB_PULL_TIMEOUT_SEC=5
|
||||
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
|
||||
HUB_SSE_HEARTBEAT_SEC=25
|
||||
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
|
||||
HUB_STATS_POLL_INTERVAL_SEC=15
|
||||
# 条件单列表后台并行拉取间隔(秒)
|
||||
HUB_COND_POLL_INTERVAL_SEC=10
|
||||
|
||||
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
||||
HUB_WECHAT_ENABLED=false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Copyright (c) 2026 马建军. All rights reserved.
|
||||
|
||||
本仓库源代码及相关文档的著作权归马建军所有。
|
||||
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
|
||||
|
||||
Author: 马建军
|
||||
QQ: 253921841
|
||||
Email: 253921841@qq.com
|
||||
Website: https://hyf2.cc
|
||||
@@ -1,10 +1,11 @@
|
||||
# Copyright (c) 2026 马建军. All rights reserved.
|
||||
# Author: 马建军 | QQ: 253921841 | Email: 253921841@qq.com | Website: https://hyf2.cc
|
||||
# See COPYRIGHT in the repository root.
|
||||
"""crypto_okx_hub — 期权多机中控."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,6 +20,7 @@ from flask import (
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
@@ -44,13 +46,13 @@ from lib.bridge_client_lib import (
|
||||
fetch_backup_file,
|
||||
fetch_backup_list,
|
||||
fetch_client_version,
|
||||
fetch_cond_orders,
|
||||
post_client_update,
|
||||
post_close_full,
|
||||
post_cond_cancel,
|
||||
post_cond_exit,
|
||||
pull_all_enabled,
|
||||
)
|
||||
from lib.cond_orders_cache_lib import cond_orders_store
|
||||
from lib.cash_sample_lib import record_cash_samples_from_overview
|
||||
from lib.db_lib import db_path as hub_db_path, init_db
|
||||
from lib.env_lib import apply_env_updates, env_get
|
||||
@@ -62,8 +64,10 @@ from lib.instances_lib import (
|
||||
list_instances,
|
||||
update_instance,
|
||||
)
|
||||
from lib.overview_cache_lib import overview_store
|
||||
from lib.sso_lib import build_embed_open_url
|
||||
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
|
||||
from lib.stats_agg_lib import normalize_window
|
||||
from lib.stats_cache_lib import stats_store
|
||||
from lib.wechat_lib import (
|
||||
hub_alert_cooldown_sec,
|
||||
hub_health_fail_threshold,
|
||||
@@ -157,21 +161,43 @@ def stats_page():
|
||||
@app.route("/api/stats/summary")
|
||||
@login_required
|
||||
def api_stats_summary():
|
||||
"""只读统计热缓存;后台常驻刷新各窗口."""
|
||||
window = normalize_window(request.args.get("window") or "7d")
|
||||
try:
|
||||
return jsonify(build_stats_summary(window))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
if force:
|
||||
stats_store.request_refresh()
|
||||
return jsonify(stats_store.summary_dict(window))
|
||||
|
||||
|
||||
@app.route("/api/stats/curve")
|
||||
@login_required
|
||||
def api_stats_curve():
|
||||
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
|
||||
try:
|
||||
return jsonify(build_stats_curve(range_key))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
if force:
|
||||
stats_store.request_refresh()
|
||||
return jsonify(stats_store.curve_dict(range_key))
|
||||
|
||||
|
||||
@app.route("/api/stats/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_stats_refresh():
|
||||
stats_store.request_refresh()
|
||||
return jsonify({"ok": True, "msg": "已触发统计后台刷新"})
|
||||
|
||||
|
||||
@app.route("/api/stats/stream")
|
||||
@login_required
|
||||
def api_stats_stream():
|
||||
return Response(
|
||||
stream_with_context(stats_store.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/instances", methods=["GET", "POST"])
|
||||
@@ -362,6 +388,8 @@ def api_hub_backup_create():
|
||||
env_path=str(ENV_PATH),
|
||||
db_path=_resolved_hub_db_path(),
|
||||
label="manual",
|
||||
username=hub_username(),
|
||||
password=hub_password(),
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
@@ -395,6 +423,12 @@ def api_hub_backup_restore():
|
||||
env_path=str(ENV_PATH),
|
||||
db_path=_resolved_hub_db_path(),
|
||||
)
|
||||
if result.get("ok"):
|
||||
u = str(result.get("hub_username") or "").strip()
|
||||
p = result.get("hub_password")
|
||||
if u or p is not None:
|
||||
set_hub_credentials(u or hub_username(), "" if p is None else str(p))
|
||||
result.pop("hub_password", None)
|
||||
return jsonify(result), (200 if result.get("ok") else 400)
|
||||
|
||||
|
||||
@@ -478,38 +512,64 @@ def desk():
|
||||
)
|
||||
|
||||
|
||||
def _on_overview_pulled(rows: list) -> None:
|
||||
"""常驻 pull 回调:健康巡检 + 余额采样(与浏览器是否打开无关)."""
|
||||
try:
|
||||
inspect_overview_rows(rows)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
record_cash_samples_from_overview(rows)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/api/overview")
|
||||
@login_required
|
||||
def api_overview():
|
||||
rows = pull_all_enabled()
|
||||
return jsonify({"ok": True, "instances": rows})
|
||||
"""只读热缓存;不在请求内现场拉实例(后台常驻并行 pull)."""
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
if force:
|
||||
overview_store.request_refresh()
|
||||
return jsonify(overview_store.snapshot_dict())
|
||||
|
||||
|
||||
def _health_pull_interval_sec() -> float:
|
||||
try:
|
||||
return max(5.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
|
||||
except (TypeError, ValueError):
|
||||
return 10.0
|
||||
@app.route("/api/overview/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_overview_refresh():
|
||||
overview_store.request_refresh()
|
||||
return jsonify({"ok": True, "msg": "已触发后台刷新"})
|
||||
|
||||
|
||||
def _health_watch_loop() -> None:
|
||||
"""后台巡检:即使无人打开总览也会告警;并采样余额曲线."""
|
||||
while True:
|
||||
try:
|
||||
rows = pull_all_enabled()
|
||||
inspect_overview_rows(rows)
|
||||
record_cash_samples_from_overview(rows)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(_health_pull_interval_sec())
|
||||
@app.route("/api/overview/stream")
|
||||
@login_required
|
||||
def api_overview_stream():
|
||||
return Response(
|
||||
stream_with_context(overview_store.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def start_health_watch() -> None:
|
||||
t = threading.Thread(target=_health_watch_loop, name="hub-health-watch", daemon=True)
|
||||
t.start()
|
||||
def start_overview_cache() -> None:
|
||||
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
|
||||
|
||||
|
||||
start_health_watch()
|
||||
def start_stats_cache() -> None:
|
||||
stats_store.start()
|
||||
|
||||
|
||||
def start_cond_orders_cache() -> None:
|
||||
cond_orders_store.start()
|
||||
|
||||
|
||||
start_overview_cache()
|
||||
start_stats_cache()
|
||||
start_cond_orders_cache()
|
||||
|
||||
try:
|
||||
start_hub_backup_scheduler(
|
||||
@@ -517,6 +577,7 @@ try:
|
||||
env_path=str(ENV_PATH),
|
||||
db_path_fn=_resolved_hub_db_path,
|
||||
hour=3,
|
||||
get_credentials=lambda: (hub_username(), hub_password()),
|
||||
)
|
||||
except Exception as _hub_bak_err:
|
||||
print(f"[backup] scheduler: {_hub_bak_err}")
|
||||
@@ -528,13 +589,52 @@ def api_open_url(iid: int):
|
||||
inst = get_instance(iid)
|
||||
if not inst:
|
||||
return jsonify({"ok": False, "msg": "不存在"}), 404
|
||||
tab = (request.args.get("tab") or "").strip() or None
|
||||
url = build_embed_open_url(
|
||||
inst["base_url"],
|
||||
bridge_token=inst["bridge_token"],
|
||||
embed_path=inst.get("embed_path") or "/embed",
|
||||
ttl_sec=3600,
|
||||
tab=tab,
|
||||
)
|
||||
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]})
|
||||
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"], "tab": tab})
|
||||
|
||||
|
||||
@app.route("/api/instances/<int:iid>/transfer", methods=["POST"])
|
||||
@login_required
|
||||
def api_instance_transfer(iid: int):
|
||||
"""条件单页本页划转:中控发指令,实例执行资金户↔交易户."""
|
||||
from lib.bridge_client_lib import post_instance_transfer
|
||||
|
||||
inst = get_instance(iid)
|
||||
if not inst:
|
||||
return jsonify({"ok": False, "msg": "不存在"}), 404
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
amount = float(data.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "数量无效"}), 400
|
||||
if amount <= 0:
|
||||
return jsonify({"ok": False, "msg": "数量须大于 0"}), 400
|
||||
from_acct = (data.get("from") or "funding").strip() or "funding"
|
||||
to_acct = (data.get("to") or "trading").strip() or "trading"
|
||||
ccy = (data.get("ccy") or "USDT").strip().upper() or "USDT"
|
||||
out = post_instance_transfer(
|
||||
inst,
|
||||
amount=amount,
|
||||
from_acct=from_acct,
|
||||
to_acct=to_acct,
|
||||
ccy=ccy,
|
||||
)
|
||||
# 成功后轻量刷新总览/条件单缓存,供下次读到新余额;不整页重载
|
||||
if out.get("ok"):
|
||||
try:
|
||||
overview_store.request_refresh()
|
||||
cond_orders_store.request_refresh()
|
||||
except Exception:
|
||||
pass
|
||||
code = 200 if out.get("ok") else 400
|
||||
return jsonify(out), code
|
||||
|
||||
|
||||
@app.route("/api/settings/update/hub/version", methods=["GET"])
|
||||
@@ -637,49 +737,66 @@ def cond_orders_page():
|
||||
@app.route("/api/cond_orders/candidates")
|
||||
@login_required
|
||||
def api_cond_order_candidates():
|
||||
"""可选客户端:在线且无持仓、未被条件单锁定."""
|
||||
rows = pull_all_enabled()
|
||||
out = []
|
||||
for row in rows:
|
||||
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
|
||||
brief = snap.get("positions_brief") or snap.get("positions") or []
|
||||
has_pos = isinstance(brief, list) and len(brief) > 0
|
||||
locked = bool(snap.get("cond_order_locked"))
|
||||
selectable = bool(row.get("online")) and not has_pos and not locked
|
||||
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
|
||||
mode = str(inst.get("mode") or "").strip().lower()
|
||||
if mode not in ("sim", "live"):
|
||||
mode = ""
|
||||
out.append(
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"online": bool(row.get("online")),
|
||||
"has_position": has_pos,
|
||||
"cond_order_locked": locked,
|
||||
"selectable": selectable,
|
||||
"mode": mode,
|
||||
"error": row.get("error") or "",
|
||||
}
|
||||
)
|
||||
return jsonify({"ok": True, "instances": out})
|
||||
"""可选客户端:读热缓存(总览快照推导),不现场拉实例."""
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
if force:
|
||||
cond_orders_store.request_refresh()
|
||||
overview_store.request_refresh()
|
||||
snap = cond_orders_store.snapshot_dict()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"instances": snap.get("candidates") or [],
|
||||
"cond_version": snap.get("cond_version"),
|
||||
"updated_at": snap.get("updated_at"),
|
||||
"refreshing": snap.get("refreshing"),
|
||||
"from_cache": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/cond_orders", methods=["GET"])
|
||||
@login_required
|
||||
def api_cond_orders_list():
|
||||
instances = list_instances(enabled_only=True)
|
||||
all_orders = []
|
||||
for inst in instances:
|
||||
data = fetch_cond_orders(inst)
|
||||
for o in data.get("orders") or []:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
item = dict(o)
|
||||
item["instance_id"] = inst.get("id")
|
||||
item["instance_name"] = inst.get("name")
|
||||
all_orders.append(item)
|
||||
return jsonify({"ok": True, "orders": all_orders})
|
||||
"""条件单列表只读热缓存;后台常驻并行从实例拉取."""
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
if force:
|
||||
cond_orders_store.request_refresh()
|
||||
snap = cond_orders_store.snapshot_dict()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"orders": snap.get("orders") or [],
|
||||
"cond_version": snap.get("cond_version"),
|
||||
"updated_at": snap.get("updated_at"),
|
||||
"refreshing": snap.get("refreshing"),
|
||||
"from_cache": True,
|
||||
"poll_interval_sec": snap.get("poll_interval_sec"),
|
||||
"error": snap.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/cond_orders/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_cond_orders_refresh():
|
||||
cond_orders_store.request_refresh()
|
||||
overview_store.request_refresh()
|
||||
return jsonify({"ok": True, "msg": "已触发条件单后台刷新"})
|
||||
|
||||
|
||||
@app.route("/api/cond_orders/stream")
|
||||
@login_required
|
||||
def api_cond_orders_stream():
|
||||
return Response(
|
||||
stream_with_context(cond_orders_store.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/cond_orders", methods=["POST"])
|
||||
@@ -696,8 +813,9 @@ def api_cond_orders_create():
|
||||
if not instance_ids:
|
||||
return jsonify({"ok": False, "msg": "请选择客户端"})
|
||||
|
||||
# 再次按快照过滤有仓/锁定
|
||||
rows = {r.get("id"): r for r in pull_all_enabled()}
|
||||
# 再次按热缓存快照过滤有仓/锁定(下发本身仍 POST 到实例)
|
||||
ov = overview_store.snapshot_dict()
|
||||
rows = {r.get("id"): r for r in (ov.get("instances") or [])}
|
||||
allowed = []
|
||||
rejected = []
|
||||
for iid in instance_ids:
|
||||
@@ -739,6 +857,9 @@ def api_cond_orders_create():
|
||||
}
|
||||
results = dispatch_cond_order_to_ids(allowed, payload)
|
||||
ok_n = sum(1 for r in results if r.get("ok"))
|
||||
if ok_n > 0:
|
||||
cond_orders_store.request_refresh()
|
||||
overview_store.request_refresh()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": ok_n > 0,
|
||||
@@ -760,7 +881,11 @@ def api_cond_orders_cancel(hub_order_id: str):
|
||||
inst = get_instance(int(iid))
|
||||
if not inst:
|
||||
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
||||
return jsonify(post_cond_cancel(inst, hub_order_id))
|
||||
result = post_cond_cancel(inst, hub_order_id)
|
||||
if isinstance(result, dict) and result.get("ok"):
|
||||
cond_orders_store.request_refresh()
|
||||
overview_store.request_refresh()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
|
||||
@@ -778,7 +903,26 @@ def api_cond_orders_exit(hub_order_id: str):
|
||||
"profit_exit_enabled": data.get("profit_exit_enabled"),
|
||||
"profit_exit_mult": data.get("profit_exit_mult"),
|
||||
}
|
||||
return jsonify(post_cond_exit(inst, hub_order_id, payload))
|
||||
result = post_cond_exit(inst, hub_order_id, payload)
|
||||
if isinstance(result, dict) and result.get("ok"):
|
||||
cond_orders_store.request_refresh()
|
||||
overview_store.request_refresh()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/manifest.webmanifest")
|
||||
def pwa_manifest():
|
||||
path = ROOT / "static" / "icons" / "manifest.webmanifest"
|
||||
return send_file(path, mimetype="application/manifest+json; charset=utf-8")
|
||||
|
||||
|
||||
@app.route("/sw.js")
|
||||
def pwa_service_worker():
|
||||
path = ROOT / "static" / "sw.js"
|
||||
resp = send_file(path, mimetype="application/javascript; charset=utf-8")
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
resp.headers["Service-Worker-Allowed"] = "/"
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2026 马建军. All rights reserved.
|
||||
# Author: 马建军 | QQ: 253921841 | Email: 253921841@qq.com | Website: https://hyf2.cc
|
||||
# See COPYRIGHT in the repository root.
|
||||
"""crypto_okx_hub shared libraries."""
|
||||
|
||||
@@ -101,20 +101,35 @@ def prune_backups(*, keep_days: int | None = None) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]:
|
||||
def create_backup(
|
||||
*,
|
||||
root_dir: str,
|
||||
env_path: str,
|
||||
db_path: str,
|
||||
label: str = "manual",
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
now = beijing_now()
|
||||
name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
||||
out_path = os.path.join(backup_dir(), name)
|
||||
u = (username if username is not None else os.getenv("HUB_USERNAME") or "admin").strip() or "admin"
|
||||
p = password if password is not None else (os.getenv("HUB_PASSWORD") or "admin123")
|
||||
if p is None:
|
||||
p = "admin123"
|
||||
manifest = {
|
||||
"kind": "crypto_okx_hub",
|
||||
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"label": (label or "manual").strip() or "manual",
|
||||
"includes": [".env", "database"],
|
||||
"includes": [".env", "database", "hub_login"],
|
||||
"hub_username": u,
|
||||
}
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_")
|
||||
os.close(tmp_fd)
|
||||
env_snap = ""
|
||||
try:
|
||||
env_snap = _snapshot_env_with_login(env_path, u, str(p))
|
||||
with tarfile.open(tmp_path, "w:gz") as tar:
|
||||
manifest_path = tmp_path + ".manifest.json"
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
@@ -126,8 +141,8 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
|
||||
os.remove(manifest_path)
|
||||
except OSError:
|
||||
pass
|
||||
if env_path and os.path.isfile(env_path):
|
||||
tar.add(env_path, arcname=".env")
|
||||
if env_snap and os.path.isfile(env_snap):
|
||||
tar.add(env_snap, arcname=".env")
|
||||
if db_path and os.path.isfile(db_path):
|
||||
tar.add(db_path, arcname="database/" + os.path.basename(db_path))
|
||||
shutil.move(tmp_path, out_path)
|
||||
@@ -137,6 +152,12 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
if env_snap:
|
||||
try:
|
||||
os.remove(env_snap)
|
||||
except OSError:
|
||||
pass
|
||||
prune_backups()
|
||||
st = os.stat(out_path)
|
||||
return {
|
||||
@@ -149,6 +170,21 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_env_with_login(env_path: str, username: str, password: str) -> str:
|
||||
"""复制 .env 并写入当前中控登录账号,避免包内缺 HUB_USERNAME/PASSWORD."""
|
||||
fd, snap = tempfile.mkstemp(suffix=".env", prefix="hub_env_")
|
||||
os.close(fd)
|
||||
if env_path and os.path.isfile(env_path):
|
||||
shutil.copy2(env_path, snap)
|
||||
else:
|
||||
with open(snap, "w", encoding="utf-8") as f:
|
||||
f.write("")
|
||||
from lib.env_lib import apply_env_updates
|
||||
|
||||
apply_env_updates(snap, {"HUB_USERNAME": username, "HUB_PASSWORD": password})
|
||||
return snap
|
||||
|
||||
|
||||
def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]:
|
||||
path = backup_path(name)
|
||||
if not path or not os.path.isfile(path):
|
||||
@@ -168,10 +204,23 @@ def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) ->
|
||||
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
|
||||
shutil.copy2(src, db_path)
|
||||
break
|
||||
restored_user = ""
|
||||
restored_password = ""
|
||||
if env_path and os.path.isfile(env_path):
|
||||
from lib.env_lib import env_get
|
||||
|
||||
restored_user = env_get(env_path, "HUB_USERNAME", "")
|
||||
restored_password = env_get(env_path, "HUB_PASSWORD", "")
|
||||
if restored_user:
|
||||
os.environ["HUB_USERNAME"] = restored_user
|
||||
if restored_password:
|
||||
os.environ["HUB_PASSWORD"] = restored_password
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效",
|
||||
"msg": "已恢复 .env / 数据库 / 登录账号,建议重启中控进程使配置完全生效",
|
||||
"name": os.path.basename(path),
|
||||
"hub_username": restored_user,
|
||||
"hub_password": restored_password,
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +286,8 @@ def maybe_run_auto_backup(
|
||||
env_path: str,
|
||||
db_path: str,
|
||||
hour: int = 3,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
global _LAST_AUTO_DAY
|
||||
now = beijing_now()
|
||||
@@ -255,6 +306,8 @@ def maybe_run_auto_backup(
|
||||
env_path=env_path,
|
||||
db_path=db_path,
|
||||
label="auto",
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
_LAST_AUTO_DAY = day
|
||||
return result
|
||||
@@ -266,15 +319,24 @@ def start_backup_scheduler(
|
||||
env_path: str,
|
||||
db_path_fn: Callable[[], str],
|
||||
hour: int = 3,
|
||||
get_credentials: Callable[[], tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
def loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
u, p = ("", "")
|
||||
if get_credentials:
|
||||
try:
|
||||
u, p = get_credentials()
|
||||
except Exception:
|
||||
u, p = "", ""
|
||||
maybe_run_auto_backup(
|
||||
root_dir=root_dir,
|
||||
env_path=env_path,
|
||||
db_path=db_path_fn(),
|
||||
hour=hour,
|
||||
username=u or None,
|
||||
password=p if p != "" else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -215,6 +215,40 @@ def fetch_cond_orders(inst: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")}
|
||||
|
||||
|
||||
def pull_all_cond_orders() -> list[dict[str, Any]]:
|
||||
"""并行拉取各机条件单并展平为带 instance 字段的订单列表."""
|
||||
instances = list_instances(enabled_only=True)
|
||||
if not instances:
|
||||
return []
|
||||
by_id: dict[Any, dict[str, Any]] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
|
||||
futs = {pool.submit(fetch_cond_orders, inst): inst for inst in instances}
|
||||
for fut in as_completed(futs):
|
||||
inst = futs[fut]
|
||||
try:
|
||||
data = fut.result()
|
||||
except Exception as e:
|
||||
data = {
|
||||
"ok": False,
|
||||
"msg": str(e) or "拉取失败",
|
||||
"orders": [],
|
||||
"instance_id": inst.get("id"),
|
||||
"instance_name": inst.get("name"),
|
||||
}
|
||||
by_id[inst.get("id")] = data if isinstance(data, dict) else {}
|
||||
all_orders: list[dict[str, Any]] = []
|
||||
for inst in instances:
|
||||
data = by_id.get(inst["id"]) or {}
|
||||
for o in data.get("orders") or []:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
item = dict(o)
|
||||
item["instance_id"] = inst.get("id")
|
||||
item["instance_name"] = inst.get("name")
|
||||
all_orders.append(item)
|
||||
return all_orders
|
||||
|
||||
|
||||
def pull_all_enabled() -> list[dict[str, Any]]:
|
||||
instances = list_instances(enabled_only=True)
|
||||
if not instances:
|
||||
@@ -291,6 +325,132 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
def post_instance_transfer(
|
||||
inst: dict[str, Any],
|
||||
*,
|
||||
amount: float,
|
||||
from_acct: str = "funding",
|
||||
to_acct: str = "trading",
|
||||
ccy: str = "USDT",
|
||||
) -> dict[str, Any]:
|
||||
"""中控指令:实例账户内划转."""
|
||||
base = (inst.get("base_url") or "").rstrip("/")
|
||||
token = (inst.get("bridge_token") or "").strip()
|
||||
out: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"msg": "",
|
||||
"instance_id": inst.get("id"),
|
||||
"instance_name": inst.get("name"),
|
||||
}
|
||||
if not base or not token:
|
||||
out["msg"] = "缺少 URL 或 Token"
|
||||
return out
|
||||
url = f"{base}/api/hub/options/transfer"
|
||||
try:
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=_headers(token),
|
||||
json={
|
||||
"amount": float(amount),
|
||||
"from": from_acct,
|
||||
"to": to_acct,
|
||||
"ccy": ccy,
|
||||
},
|
||||
timeout=max(_timeout(), 30.0),
|
||||
)
|
||||
data, err = _response_json(r)
|
||||
if data is None:
|
||||
out["msg"] = err or f"HTTP {r.status_code}"
|
||||
return out
|
||||
if not isinstance(data, dict):
|
||||
out["msg"] = f"HTTP {r.status_code}"
|
||||
return out
|
||||
out.update(data)
|
||||
out["instance_id"] = inst.get("id")
|
||||
out["instance_name"] = inst.get("name")
|
||||
if r.status_code >= 400 and not out.get("ok"):
|
||||
out["ok"] = False
|
||||
out.setdefault("msg", err or f"HTTP {r.status_code}")
|
||||
return out
|
||||
except requests.Timeout:
|
||||
out["msg"] = "划转超时"
|
||||
return out
|
||||
except Exception as e:
|
||||
out["msg"] = str(e) or "划转失败"
|
||||
return out
|
||||
|
||||
|
||||
def fetch_one_pnl_curve(inst: dict[str, Any], *, range_key: str = "7d") -> dict[str, Any]:
|
||||
"""拉取单机累计已实现盈亏曲线."""
|
||||
base = (inst.get("base_url") or "").rstrip("/")
|
||||
token = (inst.get("bridge_token") or "").strip()
|
||||
out: dict[str, Any] = {
|
||||
"id": inst.get("id"),
|
||||
"name": inst.get("name"),
|
||||
"online": False,
|
||||
"error": "",
|
||||
"points": [],
|
||||
"trade_count": 0,
|
||||
"latest": None,
|
||||
"metric": "realized_pnl",
|
||||
}
|
||||
if not base or not token:
|
||||
out["error"] = "缺少 URL 或 Token"
|
||||
return out
|
||||
url = f"{base}/api/hub/options/pnl_curve"
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"X-Hub-Token": token, "Accept": "application/json"},
|
||||
params={"range": range_key},
|
||||
timeout=max(_timeout(), 15.0),
|
||||
)
|
||||
data = {}
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"):
|
||||
out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}"
|
||||
return out
|
||||
out["online"] = True
|
||||
pts = data.get("points") if isinstance(data.get("points"), list) else []
|
||||
out["points"] = pts
|
||||
out["trade_count"] = int(data.get("trade_count") or 0)
|
||||
out["latest"] = data.get("latest")
|
||||
out["metric"] = data.get("metric") or "realized_pnl"
|
||||
out["start_ms"] = data.get("start_ms")
|
||||
out["end_ms"] = data.get("end_ms")
|
||||
return out
|
||||
except requests.Timeout:
|
||||
out["error"] = "超时"
|
||||
return out
|
||||
except Exception as e:
|
||||
out["error"] = str(e) or "拉取失败"
|
||||
return out
|
||||
|
||||
|
||||
def pull_all_pnl_curves(range_key: str = "7d") -> list[dict[str, Any]]:
|
||||
instances = list_instances(enabled_only=True)
|
||||
if not instances:
|
||||
return []
|
||||
results: list[dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
|
||||
futs = {
|
||||
pool.submit(fetch_one_pnl_curve, inst, range_key=range_key): inst["id"]
|
||||
for inst in instances
|
||||
}
|
||||
by_id: dict[Any, dict[str, Any]] = {}
|
||||
for fut in as_completed(futs):
|
||||
row = fut.result()
|
||||
by_id[row.get("id")] = row
|
||||
for inst in instances:
|
||||
results.append(
|
||||
by_id.get(inst["id"]) or fetch_one_pnl_curve(inst, range_key=range_key)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]:
|
||||
"""探测客户端 git 版本 / 在线."""
|
||||
base = (inst.get("base_url") or "").rstrip("/")
|
||||
|
||||
@@ -90,6 +90,50 @@ def record_cash_samples_from_overview(rows: list[dict[str, Any]]) -> int:
|
||||
return len(to_write)
|
||||
|
||||
|
||||
def first_cash_by_instance(instance_ids: list[Any] | None = None) -> dict[Any, float]:
|
||||
"""各机最早一笔现金采样,作初始资金回退(实盘未配 LIVE_INITIAL 时)."""
|
||||
conn = get_db()
|
||||
try:
|
||||
if instance_ids:
|
||||
ids: list[int] = []
|
||||
for x in instance_ids:
|
||||
try:
|
||||
ids.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT instance_id, cash_usdt
|
||||
FROM hub_cash_samples
|
||||
WHERE instance_id IN ({placeholders})
|
||||
ORDER BY ts_ms ASC
|
||||
""",
|
||||
tuple(ids),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT instance_id, cash_usdt
|
||||
FROM hub_cash_samples
|
||||
ORDER BY ts_ms ASC
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
out: dict[Any, float] = {}
|
||||
for r in rows:
|
||||
iid = r["instance_id"]
|
||||
if iid in out:
|
||||
continue
|
||||
v = _safe_float(r["cash_usdt"])
|
||||
if v is not None and v > 0:
|
||||
out[iid] = round(v, 4)
|
||||
return out
|
||||
|
||||
|
||||
def query_cash_series(
|
||||
*,
|
||||
range_key: str = "7d",
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""条件单热缓存:后台常驻并行从实例拉取;浏览器只读缓存 + SSE.
|
||||
下发/撤单/改出场仍由中控 POST 到实例,成功后触发刷新.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _poll_interval_sec() -> float:
|
||||
try:
|
||||
return max(
|
||||
3.0,
|
||||
float(
|
||||
os.getenv("HUB_COND_POLL_INTERVAL_SEC")
|
||||
or os.getenv("HUB_PULL_INTERVAL_SEC")
|
||||
or 10
|
||||
),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return 10.0
|
||||
|
||||
|
||||
def _sse_heartbeat_sec() -> float:
|
||||
try:
|
||||
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
||||
except (TypeError, ValueError):
|
||||
return 25.0
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows or []:
|
||||
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
|
||||
brief = snap.get("positions_brief") or snap.get("positions") or []
|
||||
has_pos = isinstance(brief, list) and len(brief) > 0
|
||||
locked = bool(snap.get("cond_order_locked"))
|
||||
selectable = bool(row.get("online")) and not has_pos and not locked
|
||||
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
|
||||
mode = str(inst.get("mode") or "").strip().lower()
|
||||
if mode not in ("sim", "live"):
|
||||
mode = ""
|
||||
funds = snap.get("funds") if isinstance(snap.get("funds"), dict) else {}
|
||||
# 开仓吃交易户;优先 avail,回退总额
|
||||
trading_usdt = _safe_float(funds.get("trading_usdt_avail"))
|
||||
if trading_usdt is None:
|
||||
trading_usdt = _safe_float(funds.get("trading_usdt"))
|
||||
if trading_usdt is None:
|
||||
trading_usdt = _safe_float(snap.get("trading_usdt"))
|
||||
funding_usdt = _safe_float(funds.get("funding_usdt"))
|
||||
if funding_usdt is None:
|
||||
funding_usdt = _safe_float(snap.get("funding_usdt"))
|
||||
if trading_usdt is not None:
|
||||
trading_usdt = round(float(trading_usdt), 4)
|
||||
if funding_usdt is not None:
|
||||
funding_usdt = round(float(funding_usdt), 4)
|
||||
transfer_blocked = bool(snap.get("transfer_blocked")) or has_pos
|
||||
transfer_block_msg = str(snap.get("transfer_block_msg") or "").strip()
|
||||
if has_pos and not transfer_block_msg:
|
||||
transfer_block_msg = "期权持仓中禁止划转"
|
||||
allowed_raw = snap.get("options_allowed_underlys")
|
||||
if not isinstance(allowed_raw, list):
|
||||
allowed_raw = inst.get("allowed_underlys")
|
||||
allowed_underlys: list[str] = []
|
||||
if isinstance(allowed_raw, list):
|
||||
for x in allowed_raw:
|
||||
u = str(x or "").strip().upper()
|
||||
if u in ("ETH", "BTC") and u not in allowed_underlys:
|
||||
allowed_underlys.append(u)
|
||||
if not allowed_underlys:
|
||||
# 旧实例未上报白名单:仍显示在 ETH/BTC 下(兼容)
|
||||
allowed_underlys = ["ETH", "BTC"]
|
||||
default_underly = str(
|
||||
snap.get("options_underly") or inst.get("underly") or allowed_underlys[0]
|
||||
).strip().upper()
|
||||
if default_underly not in allowed_underlys:
|
||||
default_underly = allowed_underlys[0]
|
||||
out.append(
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"online": bool(row.get("online")),
|
||||
"has_position": has_pos,
|
||||
"cond_order_locked": locked,
|
||||
"selectable": selectable,
|
||||
"mode": mode,
|
||||
"error": row.get("error") or "",
|
||||
"trading_usdt": trading_usdt,
|
||||
"funding_usdt": funding_usdt,
|
||||
"transfer_blocked": transfer_blocked,
|
||||
"transfer_block_msg": transfer_block_msg,
|
||||
"allowed_underlys": allowed_underlys,
|
||||
"default_underly": default_underly,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class CondOrdersStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.version = 0
|
||||
self.orders: list[dict[str, Any]] = []
|
||||
self.candidates: list[dict[str, Any]] = []
|
||||
self.refreshing = True
|
||||
self.last_error: str | None = None
|
||||
self.updated_at: str = ""
|
||||
self._ready = False
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._refresh = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
with self._lock:
|
||||
self.refreshing = True
|
||||
self._ready = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
daemon=True,
|
||||
name="hub-cond-orders-pull",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def snapshot_dict(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"ok": True,
|
||||
"orders": list(self.orders),
|
||||
"candidates": list(self.candidates),
|
||||
"cond_version": self.version,
|
||||
"refreshing": self.refreshing,
|
||||
"updated_at": self.updated_at,
|
||||
"error": self.last_error,
|
||||
"from_cache": True,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
"ready": self._ready,
|
||||
}
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"cond_version": self.version,
|
||||
"updated_at": self.updated_at,
|
||||
"refreshing": self.refreshing,
|
||||
"ok": True,
|
||||
"error": self.last_error,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
}
|
||||
|
||||
def _overview_rows(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
from lib.overview_cache_lib import overview_store
|
||||
|
||||
snap = overview_store.snapshot_dict()
|
||||
rows = snap.get("instances") if isinstance(snap, dict) else None
|
||||
if isinstance(rows, list) and rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.bridge_client_lib import pull_all_enabled
|
||||
|
||||
return pull_all_enabled()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
self._refresh_once()
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
self._refresh.wait(timeout=_poll_interval_sec())
|
||||
|
||||
def _refresh_once(self) -> None:
|
||||
with self._lock:
|
||||
self.refreshing = True
|
||||
self._broadcast()
|
||||
try:
|
||||
from lib.bridge_client_lib import pull_all_cond_orders
|
||||
|
||||
rows = self._overview_rows()
|
||||
candidates = _candidates_from_snaps(rows)
|
||||
orders = pull_all_cond_orders()
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.orders = orders if isinstance(orders, list) else []
|
||||
self.candidates = candidates
|
||||
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.last_error = None
|
||||
self.refreshing = False
|
||||
self._ready = True
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.last_error = str(e) or "cond_refresh_failed"
|
||||
self.refreshing = False
|
||||
self._ready = True
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except queue.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=_sse_heartbeat_sec())
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: cond\ndata: {body}\n\n"
|
||||
|
||||
|
||||
cond_orders_store = CondOrdersStore()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""中控总览热缓存:后台常驻并行 pull,与浏览器是否打开无关;SSE 仅扇出版本通知."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any
|
||||
|
||||
OnPullFn = Callable[[list[dict[str, Any]]], None]
|
||||
|
||||
|
||||
def _poll_interval_sec() -> float:
|
||||
try:
|
||||
return max(3.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
|
||||
except (TypeError, ValueError):
|
||||
return 10.0
|
||||
|
||||
|
||||
def _sse_heartbeat_sec() -> float:
|
||||
try:
|
||||
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
||||
except (TypeError, ValueError):
|
||||
return 25.0
|
||||
|
||||
|
||||
class OverviewStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.version = 0
|
||||
self.instances: list[dict[str, Any]] = []
|
||||
self.pulling = True # 启动至首轮完成前视为拉取中
|
||||
self.last_error: str | None = None
|
||||
self.updated_at: str = ""
|
||||
self._ready = False
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._refresh = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._pull_fn: Callable[[], list[dict[str, Any]]] | None = None
|
||||
self._on_pull: OnPullFn | None = None
|
||||
|
||||
def start(
|
||||
self,
|
||||
pull_fn: Callable[[], list[dict[str, Any]]],
|
||||
*,
|
||||
on_pull: OnPullFn | None = None,
|
||||
) -> None:
|
||||
self._pull_fn = pull_fn
|
||||
self._on_pull = on_pull
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
with self._lock:
|
||||
self.pulling = True
|
||||
self._ready = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
daemon=True,
|
||||
name="hub-overview-pull",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def snapshot_dict(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
rows = list(self.instances)
|
||||
ver = self.version
|
||||
pulling = self.pulling
|
||||
err = self.last_error
|
||||
updated_at = self.updated_at
|
||||
return {
|
||||
"ok": True,
|
||||
"instances": rows,
|
||||
"overview_version": ver,
|
||||
"pulling": pulling,
|
||||
"updated_at": updated_at,
|
||||
"error": err,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
"from_cache": True,
|
||||
}
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"overview_version": self.version,
|
||||
"updated_at": self.updated_at,
|
||||
"pulling": self.pulling,
|
||||
"ok": True,
|
||||
"error": self.last_error,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
}
|
||||
|
||||
def _loop(self) -> None:
|
||||
assert self._pull_fn is not None
|
||||
while not self._stop.is_set():
|
||||
self._pull_once()
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
self._refresh.wait(timeout=_poll_interval_sec())
|
||||
|
||||
def _pull_once(self) -> None:
|
||||
assert self._pull_fn is not None
|
||||
with self._lock:
|
||||
self.pulling = True
|
||||
self._broadcast()
|
||||
try:
|
||||
rows = self._pull_fn()
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
if self._on_pull is not None:
|
||||
try:
|
||||
self._on_pull(rows)
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.instances = rows
|
||||
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.last_error = None
|
||||
self.pulling = False
|
||||
self._ready = True
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.last_error = str(e) or "pull_failed"
|
||||
self.pulling = False
|
||||
self._ready = True
|
||||
# 保留上一轮成功缓存,避免整页空白
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except queue.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=_sse_heartbeat_sec())
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: overview\ndata: {body}\n\n"
|
||||
|
||||
|
||||
overview_store = OverviewStore()
|
||||
@@ -24,11 +24,18 @@ def build_embed_open_url(
|
||||
bridge_token: str,
|
||||
embed_path: str = "/embed",
|
||||
ttl_sec: int = 120,
|
||||
tab: str | None = None,
|
||||
) -> str:
|
||||
root = (base_url or "").rstrip("/")
|
||||
path = embed_path or "/embed"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
tab_s = "".join(c for c in str(tab or "").strip().lower() if c.isalnum() or c == "_")[
|
||||
:32
|
||||
]
|
||||
if tab_s:
|
||||
joiner = "&" if "?" in path else "?"
|
||||
path = f"{path}{joiner}tab={tab_s}"
|
||||
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
|
||||
q = urlencode({"token": token, "next": path})
|
||||
return f"{root}/hub_sso?{q}"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""中控统计汇总:KPI 对比 + 余额曲线."""
|
||||
"""中控统计汇总:KPI 对比 + 已实现盈亏曲线."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lib.bridge_client_lib import pull_all_enabled, pull_all_stats
|
||||
from lib.cash_sample_lib import query_cash_series, record_cash_samples_from_overview
|
||||
from lib.bridge_client_lib import pull_all_enabled, pull_all_pnl_curves, pull_all_stats
|
||||
from lib.cash_sample_lib import (
|
||||
first_cash_by_instance,
|
||||
record_cash_samples_from_overview,
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
@@ -36,16 +40,26 @@ def normalize_window(raw: Any) -> str:
|
||||
return w if w in ("today", "7d", "30d", "all") else "7d"
|
||||
|
||||
|
||||
def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
||||
def build_stats_summary(
|
||||
window: str = "7d",
|
||||
*,
|
||||
snaps: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
w = normalize_window(window)
|
||||
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
|
||||
snaps = pull_all_enabled()
|
||||
try:
|
||||
record_cash_samples_from_overview(snaps)
|
||||
except Exception:
|
||||
pass
|
||||
# snaps 可由热缓存传入,避免统计页再现场重复拉全机
|
||||
if snaps is None:
|
||||
snaps = pull_all_enabled()
|
||||
try:
|
||||
record_cash_samples_from_overview(snaps)
|
||||
except Exception:
|
||||
pass
|
||||
stats_rows = pull_all_stats(w)
|
||||
stats_by_id = {r.get("id"): r for r in stats_rows}
|
||||
try:
|
||||
initial_fallback = first_cash_by_instance([s.get("id") for s in snaps])
|
||||
except Exception:
|
||||
initial_fallback = {}
|
||||
|
||||
machines: list[dict[str, Any]] = []
|
||||
sum_cash = 0.0
|
||||
@@ -83,6 +97,16 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
||||
win_rate = _safe_float(stats.get("win_rate"))
|
||||
avg_hold = _safe_float(stats.get("avg_hold_sec"))
|
||||
online = bool(snap.get("online"))
|
||||
mode = ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
|
||||
"mode"
|
||||
)
|
||||
# 收益率 = 窗口已结算盈亏 / 初始资金(非当前余额)
|
||||
initial = _safe_float(premium.get("initial_cash_usdt"))
|
||||
if initial is None or initial <= 0:
|
||||
initial = _safe_float(initial_fallback.get(iid))
|
||||
return_pct = None
|
||||
if online and stats.get("ok") and net is not None and initial is not None and initial > 0:
|
||||
return_pct = round(float(net) / float(initial) * 100.0, 2)
|
||||
if online:
|
||||
online_n += 1
|
||||
if cash is not None:
|
||||
@@ -110,19 +134,19 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
||||
"name": snap.get("name"),
|
||||
"online": online,
|
||||
"error": snap.get("error") or st.get("error") or "",
|
||||
"mode": ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
|
||||
"mode"
|
||||
),
|
||||
"mode": mode,
|
||||
"cash_usdt": cash,
|
||||
"premium_usdt": paid,
|
||||
"ratio_pct": ratio,
|
||||
"unrealized_usdt": unreal,
|
||||
"initial_cash_usdt": initial,
|
||||
"pos_count": len(brief),
|
||||
"total_closed": closed if online and stats.get("ok") else None,
|
||||
"win_count": wins if online and stats.get("ok") else None,
|
||||
"loss_count": losses if online and stats.get("ok") else None,
|
||||
"win_rate": win_rate if online and stats.get("ok") else None,
|
||||
"net_realized_pnl": net if online and stats.get("ok") else None,
|
||||
"return_rate_pct": return_pct,
|
||||
"total_profit": profit if online and stats.get("ok") else None,
|
||||
"total_loss": loss if online and stats.get("ok") else None,
|
||||
"avg_hold_sec": avg_hold if online and stats.get("ok") else None,
|
||||
@@ -130,6 +154,14 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
|
||||
def _mode_rank(m: dict[str, Any]) -> tuple[int, str]:
|
||||
mode_v = str(m.get("mode") or "").strip().lower()
|
||||
# 上方 sim,下方 live
|
||||
rank = 0 if mode_v == "sim" else 1 if mode_v == "live" else 2
|
||||
return (rank, str(m.get("name") or m.get("id") or ""))
|
||||
|
||||
machines.sort(key=_mode_rank)
|
||||
|
||||
agg_win_rate = round(sum_wins / sum_closed * 100, 2) if sum_closed else 0.0
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -153,12 +185,84 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
||||
|
||||
|
||||
def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
|
||||
# 顺手采一笔,让打开页面时也有点
|
||||
try:
|
||||
snaps = pull_all_enabled()
|
||||
record_cash_samples_from_overview(snaps)
|
||||
except Exception:
|
||||
pass
|
||||
data = query_cash_series(range_key=range_key)
|
||||
data["ok"] = True
|
||||
return data
|
||||
"""多机累计已实现盈亏曲线(各机台阶叠加;划转/提现不影响)."""
|
||||
rk = normalize_window(range_key)
|
||||
now_ms = int(time.time() * 1000)
|
||||
rows = pull_all_pnl_curves(rk)
|
||||
series: list[dict[str, Any]] = []
|
||||
by_id: dict[Any, list[dict[str, float]]] = {}
|
||||
start_ms = None
|
||||
end_ms = now_ms
|
||||
for row in rows:
|
||||
iid = row.get("id")
|
||||
pts_raw = row.get("points") if isinstance(row.get("points"), list) else []
|
||||
pts: list[dict[str, float]] = []
|
||||
for p in pts_raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
try:
|
||||
t = int(p.get("t"))
|
||||
v = float(p.get("v"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
pts.append({"t": t, "v": v})
|
||||
pts.sort(key=lambda x: x["t"])
|
||||
if row.get("start_ms") is not None:
|
||||
try:
|
||||
sm = int(row.get("start_ms"))
|
||||
start_ms = sm if start_ms is None else min(start_ms, sm)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if row.get("end_ms") is not None:
|
||||
try:
|
||||
end_ms = max(end_ms, int(row.get("end_ms")))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
by_id[iid] = pts
|
||||
series.append(
|
||||
{
|
||||
"id": iid,
|
||||
"name": row.get("name") or str(iid),
|
||||
"online": bool(row.get("online")),
|
||||
"error": row.get("error") or "",
|
||||
"points": pts,
|
||||
"trade_count": int(row.get("trade_count") or 0),
|
||||
"latest": row.get("latest"),
|
||||
}
|
||||
)
|
||||
|
||||
# 合计:按 1 分钟桶,各机该桶最后值求和并前向填充
|
||||
last_in_bucket: dict[int, dict[Any, float]] = {}
|
||||
for iid, pts in by_id.items():
|
||||
for p in pts:
|
||||
bt = (int(p["t"]) // 60000) * 60000
|
||||
last_in_bucket.setdefault(bt, {})[iid] = float(p["v"])
|
||||
running: dict[Any, float] = {}
|
||||
total_pts: list[dict[str, float]] = []
|
||||
for bt in sorted(last_in_bucket.keys()):
|
||||
running.update(last_in_bucket[bt])
|
||||
if running:
|
||||
total_pts.append({"t": bt, "v": round(sum(running.values()), 4)})
|
||||
|
||||
if start_ms is None:
|
||||
if rk in ("today", "1d"):
|
||||
import datetime as dt
|
||||
|
||||
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
elif rk in ("30d", "30"):
|
||||
start_ms = now_ms - 30 * 86400 * 1000
|
||||
elif rk in ("all", "0"):
|
||||
start_ms = 0
|
||||
else:
|
||||
start_ms = now_ms - 7 * 86400 * 1000
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"metric": "realized_pnl",
|
||||
"series": series,
|
||||
"total": total_pts,
|
||||
"range": rk,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""数据统计热缓存:后台按窗口并行刷新 summary/curve;浏览器只读缓存 + SSE."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
|
||||
|
||||
STATS_WINDOWS = ("today", "7d", "30d", "all")
|
||||
|
||||
|
||||
def _poll_interval_sec() -> float:
|
||||
try:
|
||||
return max(5.0, float(os.getenv("HUB_STATS_POLL_INTERVAL_SEC") or os.getenv("HUB_PULL_INTERVAL_SEC") or 15))
|
||||
except (TypeError, ValueError):
|
||||
return 15.0
|
||||
|
||||
|
||||
def _sse_heartbeat_sec() -> float:
|
||||
try:
|
||||
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
||||
except (TypeError, ValueError):
|
||||
return 25.0
|
||||
|
||||
|
||||
class StatsStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.version = 0
|
||||
self.summaries: dict[str, dict[str, Any]] = {}
|
||||
self.curves: dict[str, dict[str, Any]] = {}
|
||||
self.refreshing = True
|
||||
self.last_error: str | None = None
|
||||
self.updated_at: str = ""
|
||||
self._ready = False
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._refresh = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
with self._lock:
|
||||
self.refreshing = True
|
||||
self._ready = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
daemon=True,
|
||||
name="hub-stats-pull",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def summary_dict(self, window: str = "7d") -> dict[str, Any]:
|
||||
w = normalize_window(window)
|
||||
with self._lock:
|
||||
body = dict(self.summaries.get(w) or {})
|
||||
ver = self.version
|
||||
refreshing = self.refreshing
|
||||
err = self.last_error
|
||||
updated_at = self.updated_at
|
||||
ready = self._ready
|
||||
if not body:
|
||||
return {
|
||||
"ok": False,
|
||||
"window": w,
|
||||
"msg": "统计缓存尚未就绪" if not ready or refreshing else "暂无统计数据",
|
||||
"stats_version": ver,
|
||||
"refreshing": refreshing,
|
||||
"updated_at": updated_at,
|
||||
"error": err,
|
||||
"from_cache": True,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
}
|
||||
body = dict(body)
|
||||
body["stats_version"] = ver
|
||||
body["refreshing"] = refreshing
|
||||
body["updated_at"] = updated_at
|
||||
body["error"] = err
|
||||
body["from_cache"] = True
|
||||
body["poll_interval_sec"] = _poll_interval_sec()
|
||||
body.setdefault("ok", True)
|
||||
return body
|
||||
|
||||
def curve_dict(self, range_key: str = "7d") -> dict[str, Any]:
|
||||
w = normalize_window(range_key)
|
||||
with self._lock:
|
||||
body = dict(self.curves.get(w) or {})
|
||||
ver = self.version
|
||||
refreshing = self.refreshing
|
||||
err = self.last_error
|
||||
updated_at = self.updated_at
|
||||
if not body:
|
||||
return {
|
||||
"ok": True,
|
||||
"range": w,
|
||||
"total": [],
|
||||
"series": [],
|
||||
"stats_version": ver,
|
||||
"refreshing": refreshing,
|
||||
"updated_at": updated_at,
|
||||
"error": err,
|
||||
"from_cache": True,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
}
|
||||
body = dict(body)
|
||||
body["stats_version"] = ver
|
||||
body["refreshing"] = refreshing
|
||||
body["updated_at"] = updated_at
|
||||
body["error"] = err
|
||||
body["from_cache"] = True
|
||||
body["poll_interval_sec"] = _poll_interval_sec()
|
||||
body.setdefault("ok", True)
|
||||
return body
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"stats_version": self.version,
|
||||
"updated_at": self.updated_at,
|
||||
"refreshing": self.refreshing,
|
||||
"ok": True,
|
||||
"error": self.last_error,
|
||||
"poll_interval_sec": _poll_interval_sec(),
|
||||
}
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
self._refresh_once()
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
self._refresh.wait(timeout=_poll_interval_sec())
|
||||
|
||||
def _current_snaps(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
from lib.overview_cache_lib import overview_store
|
||||
|
||||
snap = overview_store.snapshot_dict()
|
||||
rows = snap.get("instances") if isinstance(snap, dict) else None
|
||||
if isinstance(rows, list) and rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.bridge_client_lib import pull_all_enabled
|
||||
|
||||
return pull_all_enabled()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _refresh_once(self) -> None:
|
||||
with self._lock:
|
||||
self.refreshing = True
|
||||
self._broadcast()
|
||||
try:
|
||||
snaps = self._current_snaps()
|
||||
summaries: dict[str, dict[str, Any]] = {}
|
||||
curves: dict[str, dict[str, Any]] = {}
|
||||
# 多窗口并行刷;每窗内部 pull_all_stats 已并行拉机
|
||||
with ThreadPoolExecutor(max_workers=len(STATS_WINDOWS)) as pool:
|
||||
futs = {
|
||||
pool.submit(build_stats_summary, w, snaps=snaps): ("summary", w)
|
||||
for w in STATS_WINDOWS
|
||||
}
|
||||
futs.update(
|
||||
{
|
||||
pool.submit(build_stats_curve, w): ("curve", w)
|
||||
for w in STATS_WINDOWS
|
||||
}
|
||||
)
|
||||
for fut in as_completed(futs):
|
||||
kind, w = futs[fut]
|
||||
try:
|
||||
data = fut.result()
|
||||
except Exception as e:
|
||||
data = {"ok": False, "msg": str(e) or "failed", "window": w}
|
||||
if kind == "summary":
|
||||
summaries[w] = data if isinstance(data, dict) else {"ok": False}
|
||||
else:
|
||||
curves[w] = data if isinstance(data, dict) else {"ok": False, "total": [], "series": []}
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.summaries = summaries
|
||||
self.curves = curves
|
||||
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.last_error = None
|
||||
self.refreshing = False
|
||||
self._ready = True
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
self.last_error = str(e) or "stats_refresh_failed"
|
||||
self.refreshing = False
|
||||
self._ready = True
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except queue.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=_sse_heartbeat_sec())
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: stats\ndata: {body}\n\n"
|
||||
|
||||
|
||||
stats_store = StatsStore()
|
||||
@@ -1419,8 +1419,39 @@ input.readonly {
|
||||
}
|
||||
|
||||
.cond-head { justify-content: space-between; align-items: flex-start; }
|
||||
.cond-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.cond-head-actions .btn,
|
||||
.cond-head-actions .settings-primary {
|
||||
margin-top: 0;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cond-lead { margin: 6px 0 0; max-width: 720px; line-height: 1.5; }
|
||||
.cond-add-check {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.cond-layout { display: grid; gap: 16px; }
|
||||
.cond-add-panel { display: none; }
|
||||
#cond-add-toggle:checked ~ .cond-layout .cond-add-panel {
|
||||
display: block;
|
||||
}
|
||||
.cond-card { padding: 18px 18px 16px; }
|
||||
.cond-card-head {
|
||||
display: flex;
|
||||
@@ -1443,79 +1474,241 @@ input.readonly {
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.cond-section {
|
||||
border-top: 1px solid rgba(56, 189, 248, 0.12);
|
||||
padding: 14px 0 4px;
|
||||
.cond-create-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.92fr) minmax(300px, 1.18fr);
|
||||
gap: 16px 18px;
|
||||
align-items: start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.cond-section:first-of-type { border-top: 0; padding-top: 0; }
|
||||
.cond-section-title {
|
||||
.cond-col-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-2);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.cond-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 14px;
|
||||
.cond-block { margin-bottom: 14px; }
|
||||
.cond-block:last-child { margin-bottom: 0; }
|
||||
.cond-mini-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #b6c7da;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cond-uly-side-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: end;
|
||||
}
|
||||
.cond-grid label {
|
||||
.cond-uly-side-item { min-width: 0; }
|
||||
.cond-native-select[hidden] { display: none !important; }
|
||||
.cond-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background: rgba(8, 16, 30, 0.8);
|
||||
}
|
||||
.cond-seg-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 8px 14px;
|
||||
font-size: 0.84rem;
|
||||
cursor: pointer;
|
||||
min-width: 72px;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cond-seg-btn + .cond-seg-btn { border-left: 1px solid var(--line); }
|
||||
.cond-seg-btn.on {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
.cond-param-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 0.7fr 0.7fr;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.cond-field {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #b6c7da;
|
||||
}
|
||||
.cond-grid input,
|
||||
.cond-grid select {
|
||||
.cond-field input {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.cond-field-mult { min-width: 72px; }
|
||||
.cond-field-mult input { width: 72px; }
|
||||
.cond-trigger-hint {
|
||||
grid-column: 1 / -1;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 16, 30, 0.7);
|
||||
color: var(--text);
|
||||
}
|
||||
.cond-trigger-hint.is-put { border-color: rgba(251, 113, 133, 0.35); }
|
||||
.cond-trigger-hint.is-call { border-color: rgba(52, 211, 153, 0.35); }
|
||||
.cond-trigger-hint.is-put { border-color: rgba(251, 113, 133, 0.35); color: #fda4af; }
|
||||
.cond-trigger-hint.is-call { border-color: rgba(52, 211, 153, 0.35); color: #6ee7b7; }
|
||||
.cond-exit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.cond-switch {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 62px;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
min-height: 0;
|
||||
}
|
||||
.cond-switch input { width: auto; margin: 0; }
|
||||
.cond-inst-group + .cond-inst-group { margin-top: 14px; }
|
||||
.cond-inst-group-label { margin-bottom: 8px; }
|
||||
.cond-inst-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cond-inst {
|
||||
.cond-right-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cond-right-head .cond-col-title { margin: 0; }
|
||||
.cond-right-meta { font-size: 0.76rem; }
|
||||
.cond-right-lead {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cond-inst-group + .cond-inst-group { margin-top: 10px; }
|
||||
.cond-inst-group-label { margin-bottom: 6px; }
|
||||
.cond-inst-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cond-inst-row {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.cond-inst-row.is-off { opacity: 0.5; }
|
||||
.cond-inst {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px 10px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 16, 30, 0.72);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.cond-inst-row:not(.is-off) .cond-inst:hover {
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
.cond-inst-row:not(.is-off) .cond-inst:has(input:checked) {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
.cond-inst > input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 2px 0 0;
|
||||
flex-shrink: 0;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
}
|
||||
.cond-inst-main {
|
||||
min-width: 0;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.cond-inst-name {
|
||||
display: block;
|
||||
font-weight: 650;
|
||||
font-size: 0.92rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cond-inst input { width: auto; margin: 0; }
|
||||
.cond-inst-name { flex: 1; font-weight: 600; }
|
||||
.cond-inst-tag { color: var(--muted); font-size: 0.75rem; }
|
||||
.cond-inst.is-off { opacity: 0.5; cursor: not-allowed; }
|
||||
.cond-inst.is-off .cond-inst-name { cursor: not-allowed; }
|
||||
.cond-inst-tag {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
flex-shrink: 0;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
.cond-inst-fund-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.cond-inst-funds {
|
||||
color: var(--accent-2, #38bdf8);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cond-inst-funds--funding {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cond-inst-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
grid-column: 2 / -1;
|
||||
grid-row: 2;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.cond-inst-xfer {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
min-width: 5.5em;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cond-inst.is-off { cursor: not-allowed; }
|
||||
.cond-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.cond-create-split { grid-template-columns: 1fr; }
|
||||
.cond-param-row { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.cond-exit-row { grid-template-columns: 1fr auto auto; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.cond-param-row,
|
||||
.cond-exit-row { grid-template-columns: 1fr; }
|
||||
.cond-field-mult input { width: 100%; }
|
||||
}
|
||||
.cond-empty { padding: 28px 8px; text-align: center; }
|
||||
.cond-st {
|
||||
@@ -1531,6 +1724,28 @@ input.readonly {
|
||||
.cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); }
|
||||
.cond-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); }
|
||||
.cond-table-wrap { margin-top: 0; }
|
||||
.cond-table-wrap .overview-table tbody tr {
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.cond-table-wrap .overview-table tbody tr:hover td {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
.cond-td-time {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.cond-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cond-pager .btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* —— 数据统计 —— */
|
||||
.stats-head {
|
||||
@@ -1609,6 +1824,62 @@ input.readonly {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.stats-curve-toggle input { width: auto; margin: 0; }
|
||||
.stats-curve-panel--large { margin-top: 18px; }
|
||||
.stats-machine-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
margin: 0 0 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.stats-machine-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(5, 12, 22, 0.65);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.stats-machine-chip input { width: auto; margin: 0; }
|
||||
.stats-machine-chip i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.stats-pick-all {
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stats-pick-all:hover { color: var(--text); border-color: rgba(56, 189, 248, 0.4); }
|
||||
.stats-mode-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
color: var(--muted);
|
||||
}
|
||||
.stats-mode-badge.stats-mode-sim {
|
||||
color: #67e8f9;
|
||||
border-color: rgba(34, 211, 238, 0.35);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
.stats-mode-badge.stats-mode-live {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
.stats-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1618,11 +1889,19 @@ input.readonly {
|
||||
background: rgba(4, 10, 18, 0.65);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stats-chart-wrap--large {
|
||||
min-height: 480px;
|
||||
height: min(62vh, 560px);
|
||||
}
|
||||
#stats-curve-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.stats-chart-wrap--large #stats-curve-canvas {
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
.stats-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1666,3 +1945,399 @@ input.readonly {
|
||||
body.page-stats .main {
|
||||
max-width: 1800px;
|
||||
}
|
||||
|
||||
/* —— 手机单独壳:电脑样式不动,仅 html.hub-phone-html 启用 —— */
|
||||
.hub-phone-chrome {
|
||||
display: none;
|
||||
}
|
||||
html.hub-phone-html .hub-desk-chrome {
|
||||
display: none !important;
|
||||
}
|
||||
html.hub-phone-html .hub-phone-bar {
|
||||
display: flex;
|
||||
}
|
||||
html.hub-phone-html .hub-phone-tabbar {
|
||||
display: flex;
|
||||
}
|
||||
html.hub-phone-html .hub-phone-more {
|
||||
display: block;
|
||||
}
|
||||
html.hub-phone-html body {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
html.hub-phone-html body.page-desk {
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
html.hub-phone-html body.page-desk .main {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: block;
|
||||
padding: 10px 14px 20px;
|
||||
}
|
||||
html.hub-phone-html .main {
|
||||
max-width: none;
|
||||
padding: 10px 14px 20px;
|
||||
}
|
||||
html.hub-phone-html .topbar-logout {
|
||||
display: none;
|
||||
}
|
||||
.hub-phone-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 8px 0 12px;
|
||||
padding: 8px 4px 10px;
|
||||
border-bottom: 1px solid rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
.hub-phone-bar-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #dbe4ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hub-phone-quotes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hub-phone-quotes .nav-spot-row {
|
||||
grid-template-columns: 16px minmax(4.2em, auto) minmax(3.4em, auto);
|
||||
padding: 1px 6px 1px 4px;
|
||||
font-size: 0.72rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.hub-phone-tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px 8px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(10, 16, 28, 0.96);
|
||||
border-top: 1px solid rgba(56, 189, 248, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.hub-phone-tab {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8aa0b8;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hub-phone-tab.is-active,
|
||||
.hub-phone-tab[aria-expanded="true"] {
|
||||
color: #e8f1ff;
|
||||
}
|
||||
.hub-phone-more {
|
||||
display: none;
|
||||
}
|
||||
html.hub-phone-html .hub-phone-more {
|
||||
pointer-events: none;
|
||||
}
|
||||
html.hub-phone-html.hub-phone-more-open .hub-phone-more,
|
||||
html.hub-phone-html body.hub-phone-more-open .hub-phone-more {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.hub-phone-more-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 45;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
body.hub-phone-more-open .hub-phone-more-backdrop {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.hub-phone-more-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 46;
|
||||
max-height: min(78vh, 560px);
|
||||
overflow: auto;
|
||||
padding: 10px 16px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: #101828;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
transform: translateY(110%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
body.hub-phone-more-open .hub-phone-more-sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.hub-phone-more-handle {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
margin: 4px auto 10px;
|
||||
border-radius: 99px;
|
||||
background: #3a4568;
|
||||
}
|
||||
.hub-phone-more-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.hub-phone-more-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.hub-phone-more-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.hub-phone-more-nav a {
|
||||
display: block;
|
||||
padding: 12px 12px;
|
||||
border-radius: 10px;
|
||||
background: #151a2a;
|
||||
border: 1px solid #304164;
|
||||
color: #dbe4ff;
|
||||
}
|
||||
.hub-phone-more-nav a.is-active {
|
||||
background: #2a3f6c;
|
||||
}
|
||||
.hub-phone-more-close {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #304164;
|
||||
background: transparent;
|
||||
color: #8aa0b8;
|
||||
font: inherit;
|
||||
}
|
||||
html.hub-phone-html .page-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
html.hub-phone-html .page-head h1 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
html.hub-phone-html .overview-bj-clock {
|
||||
display: none;
|
||||
}
|
||||
html.hub-phone-html .overview-summary {
|
||||
display: none !important;
|
||||
}
|
||||
html.hub-phone-html #overview-status {
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.hub-phone-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.hub-phone-card {
|
||||
background: rgba(16, 24, 40, 0.92);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 14px;
|
||||
}
|
||||
.hub-phone-card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hub-phone-card-head strong {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.hub-phone-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.hub-phone-card-pnl {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hub-phone-card-pnl .muted {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hub-phone-card-pnl span:not(.muted) {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hub-phone-card-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hub-phone-card-kpis .muted {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hub-phone-card-pos {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hub-phone-pos-line + .hub-phone-pos-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hub-phone-card-foot {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hub-phone-card-err {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.hub-phone-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
html.hub-phone-html .desk {
|
||||
display: none !important;
|
||||
}
|
||||
.hub-phone-desk {
|
||||
display: none;
|
||||
}
|
||||
html.hub-phone-html .hub-phone-desk {
|
||||
display: block;
|
||||
}
|
||||
.hub-phone-desk-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.hub-phone-desk-open {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
html.hub-phone-html .settings-row-2,
|
||||
html.hub-phone-html .settings-grid {
|
||||
display: block;
|
||||
}
|
||||
html.hub-phone-html .inst-table,
|
||||
html.hub-phone-html .backup-table,
|
||||
html.hub-phone-html .stats-table {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
html.hub-phone-html .table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-lead {
|
||||
display: none;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-card {
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-card-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-create-split {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-param-row,
|
||||
html.hub-phone-html body.page-cond-orders .cond-exit-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-field-mult input {
|
||||
width: 100%;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
html.hub-phone-html body.page-cond-orders .cond-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.hub-phone-cond-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hub-phone-cond {
|
||||
padding: 12px 12px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(8, 16, 30, 0.72);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.hub-phone-cond:hover {
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
.hub-phone-cond-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.hub-phone-cond-head strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hub-phone-cond-main {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hub-phone-cond-meta,
|
||||
.hub-phone-cond-msg {
|
||||
margin-top: 4px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hub-phone-cond-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
html.hub-phone-html .login-wrap {
|
||||
padding: 48px 16px 24px;
|
||||
}
|
||||
html.hub-phone-html .login-card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -207,17 +207,26 @@
|
||||
|
||||
function isLiquidityWeak(p) {
|
||||
if (!p) return false;
|
||||
if (p.liquidity_ok === false || p.bid_invalid) return true;
|
||||
// 仅明确残档/流动性不足时标黄;liquidity_ok==null 表示未同步,不标黄
|
||||
if (p.bid_invalid || p.liquidity_ok === false) return true;
|
||||
const uncovered = Number(p.uncovered_sheets);
|
||||
return Number.isFinite(uncovered) && uncovered > 0;
|
||||
}
|
||||
|
||||
function bidLiquidityLine(p) {
|
||||
if (p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))) {
|
||||
const noBid = p.bid_px == null && (p.bid_sz == null || p.bid_sz === "");
|
||||
if (p.bid_invalid) {
|
||||
const reason = (p.bid_invalid_reason || "无买一/残档").toString();
|
||||
const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason;
|
||||
return { text: short, weak: true };
|
||||
}
|
||||
if (noBid) {
|
||||
// 已判定流动性不足 → 标黄;否则视为未同步,显示 —
|
||||
if (p.liquidity_ok === false) {
|
||||
return { text: "无买一", weak: true };
|
||||
}
|
||||
return { text: "—", weak: false };
|
||||
}
|
||||
const px = fmtBidPx(p.bid_px);
|
||||
const sz = p.bid_sz != null ? String(p.bid_sz) : "—";
|
||||
const uncovered = Number(p.uncovered_sheets);
|
||||
@@ -298,6 +307,177 @@
|
||||
);
|
||||
}
|
||||
|
||||
function isHubPhone() {
|
||||
return (
|
||||
document.documentElement.classList.contains("hub-phone-html") ||
|
||||
!!(document.body && document.body.classList.contains("hub-phone"))
|
||||
);
|
||||
}
|
||||
|
||||
function openClient(instanceId) {
|
||||
if (!instanceId) return;
|
||||
fetch("/api/instances/" + encodeURIComponent(instanceId) + "/open_url")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (d && d.url) {
|
||||
window.location.href = d.url;
|
||||
return;
|
||||
}
|
||||
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
|
||||
})
|
||||
.catch(function () {
|
||||
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
|
||||
});
|
||||
}
|
||||
|
||||
function bindOverviewActions(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll("[data-close-opt]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
requestClose(btn);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-open-inst]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
openClient(btn.getAttribute("data-open-inst"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shortOptName(id) {
|
||||
const s = String(id || "").trim();
|
||||
if (!s) return "—";
|
||||
const parts = s.split("-");
|
||||
if (parts.length >= 5) {
|
||||
const base = parts[0];
|
||||
const strike = parts[3];
|
||||
const ot = String(parts[4] || "").toUpperCase();
|
||||
const side = ot === "P" ? "Put" : ot === "C" ? "Call" : ot;
|
||||
return base + " " + side + " " + strike;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatExitBriefPhone(p) {
|
||||
if (!p) return "";
|
||||
if (p.target_monitor_text && String(p.target_monitor_text).trim() && p.target_monitor_text !== "—") {
|
||||
return String(p.target_monitor_text).trim();
|
||||
}
|
||||
const tgt = Number(p.target_index);
|
||||
if (Number.isFinite(tgt) && tgt > 0) {
|
||||
const ot = String(p.opt_type || "").toUpperCase();
|
||||
const side = ot === "P" ? "Put≤" : ot === "C" ? "Call≥" : "目标";
|
||||
return side + tgt;
|
||||
}
|
||||
if (p.profit_exit_enabled) {
|
||||
const m = Number(p.profit_exit_mult);
|
||||
if (Number.isFinite(m) && m > 0) {
|
||||
return (Math.abs(m - Math.round(m)) < 1e-9 ? Math.round(m) : m) + "倍出场";
|
||||
}
|
||||
return "利润出场";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function phonePosLine(p) {
|
||||
const sheets = p.sheets != null ? String(p.sheets) : "—";
|
||||
const lev = Number(p.contract_leverage != null ? p.contract_leverage : p.leverage);
|
||||
const levTxt = Number.isFinite(lev) && lev > 0 ? " · " + lev.toFixed(1) + "×" : "";
|
||||
return escapeHtml(shortOptName(p.inst_id) + " · " + sheets + "张" + levTxt);
|
||||
}
|
||||
|
||||
function phonePositionBlock(brief, cond) {
|
||||
const list = brief || [];
|
||||
if (!list.length) {
|
||||
if (cond) {
|
||||
return (
|
||||
'<div class="hub-phone-card-pos">' +
|
||||
escapeHtml(condWaitLine(cond)) +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
return '<div class="hub-phone-card-pos muted">暂无持仓</div>';
|
||||
}
|
||||
const shown = list.slice(0, 3).map(function (p) {
|
||||
return '<div class="hub-phone-pos-line">' + phonePosLine(p) + "</div>";
|
||||
});
|
||||
if (list.length > 3) {
|
||||
shown.push('<div class="muted">还有 ' + (list.length - 3) + " 笔</div>");
|
||||
}
|
||||
return '<div class="hub-phone-card-pos">' + shown.join("") + "</div>";
|
||||
}
|
||||
|
||||
function phoneFootLine(brief) {
|
||||
const list = brief || [];
|
||||
if (!list.length) return "";
|
||||
const bits = [];
|
||||
const exitText = formatExitBriefPhone(list[0]);
|
||||
if (exitText) bits.push(exitText);
|
||||
const liq = bidLiquidityLine(list[0]);
|
||||
if (liq && liq.text) bits.push((liq.weak ? "买一弱 " : "") + liq.text);
|
||||
if (!bits.length) return "";
|
||||
const cls = liq && liq.weak ? "hub-phone-card-foot liq-warn" : "hub-phone-card-foot muted";
|
||||
return '<div class="' + cls + '">' + escapeHtml(bits.join(" · ")) + "</div>";
|
||||
}
|
||||
|
||||
function renderPhoneCard(row) {
|
||||
const snap = row.snapshot || {};
|
||||
const inst = snap.instance || {};
|
||||
const pnl =
|
||||
snap.pnl && snap.pnl.unrealized_usdt != null
|
||||
? snap.pnl.unrealized_usdt
|
||||
: snap.upl_total_usdc;
|
||||
const brief = snap.positions_brief || snap.positions || [];
|
||||
const cond = activeCondOrder(snap);
|
||||
const online = !!row.online;
|
||||
const m = String(inst.mode || "").toLowerCase();
|
||||
const modeHtml =
|
||||
m === "sim" || m === "live"
|
||||
? '<span class="overview-mode-tag overview-mode-tag--' + m + '">' + escapeHtml(m) + "</span>"
|
||||
: inst.mode
|
||||
? '<span class="muted">' + escapeHtml(inst.mode) + "</span>"
|
||||
: "";
|
||||
const err = row.error
|
||||
? '<div class="muted hub-phone-card-err">错误: ' + escapeHtml(row.error) + "</div>"
|
||||
: "";
|
||||
return (
|
||||
'<article class="hub-phone-card">' +
|
||||
'<div class="hub-phone-card-head">' +
|
||||
"<strong>" +
|
||||
escapeHtml(row.name || "") +
|
||||
"</strong>" +
|
||||
'<div class="hub-phone-card-tags">' +
|
||||
(online ? '<span class="status-on">在线</span>' : '<span class="status-off">离线</span>') +
|
||||
modeHtml +
|
||||
condTagHtml(cond) +
|
||||
"</div></div>" +
|
||||
err +
|
||||
'<div class="hub-phone-card-pnl">' +
|
||||
'<span class="muted">浮盈</span>' +
|
||||
fmtPnlHtml(pnl) +
|
||||
"</div>" +
|
||||
'<div class="hub-phone-card-kpis">' +
|
||||
"<div><span class=\"muted\">权利金</span>" +
|
||||
premiumCell(snap) +
|
||||
"</div>" +
|
||||
"<div><span class=\"muted\">占比</span>" +
|
||||
premiumRatioCell(snap) +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
phonePositionBlock(brief, cond) +
|
||||
phoneFootLine(brief) +
|
||||
'<div class="hub-phone-card-actions">' +
|
||||
'<button type="button" class="btn" data-open-inst="' +
|
||||
escapeHtml(String(row.id || "")) +
|
||||
'">打开</button>' +
|
||||
closeActionButtons(brief, row.id, online) +
|
||||
"</div>" +
|
||||
"</article>"
|
||||
);
|
||||
}
|
||||
|
||||
function renderRow(row) {
|
||||
const snap = row.snapshot || {};
|
||||
const inst = snap.instance || {};
|
||||
@@ -401,7 +581,7 @@
|
||||
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
|
||||
if (dd.fully_closed) msg += " · 已全平";
|
||||
alert(msg);
|
||||
await refresh();
|
||||
await requestOverviewRefresh();
|
||||
} else {
|
||||
alert((dd && dd.msg) || "平仓失败");
|
||||
btn.disabled = false;
|
||||
@@ -579,21 +759,38 @@
|
||||
return best;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let overviewVersion = 0;
|
||||
let overviewLoading = false;
|
||||
let overviewEventSource = null;
|
||||
let overviewReconnectTimer = null;
|
||||
|
||||
async function refresh(opts) {
|
||||
opts = opts || {};
|
||||
const silent = !!opts.silent;
|
||||
const status = document.getElementById("overview-status");
|
||||
const grid = document.getElementById("overview-grid");
|
||||
if (!grid) return;
|
||||
if (status) status.textContent = "刷新中…";
|
||||
if (overviewLoading) return;
|
||||
overviewLoading = true;
|
||||
if (status && !silent) status.textContent = "读取缓存…";
|
||||
try {
|
||||
const r = await fetch("/api/overview");
|
||||
// 只读中控热缓存;后台常驻并行 pull,不在此请求内打实例
|
||||
const r = await fetch("/api/overview", { credentials: "same-origin" });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.msg || "失败");
|
||||
if (!d.ok) throw new Error(d.msg || d.error || "失败");
|
||||
overviewVersion = Number(d.overview_version) || overviewVersion;
|
||||
const list = sortInstancesByMode(d.instances || []);
|
||||
renderOverviewSummary(list);
|
||||
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
|
||||
if (!list.length) {
|
||||
grid.innerHTML =
|
||||
'<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
|
||||
d.pulling || !d.updated_at
|
||||
? '<p class="muted">后台首次拉取中…</p>'
|
||||
: '<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
|
||||
} else if (isHubPhone()) {
|
||||
grid.innerHTML =
|
||||
'<div class="hub-phone-cards">' + list.map(renderPhoneCard).join("") + "</div>";
|
||||
bindOverviewActions(grid);
|
||||
} else {
|
||||
grid.innerHTML =
|
||||
'<div class="overview-table-wrap"><table class="overview-table">' +
|
||||
@@ -602,23 +799,24 @@
|
||||
"</tr></thead><tbody>" +
|
||||
list.map(renderRow).join("") +
|
||||
"</tbody></table></div>";
|
||||
grid.querySelectorAll("[data-close-opt]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
requestClose(btn);
|
||||
});
|
||||
});
|
||||
bindOverviewActions(grid);
|
||||
}
|
||||
if (status) {
|
||||
const s = summarizeOverview(list);
|
||||
const pulled = latestPulledAt(list);
|
||||
const pulled = latestPulledAt(list) || d.updated_at || "";
|
||||
const sec = Number(d.poll_interval_sec) || 10;
|
||||
const live = overviewEventSource && overviewEventSource.readyState === 1;
|
||||
status.textContent =
|
||||
"已更新 · 共 " +
|
||||
(live ? "SSE 已连接 · " : "") +
|
||||
"缓存 " +
|
||||
list.length +
|
||||
" 台 · 在线 " +
|
||||
s.all.online +
|
||||
" · 离线 " +
|
||||
s.all.offline +
|
||||
(pulled ? " · 拉取时间 " + pulled : "");
|
||||
(pulled ? " · " + pulled : "") +
|
||||
(d.pulling ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉") +
|
||||
(d.error ? " · " + d.error : "");
|
||||
}
|
||||
} catch (e) {
|
||||
if (status) status.textContent = e.message || "加载失败";
|
||||
@@ -628,9 +826,60 @@
|
||||
box.innerHTML = "";
|
||||
}
|
||||
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]);
|
||||
} finally {
|
||||
overviewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeOverviewStream() {
|
||||
if (overviewEventSource) {
|
||||
overviewEventSource.close();
|
||||
overviewEventSource = null;
|
||||
}
|
||||
if (overviewReconnectTimer) {
|
||||
clearTimeout(overviewReconnectTimer);
|
||||
overviewReconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connectOverviewStream() {
|
||||
const grid = document.getElementById("overview-grid");
|
||||
if (!grid || typeof EventSource === "undefined") return;
|
||||
closeOverviewStream();
|
||||
overviewEventSource = new EventSource("/api/overview/stream");
|
||||
overviewEventSource.addEventListener("overview", function (ev) {
|
||||
try {
|
||||
const st = JSON.parse(ev.data || "{}");
|
||||
const ver = Number(st.overview_version) || 0;
|
||||
if (ver && ver !== overviewVersion) {
|
||||
refresh({ silent: true });
|
||||
} else if (st.pulling) {
|
||||
const status = document.getElementById("overview-status");
|
||||
if (status) status.textContent = "后台并行拉取中…";
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
overviewEventSource.onerror = function () {
|
||||
closeOverviewStream();
|
||||
const status = document.getElementById("overview-status");
|
||||
if (status) status.textContent = "SSE 断开,8s 后重连…";
|
||||
overviewReconnectTimer = setTimeout(function () {
|
||||
connectOverviewStream();
|
||||
refresh({ silent: true });
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
async function requestOverviewRefresh() {
|
||||
try {
|
||||
await fetch("/api/overview/refresh", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch (_) {}
|
||||
refresh({ silent: true });
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
|
||||
const LUNAR_INFO = [
|
||||
@@ -821,12 +1070,19 @@
|
||||
|
||||
function boot() {
|
||||
const btn = document.getElementById("btn-refresh");
|
||||
if (btn) btn.addEventListener("click", refresh);
|
||||
if (btn) btn.addEventListener("click", function () {
|
||||
requestOverviewRefresh();
|
||||
});
|
||||
tickBeijingClock();
|
||||
setInterval(tickBeijingClock, 1000);
|
||||
// 先进缓存秒开,再挂 SSE;不再前端定时现场拉实例
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
connectOverviewStream();
|
||||
}
|
||||
|
||||
global.HubOverview = { boot: boot, refresh: refresh };
|
||||
global.HubOverview = {
|
||||
boot: boot,
|
||||
refresh: refresh,
|
||||
requestRefresh: requestOverviewRefresh,
|
||||
};
|
||||
})(window);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
(function (global) {
|
||||
var MQ = "(max-width: 720px)";
|
||||
|
||||
function isMobile() {
|
||||
try {
|
||||
return window.matchMedia(MQ).matches;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
var on = isMobile();
|
||||
document.documentElement.classList.toggle("hub-phone-html", on);
|
||||
if (document.body) document.body.classList.toggle("hub-phone", on);
|
||||
if (!on) closeMore();
|
||||
}
|
||||
|
||||
function closeMore() {
|
||||
if (!document.body) return;
|
||||
document.body.classList.remove("hub-phone-more-open");
|
||||
var more = document.getElementById("hub-phone-more");
|
||||
var btn = document.getElementById("hub-phone-more-btn");
|
||||
if (more) more.setAttribute("aria-hidden", "true");
|
||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function openMore() {
|
||||
if (!isMobile()) return;
|
||||
document.body.classList.add("hub-phone-more-open");
|
||||
var more = document.getElementById("hub-phone-more");
|
||||
var btn = document.getElementById("hub-phone-more-btn");
|
||||
if (more) more.setAttribute("aria-hidden", "false");
|
||||
if (btn) btn.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
function boot() {
|
||||
sync();
|
||||
var moreBtn = document.getElementById("hub-phone-more-btn");
|
||||
var backdrop = document.getElementById("hub-phone-more-backdrop");
|
||||
var closeBtn = document.getElementById("hub-phone-more-close");
|
||||
if (moreBtn) {
|
||||
moreBtn.addEventListener("click", function (ev) {
|
||||
ev.preventDefault();
|
||||
if (document.body.classList.contains("hub-phone-more-open")) closeMore();
|
||||
else openMore();
|
||||
});
|
||||
}
|
||||
if (backdrop) backdrop.addEventListener("click", closeMore);
|
||||
if (closeBtn) closeBtn.addEventListener("click", closeMore);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") closeMore();
|
||||
});
|
||||
var t = null;
|
||||
window.addEventListener("resize", function () {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(sync, 120);
|
||||
});
|
||||
}
|
||||
|
||||
global.HubPhone = { boot: boot, isMobile: isMobile, sync: sync };
|
||||
})(window);
|
||||
@@ -0,0 +1,7 @@
|
||||
(function (global) {
|
||||
function boot() {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {});
|
||||
}
|
||||
global.HubPwa = { boot: boot };
|
||||
})(window);
|
||||
@@ -77,11 +77,7 @@
|
||||
return best;
|
||||
}
|
||||
|
||||
function paint(list) {
|
||||
const root = document.getElementById("nav-spot-tickers");
|
||||
if (!root) return;
|
||||
const src = pickQuoteSource(list);
|
||||
const tickers = src ? src.tickers : [];
|
||||
function paintRoot(root, tickers, src) {
|
||||
["ETH", "BTC"].forEach(function (base) {
|
||||
const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]');
|
||||
if (!row) return;
|
||||
@@ -119,6 +115,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function paint(list) {
|
||||
const src = pickQuoteSource(list);
|
||||
const tickers = src ? src.tickers : [];
|
||||
document.querySelectorAll("[data-hub-quotes]").forEach(function (root) {
|
||||
paintRoot(root, tickers, src);
|
||||
});
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
let inflight = false;
|
||||
|
||||
@@ -137,7 +141,7 @@
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!document.getElementById("nav-spot-tickers")) return;
|
||||
if (!document.querySelector("[data-hub-quotes]")) return;
|
||||
if (global.HubOverview) return;
|
||||
refresh();
|
||||
if (timer) clearInterval(timer);
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="70%">
|
||||
<stop offset="0%" stop-color="#0b1a38"/>
|
||||
<stop offset="100%" stop-color="#05080f"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f6e27a"/>
|
||||
<stop offset="55%" stop-color="#d4af37"/>
|
||||
<stop offset="100%" stop-color="#9a6b12"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="108" fill="url(#bg)"/>
|
||||
<rect x="22" y="22" width="468" height="468" rx="92" fill="none" stroke="#d4af37" stroke-width="8"/>
|
||||
<circle cx="256" cy="256" r="168" fill="none" stroke="#1b4f78" stroke-width="2" stroke-dasharray="4 10"/>
|
||||
<circle cx="256" cy="256" r="118" fill="none" stroke="#22d3ee" stroke-width="14"/>
|
||||
<polygon points="256,52 272,214 256,232 240,214" fill="url(#gold)"/>
|
||||
<polygon points="256,460 240,298 256,280 272,298" fill="url(#gold)"/>
|
||||
<polygon points="52,256 214,240 232,256 214,272" fill="url(#gold)"/>
|
||||
<polygon points="460,256 298,272 280,256 298,240" fill="url(#gold)"/>
|
||||
<polygon points="256,176 336,256 256,336 176,256" fill="url(#gold)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "加密货币|OKX 期权中控",
|
||||
"short_name": "期权中控",
|
||||
"description": "多机期权持仓总览与工作台",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#070b14",
|
||||
"theme_color": "#070b14",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png?v=2",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png?v=2",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192-maskable.png?v=2",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512-maskable.png?v=2",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,330 +1,529 @@
|
||||
(function () {
|
||||
const COLORS = [
|
||||
"#22d3ee",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#fb7185",
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#f472b6",
|
||||
"#2dd4bf",
|
||||
];
|
||||
|
||||
let windowKey = "7d";
|
||||
let curveData = null;
|
||||
let summaryData = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtNum(v, digits) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(digits == null ? 2 : digits);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
|
||||
return n > 0 ? "pnl-up" : "pnl-down";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
if (sec == null || sec === "") return "—";
|
||||
const n = Number(sec);
|
||||
if (!Number.isFinite(n) || n < 0) return "—";
|
||||
if (n < 60) return Math.round(n) + "s";
|
||||
if (n < 3600) return (n / 60).toFixed(1) + "m";
|
||||
return (n / 3600).toFixed(1) + "h";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = document.getElementById("stats-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.classList.toggle("err", !!isErr);
|
||||
}
|
||||
|
||||
function renderKpis(totals) {
|
||||
const grid = document.getElementById("stats-kpi-grid");
|
||||
if (!grid) return;
|
||||
const t = totals || {};
|
||||
const items = [
|
||||
{ label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
|
||||
{ label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
|
||||
{ label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
|
||||
{ label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
|
||||
{ label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
|
||||
{ label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
|
||||
];
|
||||
grid.innerHTML = items
|
||||
.map(function (it) {
|
||||
return (
|
||||
'<div class="stats-kpi-card">' +
|
||||
'<div class="stats-kpi-label">' +
|
||||
escapeHtml(it.label) +
|
||||
"</div>" +
|
||||
'<div class="stats-kpi-value ' +
|
||||
escapeHtml(it.cls || "") +
|
||||
'">' +
|
||||
escapeHtml(it.value) +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTable(machines) {
|
||||
const body = document.getElementById("stats-table-body");
|
||||
if (!body) return;
|
||||
const list = machines || [];
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list
|
||||
.map(function (m) {
|
||||
const status = m.online
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = m.error
|
||||
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||
: "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(m.name || m.id) +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.premium_usdt)) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.unrealized_usdt) +
|
||||
'">' +
|
||||
escapeHtml(fmtPnl(m.unrealized_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.net_realized_pnl) +
|
||||
'">' +
|
||||
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||
"</td>" +
|
||||
'<td><a class="btn btn-sm" href="/desk?id=' +
|
||||
encodeURIComponent(String(m.id)) +
|
||||
'">工作台</a></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function drawCurve(data) {
|
||||
const canvas = document.getElementById("stats-curve-canvas");
|
||||
const empty = document.getElementById("stats-curve-empty");
|
||||
const legend = document.getElementById("stats-curve-legend");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.clientWidth || 960;
|
||||
const cssH = 320;
|
||||
canvas.width = Math.floor(cssW * dpr);
|
||||
canvas.height = Math.floor(cssH * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked;
|
||||
const lines = [];
|
||||
if (data && data.total && data.total.length) {
|
||||
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 });
|
||||
}
|
||||
if (showMachines && data && data.series) {
|
||||
data.series.forEach(function (s, i) {
|
||||
if (!s.points || !s.points.length) return;
|
||||
lines.push({
|
||||
name: s.name || String(s.id),
|
||||
color: COLORS[i % COLORS.length],
|
||||
points: s.points,
|
||||
width: 1.4,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (legend) {
|
||||
legend.innerHTML = lines
|
||||
.map(function (ln) {
|
||||
return (
|
||||
'<span class="stats-legend-item"><i style="background:' +
|
||||
ln.color +
|
||||
'"></i>' +
|
||||
escapeHtml(ln.name) +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const allPts = lines.reduce(function (acc, ln) {
|
||||
return acc.concat(ln.points || []);
|
||||
}, []);
|
||||
if (!allPts.length) {
|
||||
if (empty) empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
let minT = Math.min.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.t;
|
||||
})
|
||||
);
|
||||
let maxT = Math.max.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.t;
|
||||
})
|
||||
);
|
||||
let minV = Math.min.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.v;
|
||||
})
|
||||
);
|
||||
let maxV = Math.max.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.v;
|
||||
})
|
||||
);
|
||||
if (maxT <= minT) maxT = minT + 1;
|
||||
if (maxV <= minV) {
|
||||
maxV = minV + 1;
|
||||
minV = minV - 1;
|
||||
}
|
||||
const pad = { l: 52, r: 16, t: 16, b: 28 };
|
||||
const w = cssW - pad.l - pad.r;
|
||||
const h = cssH - pad.t - pad.b;
|
||||
|
||||
function xOf(t) {
|
||||
return pad.l + ((t - minT) / (maxT - minT)) * w;
|
||||
}
|
||||
function yOf(v) {
|
||||
return pad.t + (1 - (v - minV) / (maxV - minV)) * h;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(56,189,248,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.t + (h * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, y);
|
||||
ctx.lineTo(pad.l + w, y);
|
||||
ctx.stroke();
|
||||
const val = maxV - ((maxV - minV) * i) / 4;
|
||||
ctx.fillStyle = "#8aa0b8";
|
||||
ctx.font = "11px IBM Plex Sans, sans-serif";
|
||||
ctx.fillText(val.toFixed(0), 8, y + 4);
|
||||
}
|
||||
|
||||
lines.forEach(function (ln) {
|
||||
const pts = ln.points || [];
|
||||
if (pts.length < 1) return;
|
||||
ctx.strokeStyle = ln.color;
|
||||
ctx.lineWidth = ln.width || 1.5;
|
||||
ctx.beginPath();
|
||||
pts.forEach(function (p, idx) {
|
||||
const x = xOf(p.t);
|
||||
const y = yOf(p.v);
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setStatus("加载中…");
|
||||
try {
|
||||
const [sumRes, curveRes] = await Promise.all([
|
||||
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)),
|
||||
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)),
|
||||
]);
|
||||
const sum = await sumRes.json();
|
||||
const curve = await curveRes.json();
|
||||
if (!sum.ok) throw new Error(sum.msg || "汇总失败");
|
||||
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
|
||||
summaryData = sum;
|
||||
curveData = curve;
|
||||
renderKpis(sum.totals);
|
||||
renderTable(sum.machines);
|
||||
drawCurve(curve);
|
||||
setStatus(
|
||||
"窗口 " +
|
||||
windowKey +
|
||||
" · 在线 " +
|
||||
(sum.totals.online || 0) +
|
||||
"/" +
|
||||
(sum.totals.machines || 0) +
|
||||
" · 曲线采样点以巡检累积"
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const tabs = document.getElementById("stats-window-tabs");
|
||||
if (tabs) {
|
||||
tabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest("[data-window]");
|
||||
if (!btn) return;
|
||||
windowKey = btn.getAttribute("data-window") || "7d";
|
||||
tabs.querySelectorAll(".stats-win-btn").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === btn);
|
||||
});
|
||||
loadAll();
|
||||
});
|
||||
}
|
||||
const toggle = document.getElementById("stats-show-machines");
|
||||
if (toggle) {
|
||||
toggle.addEventListener("change", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
loadAll();
|
||||
})();
|
||||
(function () {
|
||||
const COLORS = [
|
||||
"#22d3ee",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#fb7185",
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#f472b6",
|
||||
"#2dd4bf",
|
||||
];
|
||||
const PICKER_KEY = "hub-stats-curve-machines";
|
||||
|
||||
let windowKey = "7d";
|
||||
let curveData = null;
|
||||
let summaryData = null;
|
||||
let selectedIds = loadSelectedIds();
|
||||
let statsVersion = 0;
|
||||
let statsLoading = false;
|
||||
let statsEventSource = null;
|
||||
let statsReconnectTimer = null;
|
||||
|
||||
function loadSelectedIds() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PICKER_KEY);
|
||||
if (!raw) return null;
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr)) return new Set(arr.map(String));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveSelectedIds() {
|
||||
try {
|
||||
localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || [])));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtNum(v, digits) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(digits == null ? 2 : digits);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
|
||||
return n > 0 ? "pnl-up" : "pnl-down";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
if (sec == null || sec === "") return "—";
|
||||
const n = Number(sec);
|
||||
if (!Number.isFinite(n) || n < 0) return "—";
|
||||
if (n < 60) return Math.round(n) + "s";
|
||||
if (n < 3600) return (n / 60).toFixed(1) + "m";
|
||||
return (n / 3600).toFixed(1) + "h";
|
||||
}
|
||||
|
||||
function modeLabel(mode) {
|
||||
const m = String(mode || "").toLowerCase();
|
||||
if (m === "sim") return "sim";
|
||||
if (m === "live") return "live";
|
||||
return mode ? String(mode) : "—";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = document.getElementById("stats-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.classList.toggle("err", !!isErr);
|
||||
}
|
||||
|
||||
function renderKpis(totals) {
|
||||
const grid = document.getElementById("stats-kpi-grid");
|
||||
if (!grid) return;
|
||||
const t = totals || {};
|
||||
const items = [
|
||||
{ label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
|
||||
{ label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
|
||||
{ label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
|
||||
{ label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
|
||||
{ label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
|
||||
{ label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
|
||||
];
|
||||
grid.innerHTML = items
|
||||
.map(function (it) {
|
||||
return (
|
||||
'<div class="stats-kpi-card">' +
|
||||
'<div class="stats-kpi-label">' +
|
||||
escapeHtml(it.label) +
|
||||
"</div>" +
|
||||
'<div class="stats-kpi-value ' +
|
||||
escapeHtml(it.cls || "") +
|
||||
'">' +
|
||||
escapeHtml(it.value) +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function sortMachines(machines) {
|
||||
return (machines || []).slice().sort(function (a, b) {
|
||||
function rank(m) {
|
||||
const mode = String((m && m.mode) || "").toLowerCase();
|
||||
if (mode === "sim") return 0;
|
||||
if (mode === "live") return 1;
|
||||
return 2;
|
||||
}
|
||||
const d = rank(a) - rank(b);
|
||||
if (d !== 0) return d;
|
||||
return String((a && a.name) || "").localeCompare(String((b && b.name) || ""), "zh");
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(machines) {
|
||||
const body = document.getElementById("stats-table-body");
|
||||
if (!body) return;
|
||||
const list = sortMachines(machines);
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list
|
||||
.map(function (m) {
|
||||
const status = m.online
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = m.error
|
||||
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||
: "";
|
||||
const mode = String(m.mode || "").toLowerCase();
|
||||
const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(m.name || m.id) +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
'<td><span class="stats-mode-badge ' +
|
||||
modeCls +
|
||||
'">' +
|
||||
escapeHtml(modeLabel(m.mode)) +
|
||||
"</span></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.premium_usdt)) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.unrealized_usdt) +
|
||||
'">' +
|
||||
escapeHtml(fmtPnl(m.unrealized_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.net_realized_pnl) +
|
||||
'">' +
|
||||
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.return_rate_pct) +
|
||||
'">' +
|
||||
escapeHtml(
|
||||
m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
|
||||
) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||
"</td>" +
|
||||
'<td><a class="btn btn-sm" href="/desk?id=' +
|
||||
encodeURIComponent(String(m.id)) +
|
||||
'">工作台</a></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function ensureSelectedIds(series) {
|
||||
const ids = (series || []).map(function (s) {
|
||||
return String(s.id);
|
||||
});
|
||||
if (selectedIds == null) {
|
||||
selectedIds = new Set(ids);
|
||||
saveSelectedIds();
|
||||
return;
|
||||
}
|
||||
// 新机器默认勾选
|
||||
ids.forEach(function (id) {
|
||||
if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) {
|
||||
selectedIds.add(id);
|
||||
try {
|
||||
localStorage.setItem(PICKER_KEY + ":seen:" + id, "1");
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
saveSelectedIds();
|
||||
}
|
||||
|
||||
function renderMachinePicker(data) {
|
||||
const box = document.getElementById("stats-machine-picker");
|
||||
if (!box) return;
|
||||
const series = (data && data.series) || [];
|
||||
ensureSelectedIds(series);
|
||||
if (!series.length) {
|
||||
box.innerHTML = '<span class="muted">暂无可选机器</span>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
'<button type="button" class="stats-pick-all" data-act="all">全选</button>' +
|
||||
'<button type="button" class="stats-pick-all" data-act="none">全不选</button>' +
|
||||
series
|
||||
.map(function (s, i) {
|
||||
const id = String(s.id);
|
||||
const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
|
||||
const color = COLORS[i % COLORS.length];
|
||||
return (
|
||||
'<label class="stats-machine-chip">' +
|
||||
'<input type="checkbox" data-machine-id="' +
|
||||
escapeHtml(id) +
|
||||
'"' +
|
||||
checked +
|
||||
">" +
|
||||
'<i style="background:' +
|
||||
color +
|
||||
'"></i>' +
|
||||
escapeHtml(s.name || id) +
|
||||
"</label>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function drawCurve(data) {
|
||||
const canvas = document.getElementById("stats-curve-canvas");
|
||||
const empty = document.getElementById("stats-curve-empty");
|
||||
const legend = document.getElementById("stats-curve-legend");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.clientWidth || 960;
|
||||
const wrap = canvas.parentElement;
|
||||
const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
|
||||
canvas.width = Math.floor(cssW * dpr);
|
||||
canvas.height = Math.floor(cssH * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
|
||||
const lines = [];
|
||||
if (showTotal && data && data.total && data.total.length) {
|
||||
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
|
||||
}
|
||||
if (data && data.series) {
|
||||
data.series.forEach(function (s, i) {
|
||||
const id = String(s.id);
|
||||
if (selectedIds && !selectedIds.has(id)) return;
|
||||
if (!s.points || !s.points.length) return;
|
||||
lines.push({
|
||||
name: s.name || id,
|
||||
color: COLORS[i % COLORS.length],
|
||||
points: s.points,
|
||||
width: 1.6,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (legend) {
|
||||
legend.innerHTML = lines
|
||||
.map(function (ln) {
|
||||
return (
|
||||
'<span class="stats-legend-item"><i style="background:' +
|
||||
ln.color +
|
||||
'"></i>' +
|
||||
escapeHtml(ln.name) +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const allPts = lines.reduce(function (acc, ln) {
|
||||
return acc.concat(ln.points || []);
|
||||
}, []);
|
||||
if (!allPts.length) {
|
||||
if (empty) empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
let minT = Math.min.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.t;
|
||||
})
|
||||
);
|
||||
let maxT = Math.max.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.t;
|
||||
})
|
||||
);
|
||||
let minV = Math.min.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.v;
|
||||
})
|
||||
);
|
||||
let maxV = Math.max.apply(
|
||||
null,
|
||||
allPts.map(function (p) {
|
||||
return p.v;
|
||||
})
|
||||
);
|
||||
if (maxT <= minT) maxT = minT + 1;
|
||||
if (maxV <= minV) {
|
||||
maxV = minV + 1;
|
||||
minV = minV - 1;
|
||||
}
|
||||
const pad = { l: 56, r: 18, t: 18, b: 32 };
|
||||
const w = cssW - pad.l - pad.r;
|
||||
const h = cssH - pad.t - pad.b;
|
||||
|
||||
function xOf(t) {
|
||||
return pad.l + ((t - minT) / (maxT - minT)) * w;
|
||||
}
|
||||
function yOf(v) {
|
||||
return pad.t + (1 - (v - minV) / (maxV - minV)) * h;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(56,189,248,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = pad.t + (h * i) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, y);
|
||||
ctx.lineTo(pad.l + w, y);
|
||||
ctx.stroke();
|
||||
const val = maxV - ((maxV - minV) * i) / 5;
|
||||
ctx.fillStyle = "#8aa0b8";
|
||||
ctx.font = "11px IBM Plex Sans, sans-serif";
|
||||
ctx.fillText(val.toFixed(0), 8, y + 4);
|
||||
}
|
||||
|
||||
lines.forEach(function (ln) {
|
||||
const pts = ln.points || [];
|
||||
if (pts.length < 1) return;
|
||||
ctx.strokeStyle = ln.color;
|
||||
ctx.lineWidth = ln.width || 1.5;
|
||||
ctx.beginPath();
|
||||
pts.forEach(function (p, idx) {
|
||||
const x = xOf(p.t);
|
||||
const y = yOf(p.v);
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAll(opts) {
|
||||
opts = opts || {};
|
||||
if (statsLoading) return;
|
||||
statsLoading = true;
|
||||
if (!opts.silent) setStatus("读取缓存…");
|
||||
try {
|
||||
const [sumRes, curveRes] = await Promise.all([
|
||||
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
]);
|
||||
const sum = await sumRes.json();
|
||||
const curve = await curveRes.json();
|
||||
if (!sum.ok && !(sum.machines && sum.machines.length)) {
|
||||
if (sum.refreshing) {
|
||||
setStatus("后台首次刷新统计中…");
|
||||
return;
|
||||
}
|
||||
throw new Error(sum.msg || sum.error || "汇总失败");
|
||||
}
|
||||
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
|
||||
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
|
||||
summaryData = sum;
|
||||
curveData = curve;
|
||||
renderKpis(sum.totals || {});
|
||||
renderTable(sum.machines || []);
|
||||
renderMachinePicker(curve);
|
||||
drawCurve(curve);
|
||||
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
|
||||
const live = statsEventSource && statsEventSource.readyState === 1;
|
||||
setStatus(
|
||||
(live ? "SSE 已连接 · " : "") +
|
||||
"窗口 " +
|
||||
windowKey +
|
||||
" · 缓存 " +
|
||||
((sum.totals && sum.totals.online) || 0) +
|
||||
"/" +
|
||||
((sum.totals && sum.totals.machines) || 0) +
|
||||
(sum.updated_at ? " · " + sum.updated_at : "") +
|
||||
(sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新")
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
statsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeStatsStream() {
|
||||
if (statsEventSource) {
|
||||
statsEventSource.close();
|
||||
statsEventSource = null;
|
||||
}
|
||||
if (statsReconnectTimer) {
|
||||
clearTimeout(statsReconnectTimer);
|
||||
statsReconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connectStatsStream() {
|
||||
if (typeof EventSource === "undefined") return;
|
||||
closeStatsStream();
|
||||
statsEventSource = new EventSource("/api/stats/stream");
|
||||
statsEventSource.addEventListener("stats", function (ev) {
|
||||
try {
|
||||
const st = JSON.parse(ev.data || "{}");
|
||||
const ver = Number(st.stats_version) || 0;
|
||||
if (ver && ver !== statsVersion) {
|
||||
loadAll({ silent: true });
|
||||
} else if (st.refreshing) {
|
||||
setStatus("后台刷新统计中…");
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
statsEventSource.onerror = function () {
|
||||
closeStatsStream();
|
||||
setStatus("SSE 断开,8s 后重连…");
|
||||
statsReconnectTimer = setTimeout(function () {
|
||||
connectStatsStream();
|
||||
loadAll({ silent: true });
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const tabs = document.getElementById("stats-window-tabs");
|
||||
if (tabs) {
|
||||
tabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest("[data-window]");
|
||||
if (!btn) return;
|
||||
windowKey = btn.getAttribute("data-window") || "7d";
|
||||
tabs.querySelectorAll(".stats-win-btn").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === btn);
|
||||
});
|
||||
loadAll();
|
||||
});
|
||||
}
|
||||
const showTotal = document.getElementById("stats-show-total");
|
||||
if (showTotal) {
|
||||
showTotal.addEventListener("change", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
const picker = document.getElementById("stats-machine-picker");
|
||||
if (picker) {
|
||||
picker.addEventListener("change", function (ev) {
|
||||
const input = ev.target.closest("input[data-machine-id]");
|
||||
if (!input) return;
|
||||
const id = String(input.getAttribute("data-machine-id") || "");
|
||||
if (!selectedIds) selectedIds = new Set();
|
||||
if (input.checked) selectedIds.add(id);
|
||||
else selectedIds.delete(id);
|
||||
saveSelectedIds();
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
picker.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest("[data-act]");
|
||||
if (!btn || !curveData) return;
|
||||
const act = btn.getAttribute("data-act");
|
||||
const ids = ((curveData && curveData.series) || []).map(function (s) {
|
||||
return String(s.id);
|
||||
});
|
||||
if (act === "all") selectedIds = new Set(ids);
|
||||
else if (act === "none") selectedIds = new Set();
|
||||
saveSelectedIds();
|
||||
renderMachinePicker(curveData);
|
||||
drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
loadAll();
|
||||
connectStatsStream();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 期权中控 PWA:仅占位使可安装,请求一律走网络 */
|
||||
self.addEventListener("install", function (event) {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
@@ -2,18 +2,49 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{% block title %}加密货币|OKX 期权中控{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=36">
|
||||
<meta name="theme-color" content="#070b14">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="期权中控">
|
||||
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=54">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
if (window.matchMedia("(max-width: 720px)").matches) {
|
||||
document.documentElement.classList.add("hub-phone-html");
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path.startswith('/instances') %}page-instances{% elif request.path.startswith('/settings') %}page-settings{% elif request.path.startswith('/cond_orders') %}page-cond-orders{% elif request.path == '/' %}page-dashboard{% endif %}">
|
||||
{% if session.get('logged_in') %}
|
||||
<a href="/logout" class="topbar-logout">退出</a>
|
||||
<a href="/logout" class="topbar-logout hub-desk-chrome">退出</a>
|
||||
{% endif %}
|
||||
<main class="main">
|
||||
{% if session.get('logged_in') %}
|
||||
<a href="/" class="topbar-brand">加密货币|OKX 期权中控</a>
|
||||
<div class="topbar-row">
|
||||
<header class="hub-phone-bar hub-phone-chrome">
|
||||
<div class="hub-phone-bar-title">期权中控</div>
|
||||
<div class="hub-phone-quotes" id="nav-spot-tickers-phone" data-hub-quotes="1" aria-label="BTC ETH 报价">
|
||||
<div class="nav-spot-row" data-base="ETH">
|
||||
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
|
||||
<span class="nav-spot-last" data-field="last">—</span>
|
||||
<span class="nav-spot-pct" data-field="change_pct">—</span>
|
||||
</div>
|
||||
<div class="nav-spot-row" data-base="BTC">
|
||||
<span class="nav-spot-icon nav-spot-icon--btc" aria-hidden="true">₿</span>
|
||||
<span class="nav-spot-last" data-field="last">—</span>
|
||||
<span class="nav-spot-pct" data-field="change_pct">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<a href="/" class="topbar-brand hub-desk-chrome">加密货币|OKX 期权中控</a>
|
||||
<div class="topbar-row hub-desk-chrome">
|
||||
<nav class="topbar-nav">
|
||||
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
|
||||
<a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a>
|
||||
@@ -22,7 +53,7 @@
|
||||
<a href="/stats" class="{% if request.path.startswith('/stats') %}active{% endif %}">数据统计</a>
|
||||
<a href="/settings" class="{% if request.path.startswith('/settings') %}active{% endif %}">系统设置</a>
|
||||
</nav>
|
||||
<div class="top-nav-spot-tickers" id="nav-spot-tickers" aria-label="BTC ETH 报价" title="来自在线客户端 · 相对北京 08:00">
|
||||
<div class="top-nav-spot-tickers" id="nav-spot-tickers" data-hub-quotes="1" aria-label="BTC ETH 报价" title="来自在线客户端 · 相对北京 08:00">
|
||||
<div class="nav-spot-row" data-base="ETH">
|
||||
<span class="nav-spot-ribbon" aria-hidden="true"></span>
|
||||
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
|
||||
@@ -47,12 +78,38 @@
|
||||
{% endwith %}
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
{% if session.get('logged_in') %}
|
||||
<nav id="hub-phone-tabbar" class="hub-phone-tabbar hub-phone-chrome" aria-label="手机导航">
|
||||
<a href="/" class="hub-phone-tab{% if request.path == '/' %} is-active{% endif %}" data-hub-tab="/">总览</a>
|
||||
<a href="/cond_orders" class="hub-phone-tab{% if request.path.startswith('/cond_orders') %} is-active{% endif %}" data-hub-tab="/cond_orders">条件单</a>
|
||||
<button type="button" class="hub-phone-tab" id="hub-phone-more-btn" aria-haspopup="dialog" aria-expanded="false">更多</button>
|
||||
</nav>
|
||||
<div id="hub-phone-more" class="hub-phone-more hub-phone-chrome" aria-hidden="true">
|
||||
<div class="hub-phone-more-backdrop" id="hub-phone-more-backdrop"></div>
|
||||
<div class="hub-phone-more-sheet" role="dialog" aria-modal="true">
|
||||
<div class="hub-phone-more-handle" aria-hidden="true"></div>
|
||||
<h2 class="hub-phone-more-title">更多</h2>
|
||||
<p class="hub-phone-more-hint">次要页面 · 完整操作请用电脑</p>
|
||||
<nav class="hub-phone-more-nav">
|
||||
<a href="/instances"{% if request.path.startswith('/instances') %} class="is-active"{% endif %}>实例</a>
|
||||
<a href="/stats"{% if request.path.startswith('/stats') %} class="is-active"{% endif %}>数据统计</a>
|
||||
<a href="/settings"{% if request.path.startswith('/settings') %} class="is-active"{% endif %}>系统设置</a>
|
||||
<a href="/logout">退出登录</a>
|
||||
</nav>
|
||||
<button type="button" class="hub-phone-more-close" id="hub-phone-more-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.get('logged_in') and not request.path.startswith('/desk') %}
|
||||
<script src="{{ url_for('static', filename='hub_quotes.js') }}?v=1"></script>
|
||||
<script src="{{ url_for('static', filename='hub_quotes.js') }}?v=2"></script>
|
||||
{% endif %}
|
||||
{% block scripts %}{% endblock %}
|
||||
{% if session.get('logged_in') and not request.path.startswith('/desk') %}
|
||||
<script>if (window.HubQuotes) HubQuotes.boot();</script>
|
||||
{% endif %}
|
||||
<script src="{{ url_for('static', filename='hub_pwa.js') }}?v=2"></script>
|
||||
<script src="{{ url_for('static', filename='hub_phone.js') }}?v=1"></script>
|
||||
<script>if (window.HubPwa) HubPwa.boot();</script>
|
||||
<script>if (window.HubPhone) HubPhone.boot();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,85 +1,123 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}条件单 · 期权中控{% endblock %}
|
||||
{% block body %}
|
||||
<section class="page-head cond-head">
|
||||
<div>
|
||||
<h1>左侧条件开仓</h1>
|
||||
<p class="muted cond-lead">中控只下发;盯盘、买币、开平仓都在客户端。有仓或已占用的机器不可选。</p>
|
||||
</div>
|
||||
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
|
||||
</section>
|
||||
|
||||
<div class="cond-layout">
|
||||
<section class="panel cond-card">
|
||||
<div class="cond-card-head">
|
||||
<h2>新建条件单</h2>
|
||||
<span class="cond-chip">左侧单</span>
|
||||
</div>
|
||||
|
||||
<div class="cond-section">
|
||||
<div class="cond-section-title">入场</div>
|
||||
<div class="cond-grid">
|
||||
<label>标的
|
||||
<select id="cf-underlying">
|
||||
<option value="ETH">ETH</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>方向
|
||||
<select id="cf-opt-type">
|
||||
<option value="P">看跌 Put</option>
|
||||
<option value="C">看涨 Call</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>触发价
|
||||
<input id="cf-trigger-px" type="number" step="any" placeholder="如 3000" required>
|
||||
</label>
|
||||
<label>偏离 %
|
||||
<input id="cf-deviation" type="number" step="0.1" value="1" placeholder="1" required>
|
||||
</label>
|
||||
<label>最短到期(小时)
|
||||
<input id="cf-expiry-h" type="number" step="1" value="24" placeholder="24" required>
|
||||
</label>
|
||||
<div class="cond-trigger-hint" id="cf-trigger-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cond-section">
|
||||
<div class="cond-section-title">出场</div>
|
||||
<div class="cond-grid">
|
||||
<label>目标指数(可选)
|
||||
<input id="cf-target" type="number" step="any" placeholder="如 2800">
|
||||
</label>
|
||||
<label>翻倍倍数
|
||||
<input id="cf-pe-mult" type="number" step="0.1" value="1" placeholder="1">
|
||||
</label>
|
||||
<label class="cond-switch">
|
||||
<input id="cf-pe" type="checkbox" checked>
|
||||
<span>翻倍出场</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cond-section">
|
||||
<div class="cond-section-title">客户端</div>
|
||||
<div id="cf-instances"></div>
|
||||
</div>
|
||||
|
||||
<div class="cond-actions">
|
||||
<button type="button" class="btn" id="btn-cond-create">下发条件单</button>
|
||||
<span class="muted" id="cf-status"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel cond-card">
|
||||
<div class="cond-card-head">
|
||||
<h2>条件单列表</h2>
|
||||
</div>
|
||||
<div id="cond-list" class="muted">加载中…</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='cond_orders.js') }}"></script>
|
||||
<script>HubCondOrders.boot();</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}条件单 · 期权中控{% endblock %}
|
||||
{% block body %}
|
||||
<section class="page-head cond-head">
|
||||
<div>
|
||||
<h1>条件单</h1>
|
||||
<p class="muted cond-lead">中控下发到实例;列表由中控后台缓存并推送到浏览器。盯盘与开平仓在客户端。有仓或已占用的机器不可选。</p>
|
||||
<p class="muted" id="cond-status">读取缓存…</p>
|
||||
</div>
|
||||
<div class="cond-head-actions">
|
||||
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
|
||||
<label for="cond-add-toggle" class="settings-primary inst-add-trigger">新建条件单</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<input type="checkbox" id="cond-add-toggle" class="cond-add-check">
|
||||
|
||||
<div class="cond-layout">
|
||||
<section class="panel cond-card cond-add-panel">
|
||||
<div class="cond-card-head">
|
||||
<h2>新建条件单</h2>
|
||||
<span class="cond-chip">左侧单</span>
|
||||
</div>
|
||||
|
||||
<div class="cond-create-split">
|
||||
<div class="cond-create-left">
|
||||
<div class="cond-col-title">策略</div>
|
||||
|
||||
<div class="cond-block">
|
||||
<div class="cond-uly-side-row">
|
||||
<div class="cond-uly-side-item">
|
||||
<span class="cond-mini-label">标的</span>
|
||||
<div class="cond-seg" id="cf-uly-seg" role="group" aria-label="标的">
|
||||
<button type="button" class="cond-seg-btn on" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="cond-seg-btn" data-uly="BTC">BTC</button>
|
||||
</div>
|
||||
<select id="cf-underlying" class="cond-native-select" hidden>
|
||||
<option value="ETH" selected>ETH</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cond-uly-side-item">
|
||||
<span class="cond-mini-label">方向</span>
|
||||
<div class="cond-seg" id="cf-side-seg" role="group" aria-label="方向">
|
||||
<button type="button" class="cond-seg-btn on" data-side="P">看跌 Put</button>
|
||||
<button type="button" class="cond-seg-btn" data-side="C">看涨 Call</button>
|
||||
</div>
|
||||
<select id="cf-opt-type" class="cond-native-select" hidden>
|
||||
<option value="P" selected>看跌 Put</option>
|
||||
<option value="C">看涨 Call</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cond-block">
|
||||
<div class="cond-param-row">
|
||||
<label class="cond-field">触发价
|
||||
<input id="cf-trigger-px" type="number" step="any" placeholder="如 3000" required>
|
||||
</label>
|
||||
<label class="cond-field">偏离 %
|
||||
<input id="cf-deviation" type="number" step="0.1" value="1" placeholder="1" required>
|
||||
</label>
|
||||
<label class="cond-field">最短到期 h
|
||||
<input id="cf-expiry-h" type="number" step="1" value="24" placeholder="24" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cond-trigger-hint is-put" id="cf-trigger-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="cond-block">
|
||||
<div class="cond-col-title">出场</div>
|
||||
<div class="cond-exit-row">
|
||||
<label class="cond-field">目标指数(可选)
|
||||
<input id="cf-target" type="number" step="any" placeholder="如 2800">
|
||||
</label>
|
||||
<label class="cond-switch">
|
||||
<input id="cf-pe" type="checkbox" checked>
|
||||
<span>翻倍出场</span>
|
||||
</label>
|
||||
<label class="cond-field cond-field-mult">倍数
|
||||
<input id="cf-pe-mult" type="number" step="0.1" value="1" placeholder="1">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cond-create-right">
|
||||
<div class="cond-right-head">
|
||||
<div class="cond-col-title">下发到客户端</div>
|
||||
<div class="muted cond-right-meta" id="cf-inst-meta">按标的过滤</div>
|
||||
</div>
|
||||
<p class="muted cond-right-lead">按标的白名单过滤;余额旁可本页划转。</p>
|
||||
<div id="cf-instances"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cond-actions">
|
||||
<button type="button" class="btn" id="btn-cond-create">下发条件单</button>
|
||||
<label for="cond-add-toggle" class="btn btn-secondary">取消</label>
|
||||
<span class="muted" id="cf-status"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel cond-card">
|
||||
<div class="cond-card-head">
|
||||
<h2>进行中</h2>
|
||||
</div>
|
||||
<div id="cond-list-active" class="muted">加载中…</div>
|
||||
</section>
|
||||
|
||||
<section class="panel cond-card">
|
||||
<div class="cond-card-head">
|
||||
<h2>已结束</h2>
|
||||
</div>
|
||||
<div id="cond-list-done" class="muted">加载中…</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='cond_orders.js') }}?v=16"></script>
|
||||
<script>HubCondOrders.boot();</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
<div id="overview-grid"></div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='hub.js') }}?v=26"></script>
|
||||
<script src="{{ url_for('static', filename='hub.js') }}?v=29"></script>
|
||||
<script>HubOverview.boot();</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,9 +37,44 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="hub-phone-desk hub-phone-chrome">
|
||||
<p class="hub-phone-desk-hint muted">点开后进入该客户端(手机页),不经过中控工作台嵌页。</p>
|
||||
<ul class="hub-phone-desk-list">
|
||||
{% for inst in instances %}
|
||||
<li>
|
||||
<button type="button" class="btn hub-phone-desk-open" data-open-id="{{ inst.id }}">{{ inst.name }}</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="muted">无启用实例</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var phone = document.documentElement.classList.contains("hub-phone-html");
|
||||
if (!phone) return;
|
||||
var blank = document.getElementById("desk-open-blank");
|
||||
if (blank && blank.getAttribute("href")) {
|
||||
window.location.replace(blank.getAttribute("href"));
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".hub-phone-desk-open[data-open-id]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var id = btn.getAttribute("data-open-id");
|
||||
if (!id) return;
|
||||
fetch("/api/instances/" + encodeURIComponent(id) + "/open_url")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.url) window.location.href = d.url;
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = "hub_desk_side_collapsed";
|
||||
var root = document.getElementById("desk-root");
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<div class="backup-block">
|
||||
<div class="backup-block-head">
|
||||
<h3>中控备份</h3>
|
||||
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code>。换机时先上传家里下载的包,再点恢复。</p>
|
||||
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code>、<code>hub.db</code>、当前登录用户名和密码。换机时先上传家里下载的包,再点恢复。</p>
|
||||
<div class="settings-actions">
|
||||
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
|
||||
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
|
||||
@@ -188,7 +188,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=3"></script>
|
||||
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=4"></script>
|
||||
<script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
|
||||
<script>
|
||||
HubSettingsBackup.boot();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="settings-eyebrow">Analytics</p>
|
||||
<h1>数据统计</h1>
|
||||
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p>
|
||||
<p class="muted settings-lead">多机 KPI 对比与累计已实现盈亏曲线。划转/提现不影响曲线。后台常驻刷新,打开即读缓存。</p>
|
||||
</div>
|
||||
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
|
||||
<button type="button" class="stats-win-btn" data-window="today">今日</button>
|
||||
@@ -15,35 +15,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted" id="stats-status">加载中…</p>
|
||||
<p class="muted" id="stats-status">读取缓存…</p>
|
||||
|
||||
<section class="panel stats-kpi-panel" id="stats-kpi-panel">
|
||||
<div class="stats-kpi-grid" id="stats-kpi-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel stats-curve-panel">
|
||||
<div class="stats-card-head-inline">
|
||||
<div>
|
||||
<h2>余额曲线</h2>
|
||||
<p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。采样开启后往后累计。</p>
|
||||
</div>
|
||||
<label class="stats-curve-toggle">
|
||||
<input type="checkbox" id="stats-show-machines" checked>
|
||||
叠加各机
|
||||
</label>
|
||||
</div>
|
||||
<div class="stats-chart-wrap">
|
||||
<canvas id="stats-curve-canvas" width="960" height="320"></canvas>
|
||||
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div>
|
||||
</div>
|
||||
<div class="stats-legend" id="stats-curve-legend"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel stats-table-panel">
|
||||
<div class="stats-card-head-inline">
|
||||
<div>
|
||||
<h2>机器对比</h2>
|
||||
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p>
|
||||
<p class="muted">实时资金状态 + 当前窗口交易 KPI。排序:模拟在上、实盘在下。收益率 = 窗口已结算盈亏 ÷ 初始资金。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-table-wrap">
|
||||
@@ -52,23 +34,44 @@
|
||||
<tr>
|
||||
<th>机器</th>
|
||||
<th>状态</th>
|
||||
<th>模式</th>
|
||||
<th>余额</th>
|
||||
<th>权利金</th>
|
||||
<th>浮盈</th>
|
||||
<th>笔数</th>
|
||||
<th>胜率</th>
|
||||
<th>已实现</th>
|
||||
<th>收益率</th>
|
||||
<th>均持仓</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stats-table-body">
|
||||
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
||||
<tr><td colspan="12" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel stats-curve-panel stats-curve-panel--large">
|
||||
<div class="stats-card-head-inline">
|
||||
<div>
|
||||
<h2>已实现盈亏曲线</h2>
|
||||
<p class="muted">主指标为各机累计已实现盈亏(期权平仓真盈亏U累加)。可勾选机器叠加对比。</p>
|
||||
</div>
|
||||
<label class="stats-curve-toggle">
|
||||
<input type="checkbox" id="stats-show-total" checked>
|
||||
显示合计
|
||||
</label>
|
||||
</div>
|
||||
<div class="stats-machine-picker" id="stats-machine-picker" aria-label="选择机器"></div>
|
||||
<div class="stats-chart-wrap stats-chart-wrap--large">
|
||||
<canvas id="stats-curve-canvas" width="960" height="480"></canvas>
|
||||
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无已实现盈亏数据;实例更新并有平仓记录后显示。</div>
|
||||
</div>
|
||||
<div class="stats-legend" id="stats-curve-legend"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='stats.js') }}?v=1"></script>
|
||||
<script src="{{ url_for('static', filename='stats.js') }}?v=5"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""条件单候选:带出交易户资金字段."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestCondCandidatesFunds(unittest.TestCase):
|
||||
def test_candidates_include_trading_usdt(self):
|
||||
from lib.cond_orders_cache_lib import _candidates_from_snaps
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "okx-a",
|
||||
"online": True,
|
||||
"error": "",
|
||||
"snapshot": {
|
||||
"positions_brief": [],
|
||||
"cond_order_locked": False,
|
||||
"instance": {"mode": "live"},
|
||||
"funds": {"trading_usdt": 123.456, "funding_usdt": 50.0},
|
||||
"transfer_blocked": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "okx-b",
|
||||
"online": False,
|
||||
"error": "timeout",
|
||||
"snapshot": {
|
||||
"positions_brief": [{"inst_id": "x"}],
|
||||
"cond_order_locked": False,
|
||||
"instance": {"mode": "sim"},
|
||||
"funds": {},
|
||||
"transfer_blocked": True,
|
||||
"transfer_block_msg": "期权持仓中禁止划转",
|
||||
},
|
||||
},
|
||||
]
|
||||
out = _candidates_from_snaps(rows)
|
||||
self.assertEqual(len(out), 2)
|
||||
self.assertTrue(out[0]["selectable"])
|
||||
self.assertAlmostEqual(out[0]["trading_usdt"], 123.456, places=3)
|
||||
self.assertAlmostEqual(out[0]["funding_usdt"], 50.0, places=3)
|
||||
self.assertFalse(out[0]["transfer_blocked"])
|
||||
self.assertFalse(out[1]["selectable"])
|
||||
self.assertIsNone(out[1]["trading_usdt"])
|
||||
self.assertTrue(out[1]["transfer_blocked"])
|
||||
self.assertEqual(out[0]["allowed_underlys"], ["ETH", "BTC"])
|
||||
|
||||
def test_candidates_carry_allowed_underlys(self):
|
||||
from lib.cond_orders_cache_lib import _candidates_from_snaps
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "eth-only",
|
||||
"online": True,
|
||||
"error": "",
|
||||
"snapshot": {
|
||||
"positions_brief": [],
|
||||
"cond_order_locked": False,
|
||||
"instance": {"mode": "sim", "underly": "ETH", "allowed_underlys": ["ETH"]},
|
||||
"options_allowed_underlys": ["ETH"],
|
||||
"options_underly": "ETH",
|
||||
"funds": {"trading_usdt": 1.0, "funding_usdt": 2.0},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = _candidates_from_snaps(rows)
|
||||
self.assertEqual(out[0]["allowed_underlys"], ["ETH"])
|
||||
self.assertEqual(out[0]["default_underly"], "ETH")
|
||||
|
||||
def test_open_url_tab_appended(self):
|
||||
from lib.sso_lib import build_embed_open_url
|
||||
|
||||
url = build_embed_open_url(
|
||||
"https://okx.example.com",
|
||||
bridge_token="tok",
|
||||
embed_path="/embed",
|
||||
tab="options",
|
||||
)
|
||||
self.assertIn("hub_sso?", url)
|
||||
self.assertIn("next=", url)
|
||||
# next 已 urlencode,解码后应含 tab=options
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
q = parse_qs(urlparse(url).query)
|
||||
next_path = unquote(q.get("next", [""])[0])
|
||||
self.assertIn("tab=options", next_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""中控统计曲线:各机累计已实现盈亏叠加合计."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestStatsRealizedPnlCurve(unittest.TestCase):
|
||||
def test_build_stats_curve_sums_machines(self):
|
||||
from lib.stats_agg_lib import build_stats_curve
|
||||
|
||||
t0 = 1_700_000_040_000 # 已对齐到分钟桶
|
||||
t1 = t0 + 60_000
|
||||
t2 = t0 + 120_000
|
||||
fake_rows = [
|
||||
{
|
||||
"id": "a",
|
||||
"name": "A",
|
||||
"online": True,
|
||||
"error": "",
|
||||
"points": [{"t": t0, "v": 10.0}, {"t": t2, "v": 15.0}],
|
||||
"trade_count": 2,
|
||||
"latest": 15.0,
|
||||
"start_ms": t0,
|
||||
"end_ms": t2,
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"name": "B",
|
||||
"online": True,
|
||||
"error": "",
|
||||
"points": [{"t": t1, "v": 5.0}],
|
||||
"trade_count": 1,
|
||||
"latest": 5.0,
|
||||
"start_ms": t1,
|
||||
"end_ms": t2,
|
||||
},
|
||||
]
|
||||
with patch("lib.stats_agg_lib.pull_all_pnl_curves", return_value=fake_rows):
|
||||
out = build_stats_curve("all")
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["metric"], "realized_pnl")
|
||||
self.assertEqual(len(out["series"]), 2)
|
||||
totals = {p["t"]: p["v"] for p in out["total"]}
|
||||
# t0: only A=10; t1: A=10 + B=5; t2: A=15 + B=5
|
||||
self.assertAlmostEqual(totals[t0], 10.0)
|
||||
self.assertAlmostEqual(totals[t1], 15.0)
|
||||
self.assertAlmostEqual(totals[t2], 20.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||