Compare commits

..

26 Commits

Author SHA1 Message Date
dekun ac8a775b98 fix条件单划转常显,标的与方向同一行。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:49:34 +08:00
dekun 96c78b2ed1 feat条件单新建改为左策略右选机双栏布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:41:04 +08:00
dekun e2a28e6c94 feat条件单新建按标的过滤匹配币种的客户端。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:18:52 +08:00
dekun 18d195849c chore添加著作权声明(马建军,仅源码与 COPYRIGHT)。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:14:08 +08:00
dekun 3130a12825 fix条件单列表悬停高亮,进行中与已结束改为每页5条。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:45:40 +08:00
dekun ab47fb6142 fix条件单划转按钮等宽,并去掉备用打开入口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:42:00 +08:00
dekun b81b999090 feat条件单页支持资金与交易户双向划转,并修正账户名被挤占。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:36:52 +08:00
dekun 1c3ea77db8 feat条件单页本页划入交易户,避免跳转丢失新建表单。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:30:36 +08:00
dekun 88337ecdaf feat条件单候选展示交易户资金,并支持打开实例划转。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:07:20 +08:00
dekun f2605b1e8f feat数据统计曲线改为各机累计已实现盈亏叠加,不再用余额采样。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:06:24 +08:00
dekun c7edf0114b 机器对比模式列改为显示 sim/live。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:53:35 +08:00
dekun 5ffcee3f56 条件单列表改为中控后台并行缓存并以 SSE 推浏览器,下发仍走中控到实例。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:50:52 +08:00
dekun 1c68493aa0 机器对比改为模拟在上实盘在下,收益率按已结算盈亏相对初始资金计算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:44:16 +08:00
dekun 05661f3f30 数据统计改为后台热缓存与 SSE,曲线放大置底并支持选机,对比增加模式与收益率。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:35:02 +08:00
dekun 0e3fffef44 中控总览改为后台常驻并行拉取热缓存,浏览器只读缓存并经 SSE 更新。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:24:26 +08:00
dekun 1735c031ea 买一/流动性仅在残档或明确拉不到时标黄,未同步显示破折号。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:06:43 +08:00
dekun 85299c7b33 中控条件单已结束状态增加时间取消展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:46:18 +08:00
dekun fa395cfa35 条件单进行中与已结束列表改为每页 10 条翻页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:24:45 +08:00
dekun 4ecabd414c 条件单拆成进行中与已结束两表,并展示开仓时间。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:20:33 +08:00
dekun da1bae8751 条件单页新建与刷新按钮对齐同一高度。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:36:17 +08:00
dekun e2a61a202e 条件单页改为默认看历史,点新建才展开下发表单。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:34:00 +08:00
dekun 747ec62e34 中控条件单手机端改为卡片列表加表单,电脑端表格保持不变。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:29:33 +08:00
dekun a835e3aeec 中控手机卡片改为大浮盈与短合约名,减少重复行。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:24:43 +08:00
dekun cf347e7b6b 中控手机端使用独立底栏与卡片总览,电脑端布局保持不变。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:19:56 +08:00
dekun 2dcb191c2f 中控页面去掉安装到桌面按钮,改由浏览器地址栏安装。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:04:54 +08:00
dekun 7fd1e7aefd 中控桌面图标改为金罗盘青环徽记。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:00:05 +08:00
35 changed files with 4176 additions and 1078 deletions
+6
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
Copyright (c) 2026 马建军. All rights reserved.
本仓库源代码及相关文档的著作权归马建军所有。
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
Author: 马建军
QQ: 253921841
Email: 253921841@qq.com
Website: https://hyf2.cc
+196 -76
View File
@@ -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"])
@@ -486,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(
@@ -537,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"])
@@ -646,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"])
@@ -705,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:
@@ -748,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,
@@ -769,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"])
@@ -787,7 +903,11 @@ 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")
+4
View File
@@ -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."""
+160
View File
@@ -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("/")
+44
View File
@@ -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",
+282
View File
@@ -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()
+197
View File
@@ -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()
+7
View File
@@ -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}"
+125 -21
View File
@@ -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,
}
+268
View File
@@ -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()
+873 -333
View File
File diff suppressed because it is too large Load Diff
+712 -75
View File
@@ -169,16 +169,11 @@ a:hover { color: #7dd3fc; }
.nav-spot--flat .nav-spot-last,
.nav-spot--flat .nav-spot-chg,
.nav-spot--flat .nav-spot-pct { color: #9aa3bf; }
.topbar-actions {
.topbar-logout {
position: fixed;
top: 16px;
right: 22px;
z-index: 20;
display: flex;
align-items: center;
gap: 10px;
}
.topbar-logout {
color: var(--muted);
font-size: 0.82rem;
letter-spacing: 0.04em;
@@ -186,39 +181,6 @@ a:hover { color: #7dd3fc; }
.topbar-logout:hover {
color: #e8f1ff;
}
.topbar-install {
appearance: none;
border: 1px solid #304164;
background: #151a2a;
color: #dbe4ff;
font-size: 0.78rem;
border-radius: 8px;
padding: 4px 10px;
cursor: pointer;
font-family: inherit;
}
.topbar-install:hover {
color: #ffffff;
border-color: #3d5278;
}
.topbar-install-hint {
position: fixed;
top: 46px;
right: 22px;
z-index: 20;
margin: 0;
max-width: min(360px, calc(100vw - 40px));
color: var(--muted);
font-size: 0.75rem;
line-height: 1.4;
text-align: right;
}
@media (display-mode: standalone) {
.topbar-install,
.topbar-install-hint {
display: none !important;
}
}
@media (max-width: 1100px) {
.topbar-row {
grid-template-columns: 1fr;
@@ -1457,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;
@@ -1481,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 {
@@ -1569,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 {
@@ -1647,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%;
@@ -1656,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;
@@ -1704,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;
}
+275 -19
View File
@@ -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);
+62
View File
@@ -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);
+2 -84
View File
@@ -1,89 +1,7 @@
(function (global) {
var deferredPrompt = null;
function isStandalone() {
return (
(global.matchMedia && global.matchMedia("(display-mode: standalone)").matches) ||
global.navigator.standalone === true
);
}
function btn() {
return document.getElementById("hub-install-btn");
}
function setHint(msg) {
var el = document.getElementById("hub-install-hint");
if (el) el.textContent = msg || "";
}
function showInstall() {
var el = btn();
if (!el || isStandalone()) return;
el.hidden = false;
}
function hideInstall() {
var el = btn();
if (el) el.hidden = true;
setHint("");
}
function downloadShortcut() {
var origin = global.location.origin;
var body = "[InternetShortcut]\r\nURL=" + origin + "/\r\n";
var blob = new Blob([body], { type: "application/internet-shortcut" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "期权中控.url";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () {
URL.revokeObjectURL(a.href);
}, 1000);
}
async function onInstallClick() {
setHint("");
if (deferredPrompt) {
deferredPrompt.prompt();
try {
var choice = await deferredPrompt.userChoice;
deferredPrompt = null;
if (choice && choice.outcome === "accepted") {
hideInstall();
return;
}
} catch (e) {
deferredPrompt = null;
}
}
downloadShortcut();
setHint("已下载快捷方式,拖到桌面即可。Chrome / Edge 也可在地址栏右侧点「安装」。");
}
function boot() {
if (isStandalone()) {
hideInstall();
return;
}
showInstall();
global.addEventListener("beforeinstallprompt", function (ev) {
ev.preventDefault();
deferredPrompt = ev;
showInstall();
});
global.addEventListener("appinstalled", function () {
deferredPrompt = null;
hideInstall();
});
var el = btn();
if (el) el.addEventListener("click", onInstallClick);
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {});
}
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {});
}
global.HubPwa = { boot: boot };
})(window);
+10 -6
View File
@@ -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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+20 -5
View File
@@ -1,7 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0b0d14"/>
<rect x="36" y="36" width="440" height="440" rx="88" fill="#121828" stroke="#ffffff" stroke-width="10"/>
<circle cx="256" cy="236" r="108" fill="none" stroke="#4285f4" stroke-width="22"/>
<path d="M196 176 L316 296 M316 176 L196 296" stroke="#ffffff" stroke-width="28" stroke-linecap="round"/>
<rect x="186" y="392" width="140" height="18" rx="9" fill="#7b42ff"/>
<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>

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+4 -4
View File
@@ -10,25 +10,25 @@
"theme_color": "#070b14",
"icons": [
{
"src": "/static/icons/icon-192.png?v=1",
"src": "/static/icons/icon-192.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png?v=1",
"src": "/static/icons/icon-512.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-192-maskable.png?v=1",
"src": "/static/icons/icon-192-maskable.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icons/icon-512-maskable.png?v=1",
"src": "/static/icons/icon-512-maskable.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+529 -330
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
})();
+60 -16
View File
@@ -2,29 +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>
<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=1" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=1">
<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=37">
<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 %}">
<div class="topbar-actions">
<button type="button" class="topbar-install" id="hub-install-btn" hidden>安装到桌面</button>
{% if session.get('logged_in') %}
<a href="/logout" class="topbar-logout">退出</a>
{% endif %}
</div>
<p class="topbar-install-hint" id="hub-install-hint"></p>
{% if session.get('logged_in') %}
<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>
@@ -33,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>
@@ -58,14 +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=1"></script>
<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>
+123 -85
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+35
View File
@@ -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");
+26 -23
View File
@@ -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 %}
+94
View File
@@ -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()
+52
View File
@@ -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()