Compare commits

..

28 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
dekun 0327f49f4e 中控支持一键安装到桌面,HTTPS 下可装成独立应用。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:54:59 +08:00
dekun 470c6ba261 备份时写入当前中控登录用户名和密码,恢复后可直接使用。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:54:59 +08:00
38 changed files with 4319 additions and 944 deletions
+6
View File
@@ -7,6 +7,12 @@ FLASK_SECRET_KEY=change-me-to-long-random
DB_PATH=hub.db DB_PATH=hub.db
HUB_PULL_INTERVAL_SEC=10 HUB_PULL_INTERVAL_SEC=10
HUB_PULL_TIMEOUT_SEC=5 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 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
+219 -75
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 — 期权多机中控.""" """crypto_okx_hub — 期权多机中控."""
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
import threading
import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@@ -19,6 +20,7 @@ from flask import (
request, request,
send_file, send_file,
session, session,
stream_with_context,
url_for, url_for,
) )
@@ -44,13 +46,13 @@ from lib.bridge_client_lib import (
fetch_backup_file, fetch_backup_file,
fetch_backup_list, fetch_backup_list,
fetch_client_version, fetch_client_version,
fetch_cond_orders,
post_client_update, post_client_update,
post_close_full, post_close_full,
post_cond_cancel, post_cond_cancel,
post_cond_exit, post_cond_exit,
pull_all_enabled, 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.cash_sample_lib import record_cash_samples_from_overview
from lib.db_lib import db_path as hub_db_path, init_db from lib.db_lib import db_path as hub_db_path, init_db
from lib.env_lib import apply_env_updates, env_get from lib.env_lib import apply_env_updates, env_get
@@ -62,8 +64,10 @@ from lib.instances_lib import (
list_instances, list_instances,
update_instance, update_instance,
) )
from lib.overview_cache_lib import overview_store
from lib.sso_lib import build_embed_open_url 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 ( from lib.wechat_lib import (
hub_alert_cooldown_sec, hub_alert_cooldown_sec,
hub_health_fail_threshold, hub_health_fail_threshold,
@@ -157,21 +161,43 @@ def stats_page():
@app.route("/api/stats/summary") @app.route("/api/stats/summary")
@login_required @login_required
def api_stats_summary(): def api_stats_summary():
"""只读统计热缓存;后台常驻刷新各窗口."""
window = normalize_window(request.args.get("window") or "7d") window = normalize_window(request.args.get("window") or "7d")
try: force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
return jsonify(build_stats_summary(window)) if force:
except Exception as e: stats_store.request_refresh()
return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500 return jsonify(stats_store.summary_dict(window))
@app.route("/api/stats/curve") @app.route("/api/stats/curve")
@login_required @login_required
def api_stats_curve(): def api_stats_curve():
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d") range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
try: force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
return jsonify(build_stats_curve(range_key)) if force:
except Exception as e: stats_store.request_refresh()
return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500 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"]) @app.route("/instances", methods=["GET", "POST"])
@@ -362,6 +388,8 @@ def api_hub_backup_create():
env_path=str(ENV_PATH), env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(), db_path=_resolved_hub_db_path(),
label="manual", label="manual",
username=hub_username(),
password=hub_password(),
) )
return jsonify(result) return jsonify(result)
except Exception as exc: except Exception as exc:
@@ -395,6 +423,12 @@ def api_hub_backup_restore():
env_path=str(ENV_PATH), env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(), db_path=_resolved_hub_db_path(),
) )
if result.get("ok"):
u = str(result.get("hub_username") or "").strip()
p = result.get("hub_password")
if u or p is not None:
set_hub_credentials(u or hub_username(), "" if p is None else str(p))
result.pop("hub_password", None)
return jsonify(result), (200 if result.get("ok") else 400) return jsonify(result), (200 if result.get("ok") else 400)
@@ -478,38 +512,64 @@ def desk():
) )
@app.route("/api/overview") def _on_overview_pulled(rows: list) -> None:
@login_required """常驻 pull 回调:健康巡检 + 余额采样(与浏览器是否打开无关)."""
def api_overview():
rows = pull_all_enabled()
return jsonify({"ok": True, "instances": rows})
def _health_pull_interval_sec() -> float:
try: try:
return max(5.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
except (TypeError, ValueError):
return 10.0
def _health_watch_loop() -> None:
"""后台巡检:即使无人打开总览也会告警;并采样余额曲线."""
while True:
try:
rows = pull_all_enabled()
inspect_overview_rows(rows) inspect_overview_rows(rows)
except Exception:
pass
try:
record_cash_samples_from_overview(rows) record_cash_samples_from_overview(rows)
except Exception: except Exception:
pass pass
time.sleep(_health_pull_interval_sec())
def start_health_watch() -> None: @app.route("/api/overview")
t = threading.Thread(target=_health_watch_loop, name="hub-health-watch", daemon=True) @login_required
t.start() def api_overview():
"""只读热缓存;不在请求内现场拉实例(后台常驻并行 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())
start_health_watch() @app.route("/api/overview/refresh", methods=["POST"])
@login_required
def api_overview_refresh():
overview_store.request_refresh()
return jsonify({"ok": True, "msg": "已触发后台刷新"})
@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_overview_cache() -> None:
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
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: try:
start_hub_backup_scheduler( start_hub_backup_scheduler(
@@ -517,6 +577,7 @@ try:
env_path=str(ENV_PATH), env_path=str(ENV_PATH),
db_path_fn=_resolved_hub_db_path, db_path_fn=_resolved_hub_db_path,
hour=3, hour=3,
get_credentials=lambda: (hub_username(), hub_password()),
) )
except Exception as _hub_bak_err: except Exception as _hub_bak_err:
print(f"[backup] scheduler: {_hub_bak_err}") print(f"[backup] scheduler: {_hub_bak_err}")
@@ -528,13 +589,52 @@ def api_open_url(iid: int):
inst = get_instance(iid) inst = get_instance(iid)
if not inst: if not inst:
return jsonify({"ok": False, "msg": "不存在"}), 404 return jsonify({"ok": False, "msg": "不存在"}), 404
tab = (request.args.get("tab") or "").strip() or None
url = build_embed_open_url( url = build_embed_open_url(
inst["base_url"], inst["base_url"],
bridge_token=inst["bridge_token"], bridge_token=inst["bridge_token"],
embed_path=inst.get("embed_path") or "/embed", embed_path=inst.get("embed_path") or "/embed",
ttl_sec=3600, 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"]) @app.route("/api/settings/update/hub/version", methods=["GET"])
@@ -637,49 +737,66 @@ def cond_orders_page():
@app.route("/api/cond_orders/candidates") @app.route("/api/cond_orders/candidates")
@login_required @login_required
def api_cond_order_candidates(): def api_cond_order_candidates():
"""可选客户端:在线且无持仓、未被条件单锁定.""" """可选客户端:读热缓存(总览快照推导),不现场拉实例."""
rows = pull_all_enabled() force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
out = [] if force:
for row in rows: cond_orders_store.request_refresh()
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} overview_store.request_refresh()
brief = snap.get("positions_brief") or snap.get("positions") or [] snap = cond_orders_store.snapshot_dict()
has_pos = isinstance(brief, list) and len(brief) > 0 return jsonify(
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"), "ok": True,
"name": row.get("name"), "instances": snap.get("candidates") or [],
"online": bool(row.get("online")), "cond_version": snap.get("cond_version"),
"has_position": has_pos, "updated_at": snap.get("updated_at"),
"cond_order_locked": locked, "refreshing": snap.get("refreshing"),
"selectable": selectable, "from_cache": True,
"mode": mode,
"error": row.get("error") or "",
} }
) )
return jsonify({"ok": True, "instances": out})
@app.route("/api/cond_orders", methods=["GET"]) @app.route("/api/cond_orders", methods=["GET"])
@login_required @login_required
def api_cond_orders_list(): def api_cond_orders_list():
instances = list_instances(enabled_only=True) """条件单列表只读热缓存;后台常驻并行从实例拉取."""
all_orders = [] force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
for inst in instances: if force:
data = fetch_cond_orders(inst) cond_orders_store.request_refresh()
for o in data.get("orders") or []: snap = cond_orders_store.snapshot_dict()
if not isinstance(o, dict): return jsonify(
continue {
item = dict(o) "ok": True,
item["instance_id"] = inst.get("id") "orders": snap.get("orders") or [],
item["instance_name"] = inst.get("name") "cond_version": snap.get("cond_version"),
all_orders.append(item) "updated_at": snap.get("updated_at"),
return jsonify({"ok": True, "orders": all_orders}) "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"]) @app.route("/api/cond_orders", methods=["POST"])
@@ -696,8 +813,9 @@ def api_cond_orders_create():
if not instance_ids: if not instance_ids:
return jsonify({"ok": False, "msg": "请选择客户端"}) return jsonify({"ok": False, "msg": "请选择客户端"})
# 再次按快照过滤有仓/锁定 # 再次按热缓存快照过滤有仓/锁定(下发本身仍 POST 到实例)
rows = {r.get("id"): r for r in pull_all_enabled()} ov = overview_store.snapshot_dict()
rows = {r.get("id"): r for r in (ov.get("instances") or [])}
allowed = [] allowed = []
rejected = [] rejected = []
for iid in instance_ids: for iid in instance_ids:
@@ -739,6 +857,9 @@ def api_cond_orders_create():
} }
results = dispatch_cond_order_to_ids(allowed, payload) results = dispatch_cond_order_to_ids(allowed, payload)
ok_n = sum(1 for r in results if r.get("ok")) 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( return jsonify(
{ {
"ok": ok_n > 0, "ok": ok_n > 0,
@@ -760,7 +881,11 @@ def api_cond_orders_cancel(hub_order_id: str):
inst = get_instance(int(iid)) inst = get_instance(int(iid))
if not inst: if not inst:
return jsonify({"ok": False, "msg": "实例不存在"}), 404 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"]) @app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
@@ -778,7 +903,26 @@ def api_cond_orders_exit(hub_order_id: str):
"profit_exit_enabled": data.get("profit_exit_enabled"), "profit_exit_enabled": data.get("profit_exit_enabled"),
"profit_exit_mult": data.get("profit_exit_mult"), "profit_exit_mult": data.get("profit_exit_mult"),
} }
return jsonify(post_cond_exit(inst, hub_order_id, payload)) result = post_cond_exit(inst, hub_order_id, payload)
if isinstance(result, dict) and result.get("ok"):
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(result)
@app.route("/manifest.webmanifest")
def pwa_manifest():
path = ROOT / "static" / "icons" / "manifest.webmanifest"
return send_file(path, mimetype="application/manifest+json; charset=utf-8")
@app.route("/sw.js")
def pwa_service_worker():
path = ROOT / "static" / "sw.js"
resp = send_file(path, mimetype="application/javascript; charset=utf-8")
resp.headers["Cache-Control"] = "no-cache"
resp.headers["Service-Worker-Allowed"] = "/"
return resp
if __name__ == "__main__": if __name__ == "__main__":
+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."""
+67 -5
View File
@@ -101,20 +101,35 @@ def prune_backups(*, keep_days: int | None = None) -> int:
return removed return removed
def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]: def create_backup(
*,
root_dir: str,
env_path: str,
db_path: str,
label: str = "manual",
username: str | None = None,
password: str | None = None,
) -> dict[str, Any]:
with _LOCK: with _LOCK:
now = beijing_now() now = beijing_now()
name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz" name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name) out_path = os.path.join(backup_dir(), name)
u = (username if username is not None else os.getenv("HUB_USERNAME") or "admin").strip() or "admin"
p = password if password is not None else (os.getenv("HUB_PASSWORD") or "admin123")
if p is None:
p = "admin123"
manifest = { manifest = {
"kind": "crypto_okx_hub", "kind": "crypto_okx_hub",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"), "created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual", "label": (label or "manual").strip() or "manual",
"includes": [".env", "database"], "includes": [".env", "database", "hub_login"],
"hub_username": u,
} }
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_")
os.close(tmp_fd) os.close(tmp_fd)
env_snap = ""
try: try:
env_snap = _snapshot_env_with_login(env_path, u, str(p))
with tarfile.open(tmp_path, "w:gz") as tar: with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json" manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f: with open(manifest_path, "w", encoding="utf-8") as f:
@@ -126,8 +141,8 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
os.remove(manifest_path) os.remove(manifest_path)
except OSError: except OSError:
pass pass
if env_path and os.path.isfile(env_path): if env_snap and os.path.isfile(env_snap):
tar.add(env_path, arcname=".env") tar.add(env_snap, arcname=".env")
if db_path and os.path.isfile(db_path): if db_path and os.path.isfile(db_path):
tar.add(db_path, arcname="database/" + os.path.basename(db_path)) tar.add(db_path, arcname="database/" + os.path.basename(db_path))
shutil.move(tmp_path, out_path) shutil.move(tmp_path, out_path)
@@ -137,6 +152,12 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
except OSError: except OSError:
pass pass
raise raise
finally:
if env_snap:
try:
os.remove(env_snap)
except OSError:
pass
prune_backups() prune_backups()
st = os.stat(out_path) st = os.stat(out_path)
return { return {
@@ -149,6 +170,21 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
} }
def _snapshot_env_with_login(env_path: str, username: str, password: str) -> str:
"""复制 .env 并写入当前中控登录账号,避免包内缺 HUB_USERNAME/PASSWORD."""
fd, snap = tempfile.mkstemp(suffix=".env", prefix="hub_env_")
os.close(fd)
if env_path and os.path.isfile(env_path):
shutil.copy2(env_path, snap)
else:
with open(snap, "w", encoding="utf-8") as f:
f.write("")
from lib.env_lib import apply_env_updates
apply_env_updates(snap, {"HUB_USERNAME": username, "HUB_PASSWORD": password})
return snap
def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]: def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]:
path = backup_path(name) path = backup_path(name)
if not path or not os.path.isfile(path): if not path or not os.path.isfile(path):
@@ -168,10 +204,23 @@ def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) ->
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True) os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path) shutil.copy2(src, db_path)
break break
restored_user = ""
restored_password = ""
if env_path and os.path.isfile(env_path):
from lib.env_lib import env_get
restored_user = env_get(env_path, "HUB_USERNAME", "")
restored_password = env_get(env_path, "HUB_PASSWORD", "")
if restored_user:
os.environ["HUB_USERNAME"] = restored_user
if restored_password:
os.environ["HUB_PASSWORD"] = restored_password
return { return {
"ok": True, "ok": True,
"msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效", "msg": "已恢复 .env / 数据库 / 登录账号,建议重启中控进程使配置完全生效",
"name": os.path.basename(path), "name": os.path.basename(path),
"hub_username": restored_user,
"hub_password": restored_password,
} }
@@ -237,6 +286,8 @@ def maybe_run_auto_backup(
env_path: str, env_path: str,
db_path: str, db_path: str,
hour: int = 3, hour: int = 3,
username: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
global _LAST_AUTO_DAY global _LAST_AUTO_DAY
now = beijing_now() now = beijing_now()
@@ -255,6 +306,8 @@ def maybe_run_auto_backup(
env_path=env_path, env_path=env_path,
db_path=db_path, db_path=db_path,
label="auto", label="auto",
username=username,
password=password,
) )
_LAST_AUTO_DAY = day _LAST_AUTO_DAY = day
return result return result
@@ -266,15 +319,24 @@ def start_backup_scheduler(
env_path: str, env_path: str,
db_path_fn: Callable[[], str], db_path_fn: Callable[[], str],
hour: int = 3, hour: int = 3,
get_credentials: Callable[[], tuple[str, str]] | None = None,
) -> None: ) -> None:
def loop() -> None: def loop() -> None:
while True: while True:
try: try:
u, p = ("", "")
if get_credentials:
try:
u, p = get_credentials()
except Exception:
u, p = "", ""
maybe_run_auto_backup( maybe_run_auto_backup(
root_dir=root_dir, root_dir=root_dir,
env_path=env_path, env_path=env_path,
db_path=db_path_fn(), db_path=db_path_fn(),
hour=hour, hour=hour,
username=u or None,
password=p if p != "" else None,
) )
except Exception: except Exception:
pass pass
+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")} 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]]: def pull_all_enabled() -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True) instances = list_instances(enabled_only=True)
if not instances: if not instances:
@@ -291,6 +325,132 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
return results 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]: def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]:
"""探测客户端 git 版本 / 在线.""" """探测客户端 git 版本 / 在线."""
base = (inst.get("base_url") or "").rstrip("/") 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) 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( def query_cash_series(
*, *,
range_key: str = "7d", 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, bridge_token: str,
embed_path: str = "/embed", embed_path: str = "/embed",
ttl_sec: int = 120, ttl_sec: int = 120,
tab: str | None = None,
) -> str: ) -> str:
root = (base_url or "").rstrip("/") root = (base_url or "").rstrip("/")
path = embed_path or "/embed" path = embed_path or "/embed"
if not path.startswith("/"): if not path.startswith("/"):
path = "/" + path 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) token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path}) q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}" return f"{root}/hub_sso?{q}"
+118 -14
View File
@@ -1,10 +1,14 @@
"""中控统计汇总:KPI 对比 + 余额曲线.""" """中控统计汇总:KPI 对比 + 已实现盈亏曲线."""
from __future__ import annotations from __future__ import annotations
import time
from typing import Any from typing import Any
from lib.bridge_client_lib import pull_all_enabled, pull_all_stats from lib.bridge_client_lib import pull_all_enabled, pull_all_pnl_curves, pull_all_stats
from lib.cash_sample_lib import query_cash_series, record_cash_samples_from_overview from lib.cash_sample_lib import (
first_cash_by_instance,
record_cash_samples_from_overview,
)
def _safe_float(v: Any) -> float | None: def _safe_float(v: Any) -> float | None:
@@ -36,9 +40,15 @@ def normalize_window(raw: Any) -> str:
return w if w in ("today", "7d", "30d", "all") else "7d" 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) w = normalize_window(window)
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats # 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
# snaps 可由热缓存传入,避免统计页再现场重复拉全机
if snaps is None:
snaps = pull_all_enabled() snaps = pull_all_enabled()
try: try:
record_cash_samples_from_overview(snaps) record_cash_samples_from_overview(snaps)
@@ -46,6 +56,10 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
pass pass
stats_rows = pull_all_stats(w) stats_rows = pull_all_stats(w)
stats_by_id = {r.get("id"): r for r in stats_rows} 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]] = [] machines: list[dict[str, Any]] = []
sum_cash = 0.0 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")) win_rate = _safe_float(stats.get("win_rate"))
avg_hold = _safe_float(stats.get("avg_hold_sec")) avg_hold = _safe_float(stats.get("avg_hold_sec"))
online = bool(snap.get("online")) 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: if online:
online_n += 1 online_n += 1
if cash is not None: if cash is not None:
@@ -110,19 +134,19 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
"name": snap.get("name"), "name": snap.get("name"),
"online": online, "online": online,
"error": snap.get("error") or st.get("error") or "", "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, "cash_usdt": cash,
"premium_usdt": paid, "premium_usdt": paid,
"ratio_pct": ratio, "ratio_pct": ratio,
"unrealized_usdt": unreal, "unrealized_usdt": unreal,
"initial_cash_usdt": initial,
"pos_count": len(brief), "pos_count": len(brief),
"total_closed": closed if online and stats.get("ok") else None, "total_closed": closed if online and stats.get("ok") else None,
"win_count": wins 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, "loss_count": losses if online and stats.get("ok") else None,
"win_rate": win_rate 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, "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_profit": profit if online and stats.get("ok") else None,
"total_loss": loss 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, "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 agg_win_rate = round(sum_wins / sum_closed * 100, 2) if sum_closed else 0.0
return { return {
"ok": True, "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]: def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
# 顺手采一笔,让打开页面时也有点 """多机累计已实现盈亏曲线(各机台阶叠加;划转/提现不影响)."""
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: try:
snaps = pull_all_enabled() t = int(p.get("t"))
record_cash_samples_from_overview(snaps) v = float(p.get("v"))
except Exception: 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 pass
data = query_cash_series(range_key=range_key) if row.get("end_ms") is not None:
data["ok"] = True try:
return data 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()
+688 -148
View File
@@ -37,6 +37,7 @@
leave_up: "结束·上破", leave_up: "结束·上破",
leave_down: "结束·下破", leave_down: "结束·下破",
cancel: "已取消", cancel: "已取消",
timeout: "时间取消",
closed: "已完成", closed: "已完成",
}; };
return map[reason] || "已结束"; return map[reason] || "已结束";
@@ -53,118 +54,27 @@
return "st-done"; return "st-done";
} }
function instanceMode(it) { function isHubPhone() {
const m = String((it && it.mode) || "").toLowerCase();
return m === "sim" || m === "live" ? m : "";
}
function renderCandidateCard(it, checkedIds) {
const disabled = !it.selectable;
const tip = disabled
? it.has_position
? "已有持仓"
: it.cond_order_locked
? "条件单占用"
: it.online
? "不可选"
: "离线"
: "可选";
const id = String(it.id);
const checked = !disabled && checkedIds.indexOf(id) >= 0;
return ( return (
'<label class="cond-inst' + document.documentElement.classList.contains("hub-phone-html") ||
(disabled ? " is-off" : "") + !!(document.body && document.body.classList.contains("hub-phone"))
'" title="' +
escapeHtml(tip) +
'">' +
'<input type="checkbox" name="cf-inst" value="' +
encodeURIComponent(id) +
'"' +
(disabled ? " disabled" : "") +
(checked ? " checked" : "") +
">" +
'<span class="cond-inst-name">' +
escapeHtml(it.name || "") +
"</span>" +
'<span class="cond-inst-tag">' +
escapeHtml(tip) +
"</span></label>"
); );
} }
function renderCandidateGroup(mode, items, checkedIds) { function orderTriggerText(o) {
if (!items.length) return ""; const side = o.trigger_side === "lte" ? "下穿≤" : "上穿≥";
const label = const px = o.trigger_px != null ? String(o.trigger_px) : "";
mode === "sim" || mode === "live" return side + px;
? '<div class="cond-inst-group-label"><span class="overview-mode-tag overview-mode-tag--' +
mode +
'">' +
mode +
"</span></div>"
: "";
return (
'<div class="cond-inst-group">' +
label +
'<div class="cond-inst-grid">' +
items
.map(function (it) {
return renderCandidateCard(it, checkedIds);
})
.join("") +
"</div></div>"
);
} }
async function loadCandidates() { function orderExitText(o) {
const box = document.getElementById("cf-instances"); const bits = [];
if (!box) return; if (o.profit_exit_enabled) bits.push("翻倍×" + (o.profit_exit_mult || ""));
const checkedIds = Array.prototype.slice if (o.target_index != null) bits.push("目标" + o.target_index);
.call(box.querySelectorAll('input[name="cf-inst"]:checked')) return bits.join(" · ");
.map(function (el) {
return decodeURIComponent(el.value || "");
});
const r = await fetch("/api/cond_orders/candidates");
const d = await r.json();
const list = d.instances || [];
if (!list.length) {
box.innerHTML = '<span class="muted">暂无启用实例</span>';
return;
}
const sim = [];
const live = [];
const other = [];
list.forEach(function (it) {
const m = instanceMode(it);
if (m === "sim") sim.push(it);
else if (m === "live") live.push(it);
else other.push(it);
});
box.innerHTML =
renderCandidateGroup("sim", sim, checkedIds) +
renderCandidateGroup("live", live, checkedIds) +
renderCandidateGroup("", other, checkedIds);
} }
async function loadOrders() { function orderActionHtml(o) {
const box = document.getElementById("cond-list");
if (!box) return;
const r = await fetch("/api/cond_orders");
const d = await r.json();
const list = d.orders || [];
if (!list.length) {
box.innerHTML = '<div class="cond-empty">暂无条件单</div>';
return;
}
box.innerHTML =
'<div class="overview-table-wrap cond-table-wrap"><table class="overview-table"><thead><tr>' +
"<th>客户端</th><th>状态</th><th>标的</th><th>触发</th><th>偏离</th><th>到期h</th><th>出场</th><th>说明</th><th>操作</th>" +
"</tr></thead><tbody>" +
list
.map(function (o) {
const side = o.trigger_side === "lte" ? "下穿 ≤" : "上穿 ≥";
const exit =
(o.profit_exit_enabled ? "翻倍×" + (o.profit_exit_mult || "") : "") +
(o.target_index != null ? " 目标" + o.target_index : "");
const canCancel = o.status === "armed" || o.status === "triggering"; const canCancel = o.status === "armed" || o.status === "triggering";
const canExit = o.status === "open"; const canExit = o.status === "open";
let actions = ""; let actions = "";
@@ -190,43 +100,11 @@
encodeURIComponent(String(o.profit_exit_mult || "1")) + encodeURIComponent(String(o.profit_exit_mult || "1")) +
'">改出场</button>'; '">改出场</button>';
} }
return ( return actions;
"<tr>" + }
"<td>" +
escapeHtml(o.instance_name || "") +
"</td>" +
'<td><span class="cond-st ' +
statusClass(o) +
'">' +
escapeHtml(statusLabel(o)) +
"</span></td>" +
"<td>" +
escapeHtml((o.underlying || "") + " " + (o.opt_type || "")) +
"</td>" +
"<td>" +
escapeHtml(side + " " + (o.trigger_px != null ? o.trigger_px : "")) +
"</td>" +
"<td>" +
escapeHtml(o.deviation_pct != null ? o.deviation_pct + "%" : "—") +
"</td>" +
"<td>" +
escapeHtml(o.min_expiry_hours != null ? String(o.min_expiry_hours) : "—") +
"</td>" +
"<td>" +
escapeHtml(exit || "—") +
"</td>" +
'<td class="muted">' +
escapeHtml(o.message || "") +
"</td>" +
'<td><div class="overview-actions">' +
actions +
"</div></td>" +
"</tr>"
);
})
.join("") +
"</tbody></table></div>";
function bindOrderActions(box) {
if (!box) return;
box.querySelectorAll("[data-cancel]").forEach(function (btn) { box.querySelectorAll("[data-cancel]").forEach(function (btn) {
btn.addEventListener("click", async function () { btn.addEventListener("click", async function () {
if (!confirm("确认取消该条件单?")) return; if (!confirm("确认取消该条件单?")) return;
@@ -239,7 +117,7 @@
}); });
const dd = await rr.json(); const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已取消" : "失败")); alert(dd.msg || (dd.ok ? "已取消" : "失败"));
refresh(); requestCondRefresh();
}); });
}); });
box.querySelectorAll("[data-exit]").forEach(function (btn) { box.querySelectorAll("[data-exit]").forEach(function (btn) {
@@ -270,11 +148,550 @@
}); });
const dd = await rr.json(); const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已更新" : "失败")); alert(dd.msg || (dd.ok ? "已更新" : "失败"));
refresh(); requestCondRefresh();
}); });
}); });
} }
function isActiveOrder(o) {
const st = String((o && o.status) || "");
return st === "armed" || st === "triggering" || st === "open";
}
function formatOpenTime(o) {
const raw = String((o && o.opened_at) || "").trim();
if (!raw) return "—";
return raw.replace("T", " ").slice(0, 19);
}
function renderPhoneOrderCard(o) {
const exit = orderExitText(o);
const meta = [];
if (o.deviation_pct != null) meta.push("偏离 " + o.deviation_pct + "%");
if (o.min_expiry_hours != null) meta.push("到期 " + o.min_expiry_hours + "h");
if (exit) meta.push(exit);
const actions = orderActionHtml(o);
return (
'<article class="hub-phone-cond">' +
'<div class="hub-phone-cond-head">' +
"<strong>" +
escapeHtml(o.instance_name || "") +
"</strong>" +
'<span class="cond-st ' +
statusClass(o) +
'">' +
escapeHtml(statusLabel(o)) +
"</span></div>" +
'<div class="hub-phone-cond-main">' +
escapeHtml((o.underlying || "") + " " + (o.opt_type === "C" ? "Call" : "Put")) +
" · " +
escapeHtml(orderTriggerText(o)) +
"</div>" +
(meta.length ? '<div class="hub-phone-cond-meta muted">' + escapeHtml(meta.join(" · ")) + "</div>" : "") +
'<div class="hub-phone-cond-meta muted">开仓 ' +
escapeHtml(formatOpenTime(o)) +
"</div>" +
(o.message ? '<div class="hub-phone-cond-msg muted">' + escapeHtml(o.message) + "</div>" : "") +
(actions ? '<div class="hub-phone-cond-actions">' + actions + "</div>" : "") +
"</article>"
);
}
function instanceMode(it) {
const m = String((it && it.mode) || "").toLowerCase();
return m === "sim" || m === "live" ? m : "";
}
var candidatesById = {};
var allCandidates = [];
function selectedCreateUnderlying() {
const el = document.getElementById("cf-underlying");
const u = el ? String(el.value || "ETH").trim().toUpperCase() : "ETH";
return u === "BTC" ? "BTC" : "ETH";
}
function candidateMatchesUnderlying(it, uly) {
const u = String(uly || "ETH").toUpperCase();
const raw = it && it.allowed_underlys;
const allow = Array.isArray(raw)
? raw
.map(function (x) {
return String(x || "")
.trim()
.toUpperCase();
})
.filter(function (x) {
return x === "ETH" || x === "BTC";
})
: [];
if (!allow.length) return true;
return allow.indexOf(u) >= 0;
}
function fmtTradingU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return n.toFixed(2) + "U";
}
function patchCandidateFunds(instanceId, tradingUsdt, fundingUsdt) {
const id = String(instanceId);
const it = candidatesById[id];
if (it) {
if (tradingUsdt != null) it.trading_usdt = tradingUsdt;
if (fundingUsdt != null) it.funding_usdt = fundingUsdt;
}
const row = document.querySelector(
'.cond-inst-row[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
if (!row) return;
const tradeEl = row.querySelector(".cond-inst-funds--trading");
const fundEl = row.querySelector(".cond-inst-funds--funding");
if (tradeEl) tradeEl.textContent = "交易 " + fmtTradingU(tradingUsdt);
if (fundEl) fundEl.textContent = "资金 " + fmtTradingU(fundingUsdt);
}
function transferInternal(instanceId, direction) {
const id = String(instanceId || "");
const it = candidatesById[id];
if (!it) return;
if (!it.online) {
window.alert("实例离线,无法划转");
return;
}
if (it.transfer_blocked) {
window.alert(it.transfer_block_msg || "当前不可划转");
return;
}
const toTrading = direction !== "to_funding";
const fromAcct = toTrading ? "funding" : "trading";
const toAcct = toTrading ? "trading" : "funding";
const srcBal = Number(toTrading ? it.funding_usdt : it.trading_usdt);
const defAmt =
!Number.isNaN(srcBal) && srcBal > 0
? String(Math.round(srcBal * 10000) / 10000)
: "";
const label = toTrading ? "资金户 → 交易户" : "交易户 → 资金户";
const raw = window.prompt(label + "(USDT)。本页划转,条件单表单不会丢失。", defAmt);
if (raw == null) return;
const amount = Number(String(raw).trim());
if (!(amount > 0)) {
window.alert("请输入大于 0 的数量");
return;
}
const btns = document.querySelectorAll(
'.cond-inst-xfer[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
const labels = [];
btns.forEach(function (btn) {
labels.push(btn.textContent);
btn.disabled = true;
btn.textContent = "划转中…";
});
fetch("/api/instances/" + encodeURIComponent(id) + "/transfer", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
amount: amount,
from: fromAcct,
to: toAcct,
ccy: "USDT",
}),
})
.then(function (r) {
return r.json().then(function (d) {
return { http: r.status, d: d };
});
})
.then(function (pack) {
const d = pack.d || {};
if (!d.ok) {
window.alert(d.msg || "划转失败");
return;
}
patchCandidateFunds(id, d.trading_usdt, d.funding_usdt);
const st = document.getElementById("cf-status");
if (st) {
st.textContent =
(it.name || id) +
" " +
label +
" " +
fmtTradingU(amount) +
"(交易 " +
fmtTradingU(d.trading_usdt) +
" / 资金 " +
fmtTradingU(d.funding_usdt) +
")";
}
})
.catch(function () {
window.alert("划转请求失败");
})
.finally(function () {
const blocked = !!(it.transfer_blocked || !it.online);
btns.forEach(function (btn, i) {
btn.disabled = blocked;
btn.textContent = labels[i] || btn.getAttribute("data-label") || "划转";
});
});
}
function renderCandidateCard(it, checkedIds) {
const disabled = !it.selectable;
const tip = disabled
? it.has_position
? "已有持仓"
: it.cond_order_locked
? "条件单占用"
: it.online
? "不可选"
: "离线"
: "可选";
const id = String(it.id);
const checked = !disabled && checkedIds.indexOf(id) >= 0;
const xferBlocked = !!(it.transfer_blocked || !it.online);
const xferTitle = xferBlocked
? it.transfer_block_msg || (!it.online ? "离线" : "不可划转")
: "本页双向划转,表单不丢";
const dis = xferBlocked ? " disabled" : "";
return (
'<div class="cond-inst-row' +
(disabled ? " is-off" : "") +
'" data-inst-id="' +
escapeHtml(id) +
'">' +
'<div class="cond-inst' +
(disabled ? " is-off" : "") +
'" title="' +
escapeHtml(tip) +
'">' +
'<input type="checkbox" name="cf-inst" value="' +
encodeURIComponent(id) +
'"' +
(disabled ? " disabled" : "") +
(checked ? " checked" : "") +
">" +
'<div class="cond-inst-main">' +
'<span class="cond-inst-name">' +
escapeHtml(it.name || "") +
"</span>" +
'<span class="cond-inst-fund-line">' +
'<span class="cond-inst-funds cond-inst-funds--trading" title="交易账户可用">交易 ' +
escapeHtml(fmtTradingU(it.trading_usdt)) +
"</span>" +
'<span class="cond-inst-funds cond-inst-funds--funding muted" title="资金账户可用">资金 ' +
escapeHtml(fmtTradingU(it.funding_usdt)) +
"</span>" +
"</span>" +
"</div>" +
'<span class="cond-inst-tag">' +
escapeHtml(tip) +
"</span>" +
'<span class="cond-inst-actions">' +
'<button type="button" class="btn btn-secondary btn-sm cond-inst-xfer" data-inst-id="' +
escapeHtml(id) +
'" data-dir="to_trading" data-label="划入交易" title="' +
escapeHtml(xferTitle) +
'"' +
dis +
">划入交易</button>" +
'<button type="button" class="btn btn-secondary btn-sm cond-inst-xfer" data-inst-id="' +
escapeHtml(id) +
'" data-dir="to_funding" data-label="划入资金" title="' +
escapeHtml(xferTitle) +
'"' +
dis +
">划入资金</button>" +
"</span>" +
"</div>" +
"</div>"
);
}
function renderCandidateGroup(mode, items, checkedIds) {
if (!items.length) return "";
const label =
mode === "sim" || mode === "live"
? '<div class="cond-inst-group-label"><span class="overview-mode-tag overview-mode-tag--' +
mode +
'">' +
mode +
"</span></div>"
: "";
return (
'<div class="cond-inst-group">' +
label +
'<div class="cond-inst-grid">' +
items
.map(function (it) {
return renderCandidateCard(it, checkedIds);
})
.join("") +
"</div></div>"
);
}
function paintCandidates(checkedIds) {
const box = document.getElementById("cf-instances");
if (!box) return;
const uly = selectedCreateUnderlying();
const list = (allCandidates || []).filter(function (it) {
return candidateMatchesUnderlying(it, uly);
});
const meta = document.getElementById("cf-inst-meta");
if (!(allCandidates || []).length) {
box.innerHTML = '<span class="muted">暂无启用实例</span>';
if (meta) meta.textContent = "暂无实例";
return;
}
if (!list.length) {
box.innerHTML =
'<span class="muted">当前标的 ' +
escapeHtml(uly) +
" 下暂无匹配客户端(请确认实例 OKX_OPTIONS_UNDERLYINGS)</span>";
if (meta) meta.textContent = "当前 " + uly + " · 可选 0";
return;
}
const selectableN = list.filter(function (it) {
return !!it.selectable;
}).length;
if (meta) meta.textContent = "当前 " + uly + " · 可选 " + selectableN;
const keep = {};
(checkedIds || []).forEach(function (id) {
keep[String(id)] = true;
});
const filteredChecked = list
.map(function (it) {
return String(it.id);
})
.filter(function (id) {
return keep[id];
});
const sim = [];
const live = [];
const other = [];
list.forEach(function (it) {
const m = instanceMode(it);
if (m === "sim") sim.push(it);
else if (m === "live") live.push(it);
else other.push(it);
});
box.innerHTML =
renderCandidateGroup("sim", sim, filteredChecked) +
renderCandidateGroup("live", live, filteredChecked) +
renderCandidateGroup("", other, filteredChecked);
box.querySelectorAll(".cond-inst-xfer").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
transferInternal(
btn.getAttribute("data-inst-id") || "",
btn.getAttribute("data-dir") || "to_trading"
);
});
});
box.querySelectorAll(".cond-inst-name").forEach(function (el) {
el.addEventListener("click", function (ev) {
const card = el.closest(".cond-inst");
if (!card || card.classList.contains("is-off")) return;
const cb = card.querySelector('input[name="cf-inst"]');
if (!cb || cb.disabled) return;
if (ev.target === el) {
cb.checked = !cb.checked;
}
});
});
}
async function loadCandidates() {
const box = document.getElementById("cf-instances");
if (!box) return;
const checkedIds = Array.prototype.slice
.call(box.querySelectorAll('input[name="cf-inst"]:checked'))
.map(function (el) {
return decodeURIComponent(el.value || "");
});
const r = await fetch("/api/cond_orders/candidates");
const d = await r.json();
const list = d.instances || [];
allCandidates = list;
candidatesById = {};
list.forEach(function (it) {
candidatesById[String(it.id)] = it;
});
paintCandidates(checkedIds);
}
function renderDesktopRows(list) {
return list
.map(function (o) {
return (
"<tr>" +
"<td>" +
escapeHtml(o.instance_name || "") +
"</td>" +
'<td><span class="cond-st ' +
statusClass(o) +
'">' +
escapeHtml(statusLabel(o)) +
"</span></td>" +
"<td>" +
escapeHtml((o.underlying || "") + " " + (o.opt_type || "")) +
"</td>" +
"<td>" +
escapeHtml(orderTriggerText(o).replace("≤", " ≤ ").replace("≥", " ≥ ")) +
"</td>" +
"<td>" +
escapeHtml(o.deviation_pct != null ? o.deviation_pct + "%" : "—") +
"</td>" +
"<td>" +
escapeHtml(o.min_expiry_hours != null ? String(o.min_expiry_hours) : "—") +
"</td>" +
"<td>" +
escapeHtml(orderExitText(o) || "—") +
"</td>" +
'<td class="cond-td-time">' +
escapeHtml(formatOpenTime(o)) +
"</td>" +
'<td class="muted">' +
escapeHtml(o.message || "") +
"</td>" +
'<td><div class="overview-actions">' +
orderActionHtml(o) +
"</div></td>" +
"</tr>"
);
})
.join("");
}
var PAGE_SIZE = 5;
var listPages = { active: 1, done: 1 };
var lastActive = [];
var lastDone = [];
function pageInfo(list, page) {
const total = list.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
const p = Math.min(Math.max(1, page || 1), pages);
const start = (p - 1) * PAGE_SIZE;
return { page: p, pages: pages, total: total, slice: list.slice(start, start + PAGE_SIZE) };
}
function renderPager(key, info) {
if (info.total <= PAGE_SIZE) return "";
return (
'<div class="cond-pager">' +
'<button type="button" class="btn btn-sm" data-cond-page="' +
key +
'" data-dir="-1"' +
(info.page <= 1 ? " disabled" : "") +
">上一页</button>" +
'<span class="muted">第 ' +
info.page +
" / " +
info.pages +
" 页 · 共 " +
info.total +
" 条</span>" +
'<button type="button" class="btn btn-sm" data-cond-page="' +
key +
'" data-dir="1"' +
(info.page >= info.pages ? " disabled" : "") +
">下一页</button></div>"
);
}
function renderDesktopTable(list, emptyText) {
if (!list.length) {
return '<div class="cond-empty">' + escapeHtml(emptyText) + "</div>";
}
return (
'<div class="overview-table-wrap cond-table-wrap"><table class="overview-table"><thead><tr>' +
"<th>客户端</th><th>状态</th><th>标的</th><th>触发</th><th>偏离</th><th>到期h</th><th>出场</th><th>开仓时间</th><th>说明</th><th>操作</th>" +
"</tr></thead><tbody>" +
renderDesktopRows(list) +
"</tbody></table></div>"
);
}
function renderPhoneList(list, emptyText) {
if (!list.length) {
return '<div class="cond-empty">' + escapeHtml(emptyText) + "</div>";
}
return '<div class="hub-phone-cond-list">' + list.map(renderPhoneOrderCard).join("") + "</div>";
}
function fillOrderBox(box, list, emptyText, key) {
if (!box) return;
const info = pageInfo(list, listPages[key] || 1);
listPages[key] = info.page;
const body = isHubPhone()
? renderPhoneList(info.slice, emptyText)
: renderDesktopTable(info.slice, emptyText);
box.innerHTML = body + renderPager(key, info);
bindOrderActions(box);
box.querySelectorAll("[data-cond-page]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.disabled) return;
const k = btn.getAttribute("data-cond-page");
const dir = Number(btn.getAttribute("data-dir") || 0);
listPages[k] = (listPages[k] || 1) + dir;
paintCachedOrders();
});
});
}
function paintCachedOrders() {
fillOrderBox(document.getElementById("cond-list-active"), lastActive, "暂无进行中的条件单", "active");
fillOrderBox(document.getElementById("cond-list-done"), lastDone, "暂无已结束的条件单", "done");
}
let condVersion = 0;
let condLoading = false;
let condEventSource = null;
let condReconnectTimer = null;
function setCondStatus(msg) {
const el = document.getElementById("cond-status");
if (el) el.textContent = msg || "";
}
async function loadOrders(opts) {
opts = opts || {};
const activeBox = document.getElementById("cond-list-active");
const doneBox = document.getElementById("cond-list-done");
if (!activeBox && !doneBox) return;
const r = await fetch("/api/cond_orders", { credentials: "same-origin" });
const d = await r.json();
const list = d.orders || [];
condVersion = Number(d.cond_version) || condVersion;
lastActive = [];
lastDone = [];
list.forEach(function (o) {
if (isActiveOrder(o)) lastActive.push(o);
else lastDone.push(o);
});
paintCachedOrders();
if (!opts.silent) {
const live = condEventSource && condEventSource.readyState === 1;
const sec = Number(d.poll_interval_sec) || 10;
setCondStatus(
(live ? "SSE 已连接 · " : "") +
"进行中 " +
lastActive.length +
" · 已结束 " +
lastDone.length +
(d.updated_at ? " · " + d.updated_at : "") +
(d.refreshing ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉")
);
}
}
async function createOrder() { async function createOrder() {
const status = document.getElementById("cf-status"); const status = document.getElementById("cf-status");
const ids = Array.prototype.slice const ids = Array.prototype.slice
@@ -308,26 +725,149 @@
const d = await r.json(); const d = await r.json();
if (status) status.textContent = d.msg || ""; if (status) status.textContent = d.msg || "";
alert(d.msg || (d.ok ? "已下发" : "失败")); alert(d.msg || (d.ok ? "已下发" : "失败"));
refresh(); if (d.ok) {
const tog = document.getElementById("cond-add-toggle");
if (tog) tog.checked = false;
}
requestCondRefresh();
} }
async function refresh() { async function refresh(opts) {
await Promise.all([loadCandidates(), loadOrders()]); opts = opts || {};
if (condLoading) return;
condLoading = true;
if (!opts.silent) setCondStatus("读取缓存…");
try {
await Promise.all([loadCandidates(), loadOrders({ silent: !!opts.silent })]);
} finally {
condLoading = false;
}
}
async function requestCondRefresh() {
try {
await fetch("/api/cond_orders/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
refresh({ silent: true });
}
function closeCondStream() {
if (condEventSource) {
condEventSource.close();
condEventSource = null;
}
if (condReconnectTimer) {
clearTimeout(condReconnectTimer);
condReconnectTimer = null;
}
}
function connectCondStream() {
if (typeof EventSource === "undefined") return;
closeCondStream();
condEventSource = new EventSource("/api/cond_orders/stream");
condEventSource.addEventListener("cond", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.cond_version) || 0;
if (ver && ver !== condVersion) {
refresh({ silent: true });
} else if (st.refreshing) {
setCondStatus("后台并行拉取条件单…");
}
} catch (_) {}
});
condEventSource.onerror = function () {
closeCondStream();
setCondStatus("SSE 断开,8s 后重连…");
condReconnectTimer = setTimeout(function () {
connectCondStream();
refresh({ silent: true });
}, 8000);
};
}
function syncSegButtons(segId, attr, value) {
const seg = document.getElementById(segId);
if (!seg) return;
seg.querySelectorAll(".cond-seg-btn").forEach(function (btn) {
const on = String(btn.getAttribute(attr) || "") === String(value);
btn.classList.toggle("on", on);
});
}
function bindSegGroup(segId, attr, selectId, onChange) {
const seg = document.getElementById(segId);
const sel = document.getElementById(selectId);
if (!seg || !sel) return;
syncSegButtons(segId, attr, sel.value);
seg.querySelectorAll(".cond-seg-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
const v = btn.getAttribute(attr);
if (!v || sel.value === v) {
if (v) syncSegButtons(segId, attr, v);
return;
}
sel.value = v;
syncSegButtons(segId, attr, v);
if (typeof onChange === "function") onChange(v);
});
});
sel.addEventListener("change", function () {
syncSegButtons(segId, attr, sel.value);
if (typeof onChange === "function") onChange(sel.value);
});
}
function syncPeMultEnabled() {
const pe = document.getElementById("cf-pe");
const mult = document.getElementById("cf-pe-mult");
if (!pe || !mult) return;
mult.disabled = !pe.checked;
}
function currentCheckedInstIds() {
const box = document.getElementById("cf-instances");
if (!box) return [];
return Array.prototype.slice
.call(box.querySelectorAll('input[name="cf-inst"]:checked'))
.map(function (el) {
return decodeURIComponent(el.value || "");
});
} }
function boot() { function boot() {
const btn = document.getElementById("btn-cond-refresh"); const btn = document.getElementById("btn-cond-refresh");
if (btn) btn.addEventListener("click", refresh); if (btn) btn.addEventListener("click", function () {
requestCondRefresh();
});
const create = document.getElementById("btn-cond-create"); const create = document.getElementById("btn-cond-create");
if (create) create.addEventListener("click", createOrder); if (create) create.addEventListener("click", createOrder);
const typeEl = document.getElementById("cf-opt-type"); const typeEl = document.getElementById("cf-opt-type");
const pxEl = document.getElementById("cf-trigger-px"); const pxEl = document.getElementById("cf-trigger-px");
if (typeEl) typeEl.addEventListener("change", syncTriggerHint); if (typeEl) typeEl.addEventListener("change", syncTriggerHint);
if (pxEl) pxEl.addEventListener("input", syncTriggerHint); if (pxEl) pxEl.addEventListener("input", syncTriggerHint);
bindSegGroup("cf-uly-seg", "data-uly", "cf-underlying", function () {
paintCandidates(currentCheckedInstIds());
});
bindSegGroup("cf-side-seg", "data-side", "cf-opt-type", function () {
syncTriggerHint(); syncTriggerHint();
});
const pe = document.getElementById("cf-pe");
if (pe) pe.addEventListener("change", syncPeMultEnabled);
syncPeMultEnabled();
syncTriggerHint();
// 读缓存秒开 + SSE;不再前端定时现场拉实例
refresh(); refresh();
setInterval(refresh, 10000); connectCondStream();
} }
global.HubCondOrders = { boot: boot, refresh: refresh }; global.HubCondOrders = {
boot: boot,
refresh: refresh,
requestRefresh: requestCondRefresh,
};
})(window); })(window);
+711 -36
View File
@@ -1419,8 +1419,39 @@ input.readonly {
} }
.cond-head { justify-content: space-between; align-items: flex-start; } .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-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-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 { padding: 18px 18px 16px; }
.cond-card-head { .cond-card-head {
display: flex; display: flex;
@@ -1443,79 +1474,241 @@ input.readonly {
border-radius: 999px; border-radius: 999px;
padding: 3px 10px; padding: 3px 10px;
} }
.cond-section { .cond-create-split {
border-top: 1px solid rgba(56, 189, 248, 0.12); display: grid;
padding: 14px 0 4px; 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-col-title {
.cond-section-title {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--accent-2); color: var(--accent-2);
letter-spacing: 0.08em; letter-spacing: 0.06em;
margin-bottom: 10px; margin: 0 0 10px;
font-weight: 600; font-weight: 650;
} }
.cond-grid { .cond-block { margin-bottom: 14px; }
display: grid; .cond-block:last-child { margin-bottom: 0; }
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); .cond-mini-label {
gap: 12px 14px; 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; 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; 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; color: #b6c7da;
} }
.cond-grid input, .cond-field input {
.cond-grid select {
margin-top: 6px; margin-top: 6px;
width: 100%;
} }
.cond-field-mult { min-width: 72px; }
.cond-field-mult input { width: 72px; }
.cond-trigger-hint { .cond-trigger-hint {
grid-column: 1 / -1; margin-top: 8px;
padding: 9px 12px; padding: 8px 10px;
border-radius: 10px; border-radius: 9px;
font-size: 0.82rem; font-size: 0.78rem;
line-height: 1.45; line-height: 1.45;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(8, 16, 30, 0.7); background: rgba(8, 16, 30, 0.7);
color: var(--text); color: var(--text);
} }
.cond-trigger-hint.is-put { border-color: rgba(251, 113, 133, 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); } .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 { .cond-switch {
display: flex !important; display: flex !important;
align-items: center; align-items: center;
gap: 8px; 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-switch input { width: auto; margin: 0; }
.cond-inst-group + .cond-inst-group { margin-top: 14px; } .cond-right-head {
.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 {
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 8px; 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; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(8, 16, 30, 0.72); 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; cursor: pointer;
} }
.cond-inst input { width: auto; margin: 0; } .cond-inst.is-off .cond-inst-name { cursor: not-allowed; }
.cond-inst-name { flex: 1; font-weight: 600; } .cond-inst-tag {
.cond-inst-tag { color: var(--muted); font-size: 0.75rem; } color: var(--muted);
.cond-inst.is-off { opacity: 0.5; cursor: not-allowed; } 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 { .cond-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-top: 14px; 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-empty { padding: 28px 8px; text-align: center; }
.cond-st { .cond-st {
@@ -1531,6 +1724,28 @@ input.readonly {
.cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); } .cond-st.st-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-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); }
.cond-table-wrap { margin-top: 0; } .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 { .stats-head {
@@ -1609,6 +1824,62 @@ input.readonly {
font-size: 0.86rem; font-size: 0.86rem;
} }
.stats-curve-toggle input { width: auto; margin: 0; } .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 { .stats-chart-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -1618,11 +1889,19 @@ input.readonly {
background: rgba(4, 10, 18, 0.65); background: rgba(4, 10, 18, 0.65);
overflow: hidden; overflow: hidden;
} }
.stats-chart-wrap--large {
min-height: 480px;
height: min(62vh, 560px);
}
#stats-curve-canvas { #stats-curve-canvas {
display: block; display: block;
width: 100%; width: 100%;
height: 320px; height: 320px;
} }
.stats-chart-wrap--large #stats-curve-canvas {
height: 100%;
min-height: 480px;
}
.stats-chart-empty { .stats-chart-empty {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1666,3 +1945,399 @@ input.readonly {
body.page-stats .main { body.page-stats .main {
max-width: 1800px; 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) { function isLiquidityWeak(p) {
if (!p) return false; 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); const uncovered = Number(p.uncovered_sheets);
return Number.isFinite(uncovered) && uncovered > 0; return Number.isFinite(uncovered) && uncovered > 0;
} }
function bidLiquidityLine(p) { 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 reason = (p.bid_invalid_reason || "无买一/残档").toString();
const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason; const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason;
return { text: short, weak: true }; 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 px = fmtBidPx(p.bid_px);
const sz = p.bid_sz != null ? String(p.bid_sz) : "—"; const sz = p.bid_sz != null ? String(p.bid_sz) : "—";
const uncovered = Number(p.uncovered_sheets); 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) { function renderRow(row) {
const snap = row.snapshot || {}; const snap = row.snapshot || {};
const inst = snap.instance || {}; const inst = snap.instance || {};
@@ -401,7 +581,7 @@
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张"; if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
if (dd.fully_closed) msg += " · 已全平"; if (dd.fully_closed) msg += " · 已全平";
alert(msg); alert(msg);
await refresh(); await requestOverviewRefresh();
} else { } else {
alert((dd && dd.msg) || "平仓失败"); alert((dd && dd.msg) || "平仓失败");
btn.disabled = false; btn.disabled = false;
@@ -579,21 +759,38 @@
return best; 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 status = document.getElementById("overview-status");
const grid = document.getElementById("overview-grid"); const grid = document.getElementById("overview-grid");
if (!grid) return; if (!grid) return;
if (status) status.textContent = "刷新中…"; if (overviewLoading) return;
overviewLoading = true;
if (status && !silent) status.textContent = "读取缓存…";
try { try {
const r = await fetch("/api/overview"); // 只读中控热缓存;后台常驻并行 pull,不在此请求内打实例
const r = await fetch("/api/overview", { credentials: "same-origin" });
const d = await r.json(); 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 || []); const list = sortInstancesByMode(d.instances || []);
renderOverviewSummary(list); renderOverviewSummary(list);
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list); if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
if (!list.length) { if (!list.length) {
grid.innerHTML = 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 { } else {
grid.innerHTML = grid.innerHTML =
'<div class="overview-table-wrap"><table class="overview-table">' + '<div class="overview-table-wrap"><table class="overview-table">' +
@@ -602,23 +799,24 @@
"</tr></thead><tbody>" + "</tr></thead><tbody>" +
list.map(renderRow).join("") + list.map(renderRow).join("") +
"</tbody></table></div>"; "</tbody></table></div>";
grid.querySelectorAll("[data-close-opt]").forEach(function (btn) { bindOverviewActions(grid);
btn.addEventListener("click", function () {
requestClose(btn);
});
});
} }
if (status) { if (status) {
const s = summarizeOverview(list); 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 = status.textContent =
"已更新 · 共 " + (live ? "SSE 已连接 · " : "") +
"缓存 " +
list.length + list.length +
" 台 · 在线 " + " 台 · 在线 " +
s.all.online + s.all.online +
" · 离线 " + " · 离线 " +
s.all.offline + s.all.offline +
(pulled ? " · 拉取时间 " + pulled : ""); (pulled ? " · " + pulled : "") +
(d.pulling ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉") +
(d.error ? " · " + d.error : "");
} }
} catch (e) { } catch (e) {
if (status) status.textContent = e.message || "加载失败"; if (status) status.textContent = e.message || "加载失败";
@@ -628,9 +826,60 @@
box.innerHTML = ""; box.innerHTML = "";
} }
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]); 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 WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"]; const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
const LUNAR_INFO = [ const LUNAR_INFO = [
@@ -821,12 +1070,19 @@
function boot() { function boot() {
const btn = document.getElementById("btn-refresh"); const btn = document.getElementById("btn-refresh");
if (btn) btn.addEventListener("click", refresh); if (btn) btn.addEventListener("click", function () {
requestOverviewRefresh();
});
tickBeijingClock(); tickBeijingClock();
setInterval(tickBeijingClock, 1000); setInterval(tickBeijingClock, 1000);
// 先进缓存秒开,再挂 SSE;不再前端定时现场拉实例
refresh(); refresh();
setInterval(refresh, 10000); connectOverviewStream();
} }
global.HubOverview = { boot: boot, refresh: refresh }; global.HubOverview = {
boot: boot,
refresh: refresh,
requestRefresh: requestOverviewRefresh,
};
})(window); })(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);
+7
View File
@@ -0,0 +1,7 @@
(function (global) {
function boot() {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {});
}
global.HubPwa = { boot: boot };
})(window);
+10 -6
View File
@@ -77,11 +77,7 @@
return best; return best;
} }
function paint(list) { function paintRoot(root, tickers, src) {
const root = document.getElementById("nav-spot-tickers");
if (!root) return;
const src = pickQuoteSource(list);
const tickers = src ? src.tickers : [];
["ETH", "BTC"].forEach(function (base) { ["ETH", "BTC"].forEach(function (base) {
const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]'); const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]');
if (!row) return; 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 timer = null;
let inflight = false; let inflight = false;
@@ -137,7 +141,7 @@
} }
function boot() { function boot() {
if (!document.getElementById("nav-spot-tickers")) return; if (!document.querySelector("[data-hub-quotes]")) return;
if (global.HubOverview) return; if (global.HubOverview) return;
refresh(); refresh();
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="70%">
<stop offset="0%" stop-color="#0b1a38"/>
<stop offset="100%" stop-color="#05080f"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f6e27a"/>
<stop offset="55%" stop-color="#d4af37"/>
<stop offset="100%" stop-color="#9a6b12"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="108" fill="url(#bg)"/>
<rect x="22" y="22" width="468" height="468" rx="92" fill="none" stroke="#d4af37" stroke-width="8"/>
<circle cx="256" cy="256" r="168" fill="none" stroke="#1b4f78" stroke-width="2" stroke-dasharray="4 10"/>
<circle cx="256" cy="256" r="118" fill="none" stroke="#22d3ee" stroke-width="14"/>
<polygon points="256,52 272,214 256,232 240,214" fill="url(#gold)"/>
<polygon points="256,460 240,298 256,280 272,298" fill="url(#gold)"/>
<polygon points="52,256 214,240 232,256 214,272" fill="url(#gold)"/>
<polygon points="460,256 298,272 280,256 298,240" fill="url(#gold)"/>
<polygon points="256,176 336,256 256,336 176,256" fill="url(#gold)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+37
View File
@@ -0,0 +1,37 @@
{
"name": "加密货币|OKX 期权中控",
"short_name": "期权中控",
"description": "多机期权持仓总览与工作台",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#070b14",
"theme_color": "#070b14",
"icons": [
{
"src": "/static/icons/icon-192.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-192-maskable.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icons/icon-512-maskable.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+226 -27
View File
@@ -9,10 +9,32 @@
"#f472b6", "#f472b6",
"#2dd4bf", "#2dd4bf",
]; ];
const PICKER_KEY = "hub-stats-curve-machines";
let windowKey = "7d"; let windowKey = "7d";
let curveData = null; let curveData = null;
let summaryData = 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) { function escapeHtml(s) {
return String(s) return String(s)
@@ -51,6 +73,13 @@
return (n / 3600).toFixed(1) + "h"; 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) { function setStatus(msg, isErr) {
const el = document.getElementById("stats-status"); const el = document.getElementById("stats-status");
if (!el) return; if (!el) return;
@@ -87,12 +116,26 @@
.join(""); .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) { function renderTable(machines) {
const body = document.getElementById("stats-table-body"); const body = document.getElementById("stats-table-body");
if (!body) return; if (!body) return;
const list = machines || []; const list = sortMachines(machines);
if (!list.length) { if (!list.length) {
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>'; body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
return; return;
} }
body.innerHTML = list body.innerHTML = list
@@ -103,6 +146,8 @@
const err = m.error const err = m.error
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>" ? '<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 ( return (
"<tr>" + "<tr>" +
"<td><strong>" + "<td><strong>" +
@@ -113,6 +158,11 @@
"<td>" + "<td>" +
status + status +
"</td>" + "</td>" +
'<td><span class="stats-mode-badge ' +
modeCls +
'">' +
escapeHtml(modeLabel(m.mode)) +
"</span></td>" +
"<td>" + "<td>" +
escapeHtml(fmtNum(m.cash_usdt)) + escapeHtml(fmtNum(m.cash_usdt)) +
"</td>" + "</td>" +
@@ -135,6 +185,13 @@
'">' + '">' +
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") + escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
"</td>" + "</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>" + "<td>" +
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") + escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
"</td>" + "</td>" +
@@ -147,6 +204,61 @@
.join(""); .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) { function drawCurve(data) {
const canvas = document.getElementById("stats-curve-canvas"); const canvas = document.getElementById("stats-curve-canvas");
const empty = document.getElementById("stats-curve-empty"); const empty = document.getElementById("stats-curve-empty");
@@ -155,25 +267,28 @@
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 960; const cssW = canvas.clientWidth || 960;
const cssH = 320; const wrap = canvas.parentElement;
const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
canvas.width = Math.floor(cssW * dpr); canvas.width = Math.floor(cssW * dpr);
canvas.height = Math.floor(cssH * dpr); canvas.height = Math.floor(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH); ctx.clearRect(0, 0, cssW, cssH);
const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked; const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
const lines = []; const lines = [];
if (data && data.total && data.total.length) { if (showTotal && data && data.total && data.total.length) {
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 }); lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
} }
if (showMachines && data && data.series) { if (data && data.series) {
data.series.forEach(function (s, i) { data.series.forEach(function (s, i) {
const id = String(s.id);
if (selectedIds && !selectedIds.has(id)) return;
if (!s.points || !s.points.length) return; if (!s.points || !s.points.length) return;
lines.push({ lines.push({
name: s.name || String(s.id), name: s.name || id,
color: COLORS[i % COLORS.length], color: COLORS[i % COLORS.length],
points: s.points, points: s.points,
width: 1.4, width: 1.6,
}); });
}); });
} }
@@ -230,7 +345,7 @@
maxV = minV + 1; maxV = minV + 1;
minV = minV - 1; minV = minV - 1;
} }
const pad = { l: 52, r: 16, t: 16, b: 28 }; const pad = { l: 56, r: 18, t: 18, b: 32 };
const w = cssW - pad.l - pad.r; const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b; const h = cssH - pad.t - pad.b;
@@ -243,13 +358,13 @@
ctx.strokeStyle = "rgba(56,189,248,0.15)"; ctx.strokeStyle = "rgba(56,189,248,0.15)";
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 5; i++) {
const y = pad.t + (h * i) / 4; const y = pad.t + (h * i) / 5;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(pad.l, y); ctx.moveTo(pad.l, y);
ctx.lineTo(pad.l + w, y); ctx.lineTo(pad.l + w, y);
ctx.stroke(); ctx.stroke();
const val = maxV - ((maxV - minV) * i) / 4; const val = maxV - ((maxV - minV) * i) / 5;
ctx.fillStyle = "#8aa0b8"; ctx.fillStyle = "#8aa0b8";
ctx.font = "11px IBM Plex Sans, sans-serif"; ctx.font = "11px IBM Plex Sans, sans-serif";
ctx.fillText(val.toFixed(0), 8, y + 4); ctx.fillText(val.toFixed(0), 8, y + 4);
@@ -271,36 +386,93 @@
}); });
} }
async function loadAll() { async function loadAll(opts) {
setStatus("加载中…"); opts = opts || {};
if (statsLoading) return;
statsLoading = true;
if (!opts.silent) setStatus("读取缓存…");
try { try {
const [sumRes, curveRes] = await Promise.all([ const [sumRes, curveRes] = await Promise.all([
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)), fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)), credentials: "same-origin",
}),
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
]); ]);
const sum = await sumRes.json(); const sum = await sumRes.json();
const curve = await curveRes.json(); const curve = await curveRes.json();
if (!sum.ok) throw new Error(sum.msg || "汇总失败"); 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 || "曲线失败"); if (!curve.ok) throw new Error(curve.msg || "曲线失败");
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
summaryData = sum; summaryData = sum;
curveData = curve; curveData = curve;
renderKpis(sum.totals); renderKpis(sum.totals || {});
renderTable(sum.machines); renderTable(sum.machines || []);
renderMachinePicker(curve);
drawCurve(curve); drawCurve(curve);
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
const live = statsEventSource && statsEventSource.readyState === 1;
setStatus( setStatus(
(live ? "SSE 已连接 · " : "") +
"窗口 " + "窗口 " +
windowKey + windowKey +
" · 在线 " + " · 缓存 " +
(sum.totals.online || 0) + ((sum.totals && sum.totals.online) || 0) +
"/" + "/" +
(sum.totals.machines || 0) + ((sum.totals && sum.totals.machines) || 0) +
" · 曲线采样点以巡检累积" (sum.updated_at ? " · " + sum.updated_at : "") +
(sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新")
); );
} catch (e) { } catch (e) {
setStatus(String(e.message || e), true); 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() { function bind() {
const tabs = document.getElementById("stats-window-tabs"); const tabs = document.getElementById("stats-window-tabs");
if (tabs) { if (tabs) {
@@ -314,12 +486,38 @@
loadAll(); loadAll();
}); });
} }
const toggle = document.getElementById("stats-show-machines"); const showTotal = document.getElementById("stats-show-total");
if (toggle) { if (showTotal) {
toggle.addEventListener("change", function () { showTotal.addEventListener("change", function () {
if (curveData) drawCurve(curveData); 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 () { window.addEventListener("resize", function () {
if (curveData) drawCurve(curveData); if (curveData) drawCurve(curveData);
}); });
@@ -327,4 +525,5 @@
bind(); bind();
loadAll(); loadAll();
connectStatsStream();
})(); })();
+7
View File
@@ -0,0 +1,7 @@
/* 期权中控 PWA:仅占位使可安装,请求一律走网络 */
self.addEventListener("install", function (event) {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
+64 -7
View File
@@ -2,18 +2,49 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <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> <title>{% block title %}加密货币|OKX 期权中控{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=36"> <meta name="theme-color" content="#070b14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="期权中控">
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=54">
<script>
(function () {
try {
if (window.matchMedia("(max-width: 720px)").matches) {
document.documentElement.classList.add("hub-phone-html");
}
} catch (e) {}
})();
</script>
</head> </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 %}"> <body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path.startswith('/instances') %}page-instances{% elif request.path.startswith('/settings') %}page-settings{% elif request.path.startswith('/cond_orders') %}page-cond-orders{% elif request.path == '/' %}page-dashboard{% endif %}">
{% if session.get('logged_in') %} {% if session.get('logged_in') %}
<a href="/logout" class="topbar-logout">退出</a> <a href="/logout" class="topbar-logout hub-desk-chrome">退出</a>
{% endif %} {% endif %}
<main class="main"> <main class="main">
{% if session.get('logged_in') %} {% if session.get('logged_in') %}
<a href="/" class="topbar-brand">加密货币|OKX 期权中控</a> <header class="hub-phone-bar hub-phone-chrome">
<div class="topbar-row"> <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"> <nav class="topbar-nav">
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a> <a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
<a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a> <a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a>
@@ -22,7 +53,7 @@
<a href="/stats" class="{% if request.path.startswith('/stats') %}active{% endif %}">数据统计</a> <a href="/stats" class="{% if request.path.startswith('/stats') %}active{% endif %}">数据统计</a>
<a href="/settings" class="{% if request.path.startswith('/settings') %}active{% endif %}">系统设置</a> <a href="/settings" class="{% if request.path.startswith('/settings') %}active{% endif %}">系统设置</a>
</nav> </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"> <div class="nav-spot-row" data-base="ETH">
<span class="nav-spot-ribbon" aria-hidden="true"></span> <span class="nav-spot-ribbon" aria-hidden="true"></span>
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span> <span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
@@ -47,12 +78,38 @@
{% endwith %} {% endwith %}
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </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') %} {% 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 %} {% endif %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
{% if session.get('logged_in') and not request.path.startswith('/desk') %} {% if session.get('logged_in') and not request.path.startswith('/desk') %}
<script>if (window.HubQuotes) HubQuotes.boot();</script> <script>if (window.HubQuotes) HubQuotes.boot();</script>
{% endif %} {% endif %}
<script src="{{ url_for('static', filename='hub_pwa.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='hub_phone.js') }}?v=1"></script>
<script>if (window.HubPwa) HubPwa.boot();</script>
<script>if (window.HubPhone) HubPhone.boot();</script>
</body> </body>
</html> </html>
+74 -36
View File
@@ -3,83 +3,121 @@
{% block body %} {% block body %}
<section class="page-head cond-head"> <section class="page-head cond-head">
<div> <div>
<h1>左侧条件开仓</h1> <h1>条件单</h1>
<p class="muted cond-lead">中控只下发;盯盘、买币、开平仓都在客户端。有仓或已占用的机器不可选。</p> <p class="muted cond-lead">中控下发到实例;列表由中控后台缓存并推送到浏览器。盯盘与开平仓在客户端。有仓或已占用的机器不可选。</p>
<p class="muted" id="cond-status">读取缓存…</p>
</div> </div>
<div class="cond-head-actions">
<button type="button" class="btn" id="btn-cond-refresh">刷新</button> <button type="button" class="btn" id="btn-cond-refresh">刷新</button>
<label for="cond-add-toggle" class="settings-primary inst-add-trigger">新建条件单</label>
</div>
</section> </section>
<input type="checkbox" id="cond-add-toggle" class="cond-add-check">
<div class="cond-layout"> <div class="cond-layout">
<section class="panel cond-card"> <section class="panel cond-card cond-add-panel">
<div class="cond-card-head"> <div class="cond-card-head">
<h2>新建条件单</h2> <h2>新建条件单</h2>
<span class="cond-chip">左侧单</span> <span class="cond-chip">左侧单</span>
</div> </div>
<div class="cond-section"> <div class="cond-create-split">
<div class="cond-section-title">入场</div> <div class="cond-create-left">
<div class="cond-grid"> <div class="cond-col-title">策略</div>
<label>标的
<select id="cf-underlying"> <div class="cond-block">
<option value="ETH">ETH</option> <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> <option value="BTC">BTC</option>
</select> </select>
</label> </div>
<label>方向 <div class="cond-uly-side-item">
<select id="cf-opt-type"> <span class="cond-mini-label">方向</span>
<option value="P">看跌 Put</option> <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> <option value="C">看涨 Call</option>
</select> </select>
</label> </div>
<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> </div>
<div class="cond-section"> <div class="cond-block">
<div class="cond-section-title">出场</div> <div class="cond-param-row">
<div class="cond-grid"> <label class="cond-field">触发价
<label>目标指数(可选) <input id="cf-trigger-px" type="number" step="any" placeholder="如 3000" required>
<input id="cf-target" type="number" step="any" placeholder="如 2800">
</label> </label>
<label>翻倍倍数 <label class="cond-field">偏离 %
<input id="cf-pe-mult" type="number" step="0.1" value="1" placeholder="1"> <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>
<label class="cond-switch"> <label class="cond-switch">
<input id="cf-pe" type="checkbox" checked> <input id="cf-pe" type="checkbox" checked>
<span>翻倍出场</span> <span>翻倍出场</span>
</label> </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> </div>
<div class="cond-section"> <div class="cond-create-right">
<div class="cond-section-title">客户端</div> <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 id="cf-instances"></div>
</div> </div>
</div>
<div class="cond-actions"> <div class="cond-actions">
<button type="button" class="btn" id="btn-cond-create">下发条件单</button> <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> <span class="muted" id="cf-status"></span>
</div> </div>
</section> </section>
<section class="panel cond-card"> <section class="panel cond-card">
<div class="cond-card-head"> <div class="cond-card-head">
<h2>条件单列表</h2> <h2>进行中</h2>
</div> </div>
<div id="cond-list" class="muted">加载中…</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> </section>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='cond_orders.js') }}"></script> <script src="{{ url_for('static', filename='cond_orders.js') }}?v=16"></script>
<script>HubCondOrders.boot();</script> <script>HubCondOrders.boot();</script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -15,6 +15,6 @@
<div id="overview-grid"></div> <div id="overview-grid"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% 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> <script>HubOverview.boot();</script>
{% endblock %} {% endblock %}
+35
View File
@@ -37,9 +37,44 @@
{% endif %} {% endif %}
</div> </div>
</section> </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 %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <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 () { (function () {
var KEY = "hub_desk_side_collapsed"; var KEY = "hub_desk_side_collapsed";
var root = document.getElementById("desk-root"); var root = document.getElementById("desk-root");
+2 -2
View File
@@ -103,7 +103,7 @@
<div class="backup-block"> <div class="backup-block">
<div class="backup-block-head"> <div class="backup-block-head">
<h3>中控备份</h3> <h3>中控备份</h3>
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code>。换机时先上传家里下载的包,再点恢复。</p> <p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code>、<code>hub.db</code>、当前登录用户名和密码。换机时先上传家里下载的包,再点恢复。</p>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button> <button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button> <button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
@@ -188,7 +188,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=3"></script> <script src="{{ url_for('static', filename='settings_backup.js') }}?v=4"></script>
<script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script> <script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
<script> <script>
HubSettingsBackup.boot(); HubSettingsBackup.boot();
+26 -23
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="settings-eyebrow">Analytics</p> <p class="settings-eyebrow">Analytics</p>
<h1>数据统计</h1> <h1>数据统计</h1>
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p> <p class="muted settings-lead">多机 KPI 对比与累计已实现盈亏曲线。划转/提现不影响曲线。后台常驻刷新,打开即读缓存。</p>
</div> </div>
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist"> <div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
<button type="button" class="stats-win-btn" data-window="today">今日</button> <button type="button" class="stats-win-btn" data-window="today">今日</button>
@@ -15,35 +15,17 @@
</div> </div>
</section> </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"> <section class="panel stats-kpi-panel" id="stats-kpi-panel">
<div class="stats-kpi-grid" id="stats-kpi-grid"></div> <div class="stats-kpi-grid" id="stats-kpi-grid"></div>
</section> </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"> <section class="panel stats-table-panel">
<div class="stats-card-head-inline"> <div class="stats-card-head-inline">
<div> <div>
<h2>机器对比</h2> <h2>机器对比</h2>
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p> <p class="muted">实时资金状态 + 当前窗口交易 KPI。排序:模拟在上、实盘在下。收益率 = 窗口已结算盈亏 ÷ 初始资金。</p>
</div> </div>
</div> </div>
<div class="overview-table-wrap"> <div class="overview-table-wrap">
@@ -52,23 +34,44 @@
<tr> <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> <th>笔数</th>
<th>胜率</th> <th>胜率</th>
<th>已实现</th> <th>已实现</th>
<th>收益率</th>
<th>均持仓</th> <th>均持仓</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="stats-table-body"> <tbody id="stats-table-body">
<tr><td colspan="10" class="muted">加载中…</td></tr> <tr><td colspan="12" class="muted">加载中…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </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 %} {% endblock %}
{% block scripts %} {% 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 %} {% 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()