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
HUB_PULL_INTERVAL_SEC=10
HUB_PULL_TIMEOUT_SEC=5
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
HUB_SSE_HEARTBEAT_SEC=25
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
HUB_STATS_POLL_INTERVAL_SEC=15
# 条件单列表后台并行拉取间隔(秒)
HUB_COND_POLL_INTERVAL_SEC=10
# 中控企业微信(离线/故障告警,与客户端通知分开)
HUB_WECHAT_ENABLED=false
+9
View File
@@ -0,0 +1,9 @@
Copyright (c) 2026 马建军. All rights reserved.
本仓库源代码及相关文档的著作权归马建军所有。
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
Author: 马建军
QQ: 253921841
Email: 253921841@qq.com
Website: https://hyf2.cc
+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 — 期权多机中控."""
from __future__ import annotations
import os
import sys
import threading
import time
from functools import wraps
from pathlib import Path
@@ -19,6 +20,7 @@ from flask import (
request,
send_file,
session,
stream_with_context,
url_for,
)
@@ -44,13 +46,13 @@ from lib.bridge_client_lib import (
fetch_backup_file,
fetch_backup_list,
fetch_client_version,
fetch_cond_orders,
post_client_update,
post_close_full,
post_cond_cancel,
post_cond_exit,
pull_all_enabled,
)
from lib.cond_orders_cache_lib import cond_orders_store
from lib.cash_sample_lib import record_cash_samples_from_overview
from lib.db_lib import db_path as hub_db_path, init_db
from lib.env_lib import apply_env_updates, env_get
@@ -62,8 +64,10 @@ from lib.instances_lib import (
list_instances,
update_instance,
)
from lib.overview_cache_lib import overview_store
from lib.sso_lib import build_embed_open_url
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
from lib.stats_agg_lib import normalize_window
from lib.stats_cache_lib import stats_store
from lib.wechat_lib import (
hub_alert_cooldown_sec,
hub_health_fail_threshold,
@@ -157,21 +161,43 @@ def stats_page():
@app.route("/api/stats/summary")
@login_required
def api_stats_summary():
"""只读统计热缓存;后台常驻刷新各窗口."""
window = normalize_window(request.args.get("window") or "7d")
try:
return jsonify(build_stats_summary(window))
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
stats_store.request_refresh()
return jsonify(stats_store.summary_dict(window))
@app.route("/api/stats/curve")
@login_required
def api_stats_curve():
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
try:
return jsonify(build_stats_curve(range_key))
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
stats_store.request_refresh()
return jsonify(stats_store.curve_dict(range_key))
@app.route("/api/stats/refresh", methods=["POST"])
@login_required
def api_stats_refresh():
stats_store.request_refresh()
return jsonify({"ok": True, "msg": "已触发统计后台刷新"})
@app.route("/api/stats/stream")
@login_required
def api_stats_stream():
return Response(
stream_with_context(stats_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/instances", methods=["GET", "POST"])
@@ -362,6 +388,8 @@ def api_hub_backup_create():
env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(),
label="manual",
username=hub_username(),
password=hub_password(),
)
return jsonify(result)
except Exception as exc:
@@ -395,6 +423,12 @@ def api_hub_backup_restore():
env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(),
)
if result.get("ok"):
u = str(result.get("hub_username") or "").strip()
p = result.get("hub_password")
if u or p is not None:
set_hub_credentials(u or hub_username(), "" if p is None else str(p))
result.pop("hub_password", None)
return jsonify(result), (200 if result.get("ok") else 400)
@@ -478,38 +512,64 @@ def desk():
)
@app.route("/api/overview")
@login_required
def api_overview():
rows = pull_all_enabled()
return jsonify({"ok": True, "instances": rows})
def _health_pull_interval_sec() -> float:
def _on_overview_pulled(rows: list) -> None:
"""常驻 pull 回调:健康巡检 + 余额采样(与浏览器是否打开无关)."""
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)
except Exception:
pass
try:
record_cash_samples_from_overview(rows)
except Exception:
pass
time.sleep(_health_pull_interval_sec())
def start_health_watch() -> None:
t = threading.Thread(target=_health_watch_loop, name="hub-health-watch", daemon=True)
t.start()
@app.route("/api/overview")
@login_required
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:
start_hub_backup_scheduler(
@@ -517,6 +577,7 @@ try:
env_path=str(ENV_PATH),
db_path_fn=_resolved_hub_db_path,
hour=3,
get_credentials=lambda: (hub_username(), hub_password()),
)
except Exception as _hub_bak_err:
print(f"[backup] scheduler: {_hub_bak_err}")
@@ -528,13 +589,52 @@ def api_open_url(iid: int):
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "不存在"}), 404
tab = (request.args.get("tab") or "").strip() or None
url = build_embed_open_url(
inst["base_url"],
bridge_token=inst["bridge_token"],
embed_path=inst.get("embed_path") or "/embed",
ttl_sec=3600,
tab=tab,
)
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]})
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"], "tab": tab})
@app.route("/api/instances/<int:iid>/transfer", methods=["POST"])
@login_required
def api_instance_transfer(iid: int):
"""条件单页本页划转:中控发指令,实例执行资金户↔交易户."""
from lib.bridge_client_lib import post_instance_transfer
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "不存在"}), 404
data = request.get_json(silent=True) or {}
try:
amount = float(data.get("amount"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "数量无效"}), 400
if amount <= 0:
return jsonify({"ok": False, "msg": "数量须大于 0"}), 400
from_acct = (data.get("from") or "funding").strip() or "funding"
to_acct = (data.get("to") or "trading").strip() or "trading"
ccy = (data.get("ccy") or "USDT").strip().upper() or "USDT"
out = post_instance_transfer(
inst,
amount=amount,
from_acct=from_acct,
to_acct=to_acct,
ccy=ccy,
)
# 成功后轻量刷新总览/条件单缓存,供下次读到新余额;不整页重载
if out.get("ok"):
try:
overview_store.request_refresh()
cond_orders_store.request_refresh()
except Exception:
pass
code = 200 if out.get("ok") else 400
return jsonify(out), code
@app.route("/api/settings/update/hub/version", methods=["GET"])
@@ -637,49 +737,66 @@ def cond_orders_page():
@app.route("/api/cond_orders/candidates")
@login_required
def api_cond_order_candidates():
"""可选客户端:在线且无持仓、未被条件单锁定."""
rows = pull_all_enabled()
out = []
for row in rows:
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
brief = snap.get("positions_brief") or snap.get("positions") or []
has_pos = isinstance(brief, list) and len(brief) > 0
locked = bool(snap.get("cond_order_locked"))
selectable = bool(row.get("online")) and not has_pos and not locked
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
mode = str(inst.get("mode") or "").strip().lower()
if mode not in ("sim", "live"):
mode = ""
out.append(
"""可选客户端:读热缓存(总览快照推导),不现场拉实例."""
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
cond_orders_store.request_refresh()
overview_store.request_refresh()
snap = cond_orders_store.snapshot_dict()
return jsonify(
{
"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 "",
"ok": True,
"instances": snap.get("candidates") or [],
"cond_version": snap.get("cond_version"),
"updated_at": snap.get("updated_at"),
"refreshing": snap.get("refreshing"),
"from_cache": True,
}
)
return jsonify({"ok": True, "instances": out})
@app.route("/api/cond_orders", methods=["GET"])
@login_required
def api_cond_orders_list():
instances = list_instances(enabled_only=True)
all_orders = []
for inst in instances:
data = fetch_cond_orders(inst)
for o in data.get("orders") or []:
if not isinstance(o, dict):
continue
item = dict(o)
item["instance_id"] = inst.get("id")
item["instance_name"] = inst.get("name")
all_orders.append(item)
return jsonify({"ok": True, "orders": all_orders})
"""条件单列表只读热缓存;后台常驻并行从实例拉取."""
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
if force:
cond_orders_store.request_refresh()
snap = cond_orders_store.snapshot_dict()
return jsonify(
{
"ok": True,
"orders": snap.get("orders") or [],
"cond_version": snap.get("cond_version"),
"updated_at": snap.get("updated_at"),
"refreshing": snap.get("refreshing"),
"from_cache": True,
"poll_interval_sec": snap.get("poll_interval_sec"),
"error": snap.get("error"),
}
)
@app.route("/api/cond_orders/refresh", methods=["POST"])
@login_required
def api_cond_orders_refresh():
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify({"ok": True, "msg": "已触发条件单后台刷新"})
@app.route("/api/cond_orders/stream")
@login_required
def api_cond_orders_stream():
return Response(
stream_with_context(cond_orders_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/cond_orders", methods=["POST"])
@@ -696,8 +813,9 @@ def api_cond_orders_create():
if not instance_ids:
return jsonify({"ok": False, "msg": "请选择客户端"})
# 再次按快照过滤有仓/锁定
rows = {r.get("id"): r for r in pull_all_enabled()}
# 再次按热缓存快照过滤有仓/锁定(下发本身仍 POST 到实例)
ov = overview_store.snapshot_dict()
rows = {r.get("id"): r for r in (ov.get("instances") or [])}
allowed = []
rejected = []
for iid in instance_ids:
@@ -739,6 +857,9 @@ def api_cond_orders_create():
}
results = dispatch_cond_order_to_ids(allowed, payload)
ok_n = sum(1 for r in results if r.get("ok"))
if ok_n > 0:
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(
{
"ok": ok_n > 0,
@@ -760,7 +881,11 @@ def api_cond_orders_cancel(hub_order_id: str):
inst = get_instance(int(iid))
if not inst:
return jsonify({"ok": False, "msg": "实例不存在"}), 404
return jsonify(post_cond_cancel(inst, hub_order_id))
result = post_cond_cancel(inst, hub_order_id)
if isinstance(result, dict) and result.get("ok"):
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(result)
@app.route("/api/cond_orders/<hub_order_id>/exit", methods=["POST"])
@@ -778,7 +903,26 @@ def api_cond_orders_exit(hub_order_id: str):
"profit_exit_enabled": data.get("profit_exit_enabled"),
"profit_exit_mult": data.get("profit_exit_mult"),
}
return jsonify(post_cond_exit(inst, hub_order_id, payload))
result = post_cond_exit(inst, hub_order_id, payload)
if isinstance(result, dict) and result.get("ok"):
cond_orders_store.request_refresh()
overview_store.request_refresh()
return jsonify(result)
@app.route("/manifest.webmanifest")
def pwa_manifest():
path = ROOT / "static" / "icons" / "manifest.webmanifest"
return send_file(path, mimetype="application/manifest+json; charset=utf-8")
@app.route("/sw.js")
def pwa_service_worker():
path = ROOT / "static" / "sw.js"
resp = send_file(path, mimetype="application/javascript; charset=utf-8")
resp.headers["Cache-Control"] = "no-cache"
resp.headers["Service-Worker-Allowed"] = "/"
return resp
if __name__ == "__main__":
+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
def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]:
def create_backup(
*,
root_dir: str,
env_path: str,
db_path: str,
label: str = "manual",
username: str | None = None,
password: str | None = None,
) -> dict[str, Any]:
with _LOCK:
now = beijing_now()
name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name)
u = (username if username is not None else os.getenv("HUB_USERNAME") or "admin").strip() or "admin"
p = password if password is not None else (os.getenv("HUB_PASSWORD") or "admin123")
if p is None:
p = "admin123"
manifest = {
"kind": "crypto_okx_hub",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual",
"includes": [".env", "database"],
"includes": [".env", "database", "hub_login"],
"hub_username": u,
}
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_")
os.close(tmp_fd)
env_snap = ""
try:
env_snap = _snapshot_env_with_login(env_path, u, str(p))
with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
@@ -126,8 +141,8 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
os.remove(manifest_path)
except OSError:
pass
if env_path and os.path.isfile(env_path):
tar.add(env_path, arcname=".env")
if env_snap and os.path.isfile(env_snap):
tar.add(env_snap, arcname=".env")
if db_path and os.path.isfile(db_path):
tar.add(db_path, arcname="database/" + os.path.basename(db_path))
shutil.move(tmp_path, out_path)
@@ -137,6 +152,12 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
except OSError:
pass
raise
finally:
if env_snap:
try:
os.remove(env_snap)
except OSError:
pass
prune_backups()
st = os.stat(out_path)
return {
@@ -149,6 +170,21 @@ def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "m
}
def _snapshot_env_with_login(env_path: str, username: str, password: str) -> str:
"""复制 .env 并写入当前中控登录账号,避免包内缺 HUB_USERNAME/PASSWORD."""
fd, snap = tempfile.mkstemp(suffix=".env", prefix="hub_env_")
os.close(fd)
if env_path and os.path.isfile(env_path):
shutil.copy2(env_path, snap)
else:
with open(snap, "w", encoding="utf-8") as f:
f.write("")
from lib.env_lib import apply_env_updates
apply_env_updates(snap, {"HUB_USERNAME": username, "HUB_PASSWORD": password})
return snap
def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]:
path = backup_path(name)
if not path or not os.path.isfile(path):
@@ -168,10 +204,23 @@ def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) ->
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path)
break
restored_user = ""
restored_password = ""
if env_path and os.path.isfile(env_path):
from lib.env_lib import env_get
restored_user = env_get(env_path, "HUB_USERNAME", "")
restored_password = env_get(env_path, "HUB_PASSWORD", "")
if restored_user:
os.environ["HUB_USERNAME"] = restored_user
if restored_password:
os.environ["HUB_PASSWORD"] = restored_password
return {
"ok": True,
"msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效",
"msg": "已恢复 .env / 数据库 / 登录账号,建议重启中控进程使配置完全生效",
"name": os.path.basename(path),
"hub_username": restored_user,
"hub_password": restored_password,
}
@@ -237,6 +286,8 @@ def maybe_run_auto_backup(
env_path: str,
db_path: str,
hour: int = 3,
username: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
global _LAST_AUTO_DAY
now = beijing_now()
@@ -255,6 +306,8 @@ def maybe_run_auto_backup(
env_path=env_path,
db_path=db_path,
label="auto",
username=username,
password=password,
)
_LAST_AUTO_DAY = day
return result
@@ -266,15 +319,24 @@ def start_backup_scheduler(
env_path: str,
db_path_fn: Callable[[], str],
hour: int = 3,
get_credentials: Callable[[], tuple[str, str]] | None = None,
) -> None:
def loop() -> None:
while True:
try:
u, p = ("", "")
if get_credentials:
try:
u, p = get_credentials()
except Exception:
u, p = "", ""
maybe_run_auto_backup(
root_dir=root_dir,
env_path=env_path,
db_path=db_path_fn(),
hour=hour,
username=u or None,
password=p if p != "" else None,
)
except Exception:
pass
+160
View File
@@ -215,6 +215,40 @@ def fetch_cond_orders(inst: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")}
def pull_all_cond_orders() -> list[dict[str, Any]]:
"""并行拉取各机条件单并展平为带 instance 字段的订单列表."""
instances = list_instances(enabled_only=True)
if not instances:
return []
by_id: dict[Any, dict[str, Any]] = {}
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
futs = {pool.submit(fetch_cond_orders, inst): inst for inst in instances}
for fut in as_completed(futs):
inst = futs[fut]
try:
data = fut.result()
except Exception as e:
data = {
"ok": False,
"msg": str(e) or "拉取失败",
"orders": [],
"instance_id": inst.get("id"),
"instance_name": inst.get("name"),
}
by_id[inst.get("id")] = data if isinstance(data, dict) else {}
all_orders: list[dict[str, Any]] = []
for inst in instances:
data = by_id.get(inst["id"]) or {}
for o in data.get("orders") or []:
if not isinstance(o, dict):
continue
item = dict(o)
item["instance_id"] = inst.get("id")
item["instance_name"] = inst.get("name")
all_orders.append(item)
return all_orders
def pull_all_enabled() -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True)
if not instances:
@@ -291,6 +325,132 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
return results
def post_instance_transfer(
inst: dict[str, Any],
*,
amount: float,
from_acct: str = "funding",
to_acct: str = "trading",
ccy: str = "USDT",
) -> dict[str, Any]:
"""中控指令:实例账户内划转."""
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
out: dict[str, Any] = {
"ok": False,
"msg": "",
"instance_id": inst.get("id"),
"instance_name": inst.get("name"),
}
if not base or not token:
out["msg"] = "缺少 URL 或 Token"
return out
url = f"{base}/api/hub/options/transfer"
try:
r = requests.post(
url,
headers=_headers(token),
json={
"amount": float(amount),
"from": from_acct,
"to": to_acct,
"ccy": ccy,
},
timeout=max(_timeout(), 30.0),
)
data, err = _response_json(r)
if data is None:
out["msg"] = err or f"HTTP {r.status_code}"
return out
if not isinstance(data, dict):
out["msg"] = f"HTTP {r.status_code}"
return out
out.update(data)
out["instance_id"] = inst.get("id")
out["instance_name"] = inst.get("name")
if r.status_code >= 400 and not out.get("ok"):
out["ok"] = False
out.setdefault("msg", err or f"HTTP {r.status_code}")
return out
except requests.Timeout:
out["msg"] = "划转超时"
return out
except Exception as e:
out["msg"] = str(e) or "划转失败"
return out
def fetch_one_pnl_curve(inst: dict[str, Any], *, range_key: str = "7d") -> dict[str, Any]:
"""拉取单机累计已实现盈亏曲线."""
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
out: dict[str, Any] = {
"id": inst.get("id"),
"name": inst.get("name"),
"online": False,
"error": "",
"points": [],
"trade_count": 0,
"latest": None,
"metric": "realized_pnl",
}
if not base or not token:
out["error"] = "缺少 URL 或 Token"
return out
url = f"{base}/api/hub/options/pnl_curve"
try:
r = requests.get(
url,
headers={"X-Hub-Token": token, "Accept": "application/json"},
params={"range": range_key},
timeout=max(_timeout(), 15.0),
)
data = {}
try:
data = r.json()
except Exception:
data = {}
if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"):
out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}"
return out
out["online"] = True
pts = data.get("points") if isinstance(data.get("points"), list) else []
out["points"] = pts
out["trade_count"] = int(data.get("trade_count") or 0)
out["latest"] = data.get("latest")
out["metric"] = data.get("metric") or "realized_pnl"
out["start_ms"] = data.get("start_ms")
out["end_ms"] = data.get("end_ms")
return out
except requests.Timeout:
out["error"] = "超时"
return out
except Exception as e:
out["error"] = str(e) or "拉取失败"
return out
def pull_all_pnl_curves(range_key: str = "7d") -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True)
if not instances:
return []
results: list[dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
futs = {
pool.submit(fetch_one_pnl_curve, inst, range_key=range_key): inst["id"]
for inst in instances
}
by_id: dict[Any, dict[str, Any]] = {}
for fut in as_completed(futs):
row = fut.result()
by_id[row.get("id")] = row
for inst in instances:
results.append(
by_id.get(inst["id"]) or fetch_one_pnl_curve(inst, range_key=range_key)
)
return results
def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]:
"""探测客户端 git 版本 / 在线."""
base = (inst.get("base_url") or "").rstrip("/")
+44
View File
@@ -90,6 +90,50 @@ def record_cash_samples_from_overview(rows: list[dict[str, Any]]) -> int:
return len(to_write)
def first_cash_by_instance(instance_ids: list[Any] | None = None) -> dict[Any, float]:
"""各机最早一笔现金采样,作初始资金回退(实盘未配 LIVE_INITIAL 时)."""
conn = get_db()
try:
if instance_ids:
ids: list[int] = []
for x in instance_ids:
try:
ids.append(int(x))
except (TypeError, ValueError):
continue
if not ids:
return {}
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"""
SELECT instance_id, cash_usdt
FROM hub_cash_samples
WHERE instance_id IN ({placeholders})
ORDER BY ts_ms ASC
""",
tuple(ids),
).fetchall()
else:
rows = conn.execute(
"""
SELECT instance_id, cash_usdt
FROM hub_cash_samples
ORDER BY ts_ms ASC
"""
).fetchall()
finally:
conn.close()
out: dict[Any, float] = {}
for r in rows:
iid = r["instance_id"]
if iid in out:
continue
v = _safe_float(r["cash_usdt"])
if v is not None and v > 0:
out[iid] = round(v, 4)
return out
def query_cash_series(
*,
range_key: str = "7d",
+282
View File
@@ -0,0 +1,282 @@
"""条件单热缓存:后台常驻并行从实例拉取;浏览器只读缓存 + SSE.
下发/撤单/改出场仍由中控 POST 到实例,成功后触发刷新.
"""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Iterator
from typing import Any
def _poll_interval_sec() -> float:
try:
return max(
3.0,
float(
os.getenv("HUB_COND_POLL_INTERVAL_SEC")
or os.getenv("HUB_PULL_INTERVAL_SEC")
or 10
),
)
except (TypeError, ValueError):
return 10.0
def _sse_heartbeat_sec() -> float:
try:
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
except (TypeError, ValueError):
return 25.0
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in rows or []:
snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {}
brief = snap.get("positions_brief") or snap.get("positions") or []
has_pos = isinstance(brief, list) and len(brief) > 0
locked = bool(snap.get("cond_order_locked"))
selectable = bool(row.get("online")) and not has_pos and not locked
inst = snap.get("instance") if isinstance(snap.get("instance"), dict) else {}
mode = str(inst.get("mode") or "").strip().lower()
if mode not in ("sim", "live"):
mode = ""
funds = snap.get("funds") if isinstance(snap.get("funds"), dict) else {}
# 开仓吃交易户;优先 avail,回退总额
trading_usdt = _safe_float(funds.get("trading_usdt_avail"))
if trading_usdt is None:
trading_usdt = _safe_float(funds.get("trading_usdt"))
if trading_usdt is None:
trading_usdt = _safe_float(snap.get("trading_usdt"))
funding_usdt = _safe_float(funds.get("funding_usdt"))
if funding_usdt is None:
funding_usdt = _safe_float(snap.get("funding_usdt"))
if trading_usdt is not None:
trading_usdt = round(float(trading_usdt), 4)
if funding_usdt is not None:
funding_usdt = round(float(funding_usdt), 4)
transfer_blocked = bool(snap.get("transfer_blocked")) or has_pos
transfer_block_msg = str(snap.get("transfer_block_msg") or "").strip()
if has_pos and not transfer_block_msg:
transfer_block_msg = "期权持仓中禁止划转"
allowed_raw = snap.get("options_allowed_underlys")
if not isinstance(allowed_raw, list):
allowed_raw = inst.get("allowed_underlys")
allowed_underlys: list[str] = []
if isinstance(allowed_raw, list):
for x in allowed_raw:
u = str(x or "").strip().upper()
if u in ("ETH", "BTC") and u not in allowed_underlys:
allowed_underlys.append(u)
if not allowed_underlys:
# 旧实例未上报白名单:仍显示在 ETH/BTC 下(兼容)
allowed_underlys = ["ETH", "BTC"]
default_underly = str(
snap.get("options_underly") or inst.get("underly") or allowed_underlys[0]
).strip().upper()
if default_underly not in allowed_underlys:
default_underly = allowed_underlys[0]
out.append(
{
"id": row.get("id"),
"name": row.get("name"),
"online": bool(row.get("online")),
"has_position": has_pos,
"cond_order_locked": locked,
"selectable": selectable,
"mode": mode,
"error": row.get("error") or "",
"trading_usdt": trading_usdt,
"funding_usdt": funding_usdt,
"transfer_blocked": transfer_blocked,
"transfer_block_msg": transfer_block_msg,
"allowed_underlys": allowed_underlys,
"default_underly": default_underly,
}
)
return out
class CondOrdersStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.orders: list[dict[str, Any]] = []
self.candidates: list[dict[str, Any]] = []
self.refreshing = True
self.last_error: str | None = None
self.updated_at: str = ""
self._ready = False
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self.refreshing = True
self._ready = False
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="hub-cond-orders-pull",
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._refresh.set()
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def snapshot_dict(self) -> dict[str, Any]:
with self._lock:
return {
"ok": True,
"orders": list(self.orders),
"candidates": list(self.candidates),
"cond_version": self.version,
"refreshing": self.refreshing,
"updated_at": self.updated_at,
"error": self.last_error,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
"ready": self._ready,
}
def event_dict(self) -> dict[str, Any]:
with self._lock:
return {
"cond_version": self.version,
"updated_at": self.updated_at,
"refreshing": self.refreshing,
"ok": True,
"error": self.last_error,
"poll_interval_sec": _poll_interval_sec(),
}
def _overview_rows(self) -> list[dict[str, Any]]:
try:
from lib.overview_cache_lib import overview_store
snap = overview_store.snapshot_dict()
rows = snap.get("instances") if isinstance(snap, dict) else None
if isinstance(rows, list) and rows:
return rows
except Exception:
pass
try:
from lib.bridge_client_lib import pull_all_enabled
return pull_all_enabled()
except Exception:
return []
def _loop(self) -> None:
while not self._stop.is_set():
self._refresh_once()
if self._stop.is_set():
break
self._refresh.clear()
self._refresh.wait(timeout=_poll_interval_sec())
def _refresh_once(self) -> None:
with self._lock:
self.refreshing = True
self._broadcast()
try:
from lib.bridge_client_lib import pull_all_cond_orders
rows = self._overview_rows()
candidates = _candidates_from_snaps(rows)
orders = pull_all_cond_orders()
with self._lock:
self.version += 1
self.orders = orders if isinstance(orders, list) else []
self.candidates = candidates
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
self.last_error = None
self.refreshing = False
self._ready = True
except Exception as e:
with self._lock:
self.version += 1
self.last_error = str(e) or "cond_refresh_failed"
self.refreshing = False
self._ready = True
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
dead.append(q)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
with self._lock:
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = q.get(timeout=_sse_heartbeat_sec())
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: cond\ndata: {body}\n\n"
cond_orders_store = CondOrdersStore()
+197
View File
@@ -0,0 +1,197 @@
"""中控总览热缓存:后台常驻并行 pull,与浏览器是否打开无关;SSE 仅扇出版本通知."""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Callable, Iterator
from typing import Any
OnPullFn = Callable[[list[dict[str, Any]]], None]
def _poll_interval_sec() -> float:
try:
return max(3.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
except (TypeError, ValueError):
return 10.0
def _sse_heartbeat_sec() -> float:
try:
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
except (TypeError, ValueError):
return 25.0
class OverviewStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.instances: list[dict[str, Any]] = []
self.pulling = True # 启动至首轮完成前视为拉取中
self.last_error: str | None = None
self.updated_at: str = ""
self._ready = False
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
self._pull_fn: Callable[[], list[dict[str, Any]]] | None = None
self._on_pull: OnPullFn | None = None
def start(
self,
pull_fn: Callable[[], list[dict[str, Any]]],
*,
on_pull: OnPullFn | None = None,
) -> None:
self._pull_fn = pull_fn
self._on_pull = on_pull
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self.pulling = True
self._ready = False
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="hub-overview-pull",
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._refresh.set()
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def snapshot_dict(self) -> dict[str, Any]:
with self._lock:
rows = list(self.instances)
ver = self.version
pulling = self.pulling
err = self.last_error
updated_at = self.updated_at
return {
"ok": True,
"instances": rows,
"overview_version": ver,
"pulling": pulling,
"updated_at": updated_at,
"error": err,
"poll_interval_sec": _poll_interval_sec(),
"from_cache": True,
}
def event_dict(self) -> dict[str, Any]:
with self._lock:
return {
"overview_version": self.version,
"updated_at": self.updated_at,
"pulling": self.pulling,
"ok": True,
"error": self.last_error,
"poll_interval_sec": _poll_interval_sec(),
}
def _loop(self) -> None:
assert self._pull_fn is not None
while not self._stop.is_set():
self._pull_once()
if self._stop.is_set():
break
self._refresh.clear()
self._refresh.wait(timeout=_poll_interval_sec())
def _pull_once(self) -> None:
assert self._pull_fn is not None
with self._lock:
self.pulling = True
self._broadcast()
try:
rows = self._pull_fn()
if not isinstance(rows, list):
rows = []
if self._on_pull is not None:
try:
self._on_pull(rows)
except Exception:
pass
with self._lock:
self.version += 1
self.instances = rows
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
self.last_error = None
self.pulling = False
self._ready = True
except Exception as e:
with self._lock:
self.version += 1
self.last_error = str(e) or "pull_failed"
self.pulling = False
self._ready = True
# 保留上一轮成功缓存,避免整页空白
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
dead.append(q)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
with self._lock:
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = q.get(timeout=_sse_heartbeat_sec())
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: overview\ndata: {body}\n\n"
overview_store = OverviewStore()
+7
View File
@@ -24,11 +24,18 @@ def build_embed_open_url(
bridge_token: str,
embed_path: str = "/embed",
ttl_sec: int = 120,
tab: str | None = None,
) -> str:
root = (base_url or "").rstrip("/")
path = embed_path or "/embed"
if not path.startswith("/"):
path = "/" + path
tab_s = "".join(c for c in str(tab or "").strip().lower() if c.isalnum() or c == "_")[
:32
]
if tab_s:
joiner = "&" if "?" in path else "?"
path = f"{path}{joiner}tab={tab_s}"
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}"
+118 -14
View File
@@ -1,10 +1,14 @@
"""中控统计汇总:KPI 对比 + 余额曲线."""
"""中控统计汇总:KPI 对比 + 已实现盈亏曲线."""
from __future__ import annotations
import time
from typing import Any
from lib.bridge_client_lib import pull_all_enabled, pull_all_stats
from lib.cash_sample_lib import query_cash_series, record_cash_samples_from_overview
from lib.bridge_client_lib import pull_all_enabled, pull_all_pnl_curves, pull_all_stats
from lib.cash_sample_lib import (
first_cash_by_instance,
record_cash_samples_from_overview,
)
def _safe_float(v: Any) -> float | None:
@@ -36,9 +40,15 @@ def normalize_window(raw: Any) -> str:
return w if w in ("today", "7d", "30d", "all") else "7d"
def build_stats_summary(window: str = "7d") -> dict[str, Any]:
def build_stats_summary(
window: str = "7d",
*,
snaps: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
w = normalize_window(window)
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
# snaps 可由热缓存传入,避免统计页再现场重复拉全机
if snaps is None:
snaps = pull_all_enabled()
try:
record_cash_samples_from_overview(snaps)
@@ -46,6 +56,10 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
pass
stats_rows = pull_all_stats(w)
stats_by_id = {r.get("id"): r for r in stats_rows}
try:
initial_fallback = first_cash_by_instance([s.get("id") for s in snaps])
except Exception:
initial_fallback = {}
machines: list[dict[str, Any]] = []
sum_cash = 0.0
@@ -83,6 +97,16 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
win_rate = _safe_float(stats.get("win_rate"))
avg_hold = _safe_float(stats.get("avg_hold_sec"))
online = bool(snap.get("online"))
mode = ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
"mode"
)
# 收益率 = 窗口已结算盈亏 / 初始资金(非当前余额)
initial = _safe_float(premium.get("initial_cash_usdt"))
if initial is None or initial <= 0:
initial = _safe_float(initial_fallback.get(iid))
return_pct = None
if online and stats.get("ok") and net is not None and initial is not None and initial > 0:
return_pct = round(float(net) / float(initial) * 100.0, 2)
if online:
online_n += 1
if cash is not None:
@@ -110,19 +134,19 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
"name": snap.get("name"),
"online": online,
"error": snap.get("error") or st.get("error") or "",
"mode": ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
"mode"
),
"mode": mode,
"cash_usdt": cash,
"premium_usdt": paid,
"ratio_pct": ratio,
"unrealized_usdt": unreal,
"initial_cash_usdt": initial,
"pos_count": len(brief),
"total_closed": closed if online and stats.get("ok") else None,
"win_count": wins if online and stats.get("ok") else None,
"loss_count": losses if online and stats.get("ok") else None,
"win_rate": win_rate if online and stats.get("ok") else None,
"net_realized_pnl": net if online and stats.get("ok") else None,
"return_rate_pct": return_pct,
"total_profit": profit if online and stats.get("ok") else None,
"total_loss": loss if online and stats.get("ok") else None,
"avg_hold_sec": avg_hold if online and stats.get("ok") else None,
@@ -130,6 +154,14 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
}
)
def _mode_rank(m: dict[str, Any]) -> tuple[int, str]:
mode_v = str(m.get("mode") or "").strip().lower()
# 上方 sim,下方 live
rank = 0 if mode_v == "sim" else 1 if mode_v == "live" else 2
return (rank, str(m.get("name") or m.get("id") or ""))
machines.sort(key=_mode_rank)
agg_win_rate = round(sum_wins / sum_closed * 100, 2) if sum_closed else 0.0
return {
"ok": True,
@@ -153,12 +185,84 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
# 顺手采一笔,让打开页面时也有点
"""多机累计已实现盈亏曲线(各机台阶叠加;划转/提现不影响)."""
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:
snaps = pull_all_enabled()
record_cash_samples_from_overview(snaps)
except Exception:
t = int(p.get("t"))
v = float(p.get("v"))
except (TypeError, ValueError):
continue
pts.append({"t": t, "v": v})
pts.sort(key=lambda x: x["t"])
if row.get("start_ms") is not None:
try:
sm = int(row.get("start_ms"))
start_ms = sm if start_ms is None else min(start_ms, sm)
except (TypeError, ValueError):
pass
data = query_cash_series(range_key=range_key)
data["ok"] = True
return data
if row.get("end_ms") is not None:
try:
end_ms = max(end_ms, int(row.get("end_ms")))
except (TypeError, ValueError):
pass
by_id[iid] = pts
series.append(
{
"id": iid,
"name": row.get("name") or str(iid),
"online": bool(row.get("online")),
"error": row.get("error") or "",
"points": pts,
"trade_count": int(row.get("trade_count") or 0),
"latest": row.get("latest"),
}
)
# 合计:按 1 分钟桶,各机该桶最后值求和并前向填充
last_in_bucket: dict[int, dict[Any, float]] = {}
for iid, pts in by_id.items():
for p in pts:
bt = (int(p["t"]) // 60000) * 60000
last_in_bucket.setdefault(bt, {})[iid] = float(p["v"])
running: dict[Any, float] = {}
total_pts: list[dict[str, float]] = []
for bt in sorted(last_in_bucket.keys()):
running.update(last_in_bucket[bt])
if running:
total_pts.append({"t": bt, "v": round(sum(running.values()), 4)})
if start_ms is None:
if rk in ("today", "1d"):
import datetime as dt
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_ms = int(start.timestamp() * 1000)
elif rk in ("30d", "30"):
start_ms = now_ms - 30 * 86400 * 1000
elif rk in ("all", "0"):
start_ms = 0
else:
start_ms = now_ms - 7 * 86400 * 1000
return {
"ok": True,
"metric": "realized_pnl",
"series": series,
"total": total_pts,
"range": rk,
"start_ms": start_ms,
"end_ms": end_ms,
}
+268
View File
@@ -0,0 +1,268 @@
"""数据统计热缓存:后台按窗口并行刷新 summary/curve;浏览器只读缓存 + SSE."""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
STATS_WINDOWS = ("today", "7d", "30d", "all")
def _poll_interval_sec() -> float:
try:
return max(5.0, float(os.getenv("HUB_STATS_POLL_INTERVAL_SEC") or os.getenv("HUB_PULL_INTERVAL_SEC") or 15))
except (TypeError, ValueError):
return 15.0
def _sse_heartbeat_sec() -> float:
try:
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
except (TypeError, ValueError):
return 25.0
class StatsStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.summaries: dict[str, dict[str, Any]] = {}
self.curves: dict[str, dict[str, Any]] = {}
self.refreshing = True
self.last_error: str | None = None
self.updated_at: str = ""
self._ready = False
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self.refreshing = True
self._ready = False
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="hub-stats-pull",
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._refresh.set()
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def summary_dict(self, window: str = "7d") -> dict[str, Any]:
w = normalize_window(window)
with self._lock:
body = dict(self.summaries.get(w) or {})
ver = self.version
refreshing = self.refreshing
err = self.last_error
updated_at = self.updated_at
ready = self._ready
if not body:
return {
"ok": False,
"window": w,
"msg": "统计缓存尚未就绪" if not ready or refreshing else "暂无统计数据",
"stats_version": ver,
"refreshing": refreshing,
"updated_at": updated_at,
"error": err,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
}
body = dict(body)
body["stats_version"] = ver
body["refreshing"] = refreshing
body["updated_at"] = updated_at
body["error"] = err
body["from_cache"] = True
body["poll_interval_sec"] = _poll_interval_sec()
body.setdefault("ok", True)
return body
def curve_dict(self, range_key: str = "7d") -> dict[str, Any]:
w = normalize_window(range_key)
with self._lock:
body = dict(self.curves.get(w) or {})
ver = self.version
refreshing = self.refreshing
err = self.last_error
updated_at = self.updated_at
if not body:
return {
"ok": True,
"range": w,
"total": [],
"series": [],
"stats_version": ver,
"refreshing": refreshing,
"updated_at": updated_at,
"error": err,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
}
body = dict(body)
body["stats_version"] = ver
body["refreshing"] = refreshing
body["updated_at"] = updated_at
body["error"] = err
body["from_cache"] = True
body["poll_interval_sec"] = _poll_interval_sec()
body.setdefault("ok", True)
return body
def event_dict(self) -> dict[str, Any]:
with self._lock:
return {
"stats_version": self.version,
"updated_at": self.updated_at,
"refreshing": self.refreshing,
"ok": True,
"error": self.last_error,
"poll_interval_sec": _poll_interval_sec(),
}
def _loop(self) -> None:
while not self._stop.is_set():
self._refresh_once()
if self._stop.is_set():
break
self._refresh.clear()
self._refresh.wait(timeout=_poll_interval_sec())
def _current_snaps(self) -> list[dict[str, Any]]:
try:
from lib.overview_cache_lib import overview_store
snap = overview_store.snapshot_dict()
rows = snap.get("instances") if isinstance(snap, dict) else None
if isinstance(rows, list) and rows:
return rows
except Exception:
pass
try:
from lib.bridge_client_lib import pull_all_enabled
return pull_all_enabled()
except Exception:
return []
def _refresh_once(self) -> None:
with self._lock:
self.refreshing = True
self._broadcast()
try:
snaps = self._current_snaps()
summaries: dict[str, dict[str, Any]] = {}
curves: dict[str, dict[str, Any]] = {}
# 多窗口并行刷;每窗内部 pull_all_stats 已并行拉机
with ThreadPoolExecutor(max_workers=len(STATS_WINDOWS)) as pool:
futs = {
pool.submit(build_stats_summary, w, snaps=snaps): ("summary", w)
for w in STATS_WINDOWS
}
futs.update(
{
pool.submit(build_stats_curve, w): ("curve", w)
for w in STATS_WINDOWS
}
)
for fut in as_completed(futs):
kind, w = futs[fut]
try:
data = fut.result()
except Exception as e:
data = {"ok": False, "msg": str(e) or "failed", "window": w}
if kind == "summary":
summaries[w] = data if isinstance(data, dict) else {"ok": False}
else:
curves[w] = data if isinstance(data, dict) else {"ok": False, "total": [], "series": []}
with self._lock:
self.version += 1
self.summaries = summaries
self.curves = curves
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
self.last_error = None
self.refreshing = False
self._ready = True
except Exception as e:
with self._lock:
self.version += 1
self.last_error = str(e) or "stats_refresh_failed"
self.refreshing = False
self._ready = True
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
dead.append(q)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
with self._lock:
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = q.get(timeout=_sse_heartbeat_sec())
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: stats\ndata: {body}\n\n"
stats_store = StatsStore()
+688 -148
View File
@@ -37,6 +37,7 @@
leave_up: "结束·上破",
leave_down: "结束·下破",
cancel: "已取消",
timeout: "时间取消",
closed: "已完成",
};
return map[reason] || "已结束";
@@ -53,118 +54,27 @@
return "st-done";
}
function instanceMode(it) {
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;
function isHubPhone() {
return (
'<label class="cond-inst' +
(disabled ? " is-off" : "") +
'" 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>"
document.documentElement.classList.contains("hub-phone-html") ||
!!(document.body && document.body.classList.contains("hub-phone"))
);
}
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 orderTriggerText(o) {
const side = o.trigger_side === "lte" ? "下穿≤" : "上穿≥";
const px = o.trigger_px != null ? String(o.trigger_px) : "";
return side + px;
}
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 || [];
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);
function orderExitText(o) {
const bits = [];
if (o.profit_exit_enabled) bits.push("翻倍×" + (o.profit_exit_mult || ""));
if (o.target_index != null) bits.push("目标" + o.target_index);
return bits.join(" · ");
}
async function loadOrders() {
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 : "");
function orderActionHtml(o) {
const canCancel = o.status === "armed" || o.status === "triggering";
const canExit = o.status === "open";
let actions = "";
@@ -190,43 +100,11 @@
encodeURIComponent(String(o.profit_exit_mult || "1")) +
'">改出场</button>';
}
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(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>";
return actions;
}
function bindOrderActions(box) {
if (!box) return;
box.querySelectorAll("[data-cancel]").forEach(function (btn) {
btn.addEventListener("click", async function () {
if (!confirm("确认取消该条件单?")) return;
@@ -239,7 +117,7 @@
});
const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已取消" : "失败"));
refresh();
requestCondRefresh();
});
});
box.querySelectorAll("[data-exit]").forEach(function (btn) {
@@ -270,11 +148,550 @@
});
const dd = await rr.json();
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() {
const status = document.getElementById("cf-status");
const ids = Array.prototype.slice
@@ -308,26 +725,149 @@
const d = await r.json();
if (status) status.textContent = d.msg || "";
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() {
await Promise.all([loadCandidates(), loadOrders()]);
async function refresh(opts) {
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() {
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");
if (create) create.addEventListener("click", createOrder);
const typeEl = document.getElementById("cf-opt-type");
const pxEl = document.getElementById("cf-trigger-px");
if (typeEl) typeEl.addEventListener("change", 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();
});
const pe = document.getElementById("cf-pe");
if (pe) pe.addEventListener("change", syncPeMultEnabled);
syncPeMultEnabled();
syncTriggerHint();
// 读缓存秒开 + SSE;不再前端定时现场拉实例
refresh();
setInterval(refresh, 10000);
connectCondStream();
}
global.HubCondOrders = { boot: boot, refresh: refresh };
global.HubCondOrders = {
boot: boot,
refresh: refresh,
requestRefresh: requestCondRefresh,
};
})(window);
+711 -36
View File
@@ -1419,8 +1419,39 @@ input.readonly {
}
.cond-head { justify-content: space-between; align-items: flex-start; }
.cond-head-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.cond-head-actions .btn,
.cond-head-actions .settings-primary {
margin-top: 0;
box-sizing: border-box;
height: 36px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 8px;
font-size: inherit;
font-weight: 600;
}
.cond-lead { margin: 6px 0 0; max-width: 720px; line-height: 1.5; }
.cond-add-check {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.cond-layout { display: grid; gap: 16px; }
.cond-add-panel { display: none; }
#cond-add-toggle:checked ~ .cond-layout .cond-add-panel {
display: block;
}
.cond-card { padding: 18px 18px 16px; }
.cond-card-head {
display: flex;
@@ -1443,79 +1474,241 @@ input.readonly {
border-radius: 999px;
padding: 3px 10px;
}
.cond-section {
border-top: 1px solid rgba(56, 189, 248, 0.12);
padding: 14px 0 4px;
.cond-create-split {
display: grid;
grid-template-columns: minmax(260px, 0.92fr) minmax(300px, 1.18fr);
gap: 16px 18px;
align-items: start;
padding-top: 2px;
}
.cond-section:first-of-type { border-top: 0; padding-top: 0; }
.cond-section-title {
.cond-col-title {
font-size: 0.78rem;
color: var(--accent-2);
letter-spacing: 0.08em;
margin-bottom: 10px;
font-weight: 600;
letter-spacing: 0.06em;
margin: 0 0 10px;
font-weight: 650;
}
.cond-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px 14px;
.cond-block { margin-bottom: 14px; }
.cond-block:last-child { margin-bottom: 0; }
.cond-mini-label {
display: block;
font-size: 0.75rem;
color: #b6c7da;
margin-bottom: 6px;
}
.cond-uly-side-row {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: end;
}
.cond-grid label {
.cond-uly-side-item { min-width: 0; }
.cond-native-select[hidden] { display: none !important; }
.cond-seg {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 9px;
overflow: hidden;
background: rgba(8, 16, 30, 0.8);
}
.cond-seg-btn {
border: 0;
background: transparent;
color: var(--muted);
padding: 8px 14px;
font-size: 0.84rem;
cursor: pointer;
min-width: 72px;
margin: 0;
font-size: 0.8rem;
border-radius: 0;
font-weight: 500;
}
.cond-seg-btn + .cond-seg-btn { border-left: 1px solid var(--line); }
.cond-seg-btn.on {
background: rgba(56, 189, 248, 0.16);
color: var(--text);
font-weight: 650;
}
.cond-param-row {
display: grid;
grid-template-columns: 1.25fr 0.7fr 0.7fr;
gap: 10px;
align-items: end;
}
.cond-field {
display: block;
margin: 0;
font-size: 0.75rem;
color: #b6c7da;
}
.cond-grid input,
.cond-grid select {
.cond-field input {
margin-top: 6px;
width: 100%;
}
.cond-field-mult { min-width: 72px; }
.cond-field-mult input { width: 72px; }
.cond-trigger-hint {
grid-column: 1 / -1;
padding: 9px 12px;
border-radius: 10px;
font-size: 0.82rem;
margin-top: 8px;
padding: 8px 10px;
border-radius: 9px;
font-size: 0.78rem;
line-height: 1.45;
border: 1px solid var(--line);
background: rgba(8, 16, 30, 0.7);
color: var(--text);
}
.cond-trigger-hint.is-put { border-color: rgba(251, 113, 133, 0.35); }
.cond-trigger-hint.is-call { border-color: rgba(52, 211, 153, 0.35); }
.cond-trigger-hint.is-put { border-color: rgba(251, 113, 133, 0.35); color: #fda4af; }
.cond-trigger-hint.is-call { border-color: rgba(52, 211, 153, 0.35); color: #6ee7b7; }
.cond-exit-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: end;
}
.cond-switch {
display: flex !important;
align-items: center;
gap: 8px;
min-height: 62px;
margin: 0;
padding: 8px 0;
font-size: 0.84rem;
color: var(--text);
white-space: nowrap;
min-height: 0;
}
.cond-switch input { width: auto; margin: 0; }
.cond-inst-group + .cond-inst-group { margin-top: 14px; }
.cond-inst-group-label { margin-bottom: 8px; }
.cond-inst-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.cond-inst {
.cond-right-head {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.cond-right-head .cond-col-title { margin: 0; }
.cond-right-meta { font-size: 0.76rem; }
.cond-right-lead {
margin: 0 0 10px;
font-size: 0.76rem;
line-height: 1.4;
}
.cond-inst-group + .cond-inst-group { margin-top: 10px; }
.cond-inst-group-label { margin-bottom: 6px; }
.cond-inst-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.cond-inst-row {
display: block;
min-width: 0;
}
.cond-inst-row.is-off { opacity: 0.5; }
.cond-inst {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px 10px;
align-items: start;
min-width: 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(8, 16, 30, 0.72);
transition: border-color 0.15s ease, background 0.15s ease;
}
.cond-inst-row:not(.is-off) .cond-inst:hover {
border-color: rgba(56, 189, 248, 0.45);
background: rgba(56, 189, 248, 0.1);
}
.cond-inst-row:not(.is-off) .cond-inst:has(input:checked) {
border-color: rgba(56, 189, 248, 0.55);
background: rgba(56, 189, 248, 0.12);
}
.cond-inst > input[type="checkbox"] {
width: auto;
margin: 2px 0 0;
flex-shrink: 0;
grid-column: 1;
grid-row: 1 / span 2;
align-self: start;
}
.cond-inst-main {
min-width: 0;
grid-column: 2;
grid-row: 1;
}
.cond-inst-name {
display: block;
font-weight: 650;
font-size: 0.92rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.cond-inst input { width: auto; margin: 0; }
.cond-inst-name { flex: 1; font-weight: 600; }
.cond-inst-tag { color: var(--muted); font-size: 0.75rem; }
.cond-inst.is-off { opacity: 0.5; cursor: not-allowed; }
.cond-inst.is-off .cond-inst-name { cursor: not-allowed; }
.cond-inst-tag {
color: var(--muted);
font-size: 0.72rem;
flex-shrink: 0;
justify-self: end;
align-self: start;
grid-column: 3;
grid-row: 1;
}
.cond-inst-fund-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 3px;
}
.cond-inst-funds {
color: var(--accent-2, #38bdf8);
font-size: 0.76rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.cond-inst-funds--funding {
color: var(--muted);
font-weight: 500;
}
.cond-inst-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
grid-column: 2 / -1;
grid-row: 2;
padding-top: 2px;
}
.cond-inst-xfer {
flex-shrink: 0;
white-space: nowrap;
min-width: 5.5em;
box-sizing: border-box;
text-align: center;
justify-content: center;
}
.cond-inst.is-off { cursor: not-allowed; }
.cond-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(56, 189, 248, 0.12);
}
@media (max-width: 900px) {
.cond-create-split { grid-template-columns: 1fr; }
.cond-param-row { grid-template-columns: 1fr 1fr 1fr; }
.cond-exit-row { grid-template-columns: 1fr auto auto; }
}
@media (max-width: 560px) {
.cond-param-row,
.cond-exit-row { grid-template-columns: 1fr; }
.cond-field-mult input { width: 100%; }
}
.cond-empty { padding: 28px 8px; text-align: center; }
.cond-st {
@@ -1531,6 +1724,28 @@ input.readonly {
.cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); }
.cond-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); }
.cond-table-wrap { margin-top: 0; }
.cond-table-wrap .overview-table tbody tr {
transition: background 0.12s ease;
}
.cond-table-wrap .overview-table tbody tr:hover td {
background: rgba(56, 189, 248, 0.08);
}
.cond-td-time {
white-space: nowrap;
font-variant-numeric: tabular-nums;
font-size: 0.82rem;
}
.cond-pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.cond-pager .btn {
margin-top: 0;
}
/* —— 数据统计 —— */
.stats-head {
@@ -1609,6 +1824,62 @@ input.readonly {
font-size: 0.86rem;
}
.stats-curve-toggle input { width: auto; margin: 0; }
.stats-curve-panel--large { margin-top: 18px; }
.stats-machine-picker {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
margin: 0 0 12px;
align-items: center;
}
.stats-machine-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.18);
background: rgba(5, 12, 22, 0.65);
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
user-select: none;
}
.stats-machine-chip input { width: auto; margin: 0; }
.stats-machine-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.stats-pick-all {
border: 1px solid rgba(56, 189, 248, 0.22);
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 5px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.stats-pick-all:hover { color: var(--text); border-color: rgba(56, 189, 248, 0.4); }
.stats-mode-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.25);
color: var(--muted);
}
.stats-mode-badge.stats-mode-sim {
color: #67e8f9;
border-color: rgba(34, 211, 238, 0.35);
background: rgba(34, 211, 238, 0.08);
}
.stats-mode-badge.stats-mode-live {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08);
}
.stats-chart-wrap {
position: relative;
width: 100%;
@@ -1618,11 +1889,19 @@ input.readonly {
background: rgba(4, 10, 18, 0.65);
overflow: hidden;
}
.stats-chart-wrap--large {
min-height: 480px;
height: min(62vh, 560px);
}
#stats-curve-canvas {
display: block;
width: 100%;
height: 320px;
}
.stats-chart-wrap--large #stats-curve-canvas {
height: 100%;
min-height: 480px;
}
.stats-chart-empty {
position: absolute;
inset: 0;
@@ -1666,3 +1945,399 @@ input.readonly {
body.page-stats .main {
max-width: 1800px;
}
/* —— 手机单独壳:电脑样式不动,仅 html.hub-phone-html 启用 —— */
.hub-phone-chrome {
display: none;
}
html.hub-phone-html .hub-desk-chrome {
display: none !important;
}
html.hub-phone-html .hub-phone-bar {
display: flex;
}
html.hub-phone-html .hub-phone-tabbar {
display: flex;
}
html.hub-phone-html .hub-phone-more {
display: block;
}
html.hub-phone-html body {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
}
html.hub-phone-html body.page-desk {
height: auto;
overflow: auto;
}
html.hub-phone-html body.page-desk .main {
height: auto;
max-height: none;
display: block;
padding: 10px 14px 20px;
}
html.hub-phone-html .main {
max-width: none;
padding: 10px 14px 20px;
}
html.hub-phone-html .topbar-logout {
display: none;
}
.hub-phone-bar {
display: none;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 8px 0 12px;
padding: 8px 4px 10px;
border-bottom: 1px solid rgba(56, 189, 248, 0.16);
}
.hub-phone-bar-title {
font-size: 1.05rem;
font-weight: 700;
color: #dbe4ff;
white-space: nowrap;
}
.hub-phone-quotes {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
font-variant-numeric: tabular-nums;
}
.hub-phone-quotes .nav-spot-row {
grid-template-columns: 16px minmax(4.2em, auto) minmax(3.4em, auto);
padding: 1px 6px 1px 4px;
font-size: 0.72rem;
background: transparent;
border: none;
}
.hub-phone-tabbar {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
justify-content: space-around;
align-items: stretch;
gap: 4px;
padding: 6px 8px calc(8px + env(safe-area-inset-bottom, 0px));
background: rgba(10, 16, 28, 0.96);
border-top: 1px solid rgba(56, 189, 248, 0.22);
backdrop-filter: blur(10px);
}
.hub-phone-tab {
flex: 1;
appearance: none;
border: 0;
background: transparent;
color: #8aa0b8;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
padding: 10px 4px;
text-align: center;
cursor: pointer;
}
.hub-phone-tab.is-active,
.hub-phone-tab[aria-expanded="true"] {
color: #e8f1ff;
}
.hub-phone-more {
display: none;
}
html.hub-phone-html .hub-phone-more {
pointer-events: none;
}
html.hub-phone-html.hub-phone-more-open .hub-phone-more,
html.hub-phone-html body.hub-phone-more-open .hub-phone-more {
pointer-events: auto;
}
.hub-phone-more-backdrop {
position: fixed;
inset: 0;
z-index: 45;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
visibility: hidden;
transition: opacity 0.18s ease;
}
body.hub-phone-more-open .hub-phone-more-backdrop {
opacity: 1;
visibility: visible;
}
.hub-phone-more-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 46;
max-height: min(78vh, 560px);
overflow: auto;
padding: 10px 16px calc(18px + env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: #101828;
border: 1px solid rgba(56, 189, 248, 0.28);
transform: translateY(110%);
transition: transform 0.2s ease;
}
body.hub-phone-more-open .hub-phone-more-sheet {
transform: translateY(0);
}
.hub-phone-more-handle {
width: 42px;
height: 4px;
margin: 4px auto 10px;
border-radius: 99px;
background: #3a4568;
}
.hub-phone-more-title {
margin: 0 0 4px;
font-size: 1rem;
}
.hub-phone-more-hint {
margin: 0 0 12px;
font-size: 0.78rem;
color: var(--muted);
}
.hub-phone-more-nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.hub-phone-more-nav a {
display: block;
padding: 12px 12px;
border-radius: 10px;
background: #151a2a;
border: 1px solid #304164;
color: #dbe4ff;
}
.hub-phone-more-nav a.is-active {
background: #2a3f6c;
}
.hub-phone-more-close {
display: block;
width: 100%;
margin-top: 14px;
padding: 10px;
border-radius: 10px;
border: 1px solid #304164;
background: transparent;
color: #8aa0b8;
font: inherit;
}
html.hub-phone-html .page-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
html.hub-phone-html .page-head h1 {
font-size: 1.15rem;
margin: 0;
flex: 1;
}
html.hub-phone-html .overview-bj-clock {
display: none;
}
html.hub-phone-html .overview-summary {
display: none !important;
}
html.hub-phone-html #overview-status {
font-size: 0.78rem;
margin: 0 0 10px;
}
.hub-phone-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.hub-phone-card {
background: rgba(16, 24, 40, 0.92);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 14px;
padding: 16px 16px 14px;
}
.hub-phone-card-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-bottom: 12px;
}
.hub-phone-card-head strong {
font-size: 1.02rem;
line-height: 1.3;
}
.hub-phone-card-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.hub-phone-card-pnl {
margin-bottom: 10px;
}
.hub-phone-card-pnl .muted {
display: block;
font-size: 0.72rem;
margin-bottom: 2px;
}
.hub-phone-card-pnl span:not(.muted) {
font-size: 1.35rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.hub-phone-card-kpis {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin-bottom: 12px;
}
.hub-phone-card-kpis .muted {
display: block;
font-size: 0.72rem;
margin-bottom: 2px;
}
.hub-phone-card-pos {
font-size: 0.92rem;
line-height: 1.45;
}
.hub-phone-pos-line + .hub-phone-pos-line {
margin-top: 4px;
}
.hub-phone-card-foot {
margin-top: 8px;
font-size: 0.8rem;
line-height: 1.4;
}
.hub-phone-card-err {
margin: 0 0 8px;
font-size: 0.78rem;
}
.hub-phone-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
html.hub-phone-html .desk {
display: none !important;
}
.hub-phone-desk {
display: none;
}
html.hub-phone-html .hub-phone-desk {
display: block;
}
.hub-phone-desk-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.hub-phone-desk-open {
width: 100%;
text-align: left;
}
html.hub-phone-html .settings-row-2,
html.hub-phone-html .settings-grid {
display: block;
}
html.hub-phone-html .inst-table,
html.hub-phone-html .backup-table,
html.hub-phone-html .stats-table {
font-size: 0.78rem;
}
html.hub-phone-html .table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
html.hub-phone-html body.page-cond-orders .cond-lead {
display: none;
}
html.hub-phone-html body.page-cond-orders .cond-layout {
display: flex;
flex-direction: column;
gap: 14px;
}
html.hub-phone-html body.page-cond-orders .cond-card {
padding: 14px;
border-radius: 14px;
}
html.hub-phone-html body.page-cond-orders .cond-card-head {
margin-bottom: 10px;
}
html.hub-phone-html body.page-cond-orders .cond-create-split {
grid-template-columns: 1fr;
gap: 14px;
}
html.hub-phone-html body.page-cond-orders .cond-param-row,
html.hub-phone-html body.page-cond-orders .cond-exit-row {
grid-template-columns: 1fr;
}
html.hub-phone-html body.page-cond-orders .cond-field-mult input {
width: 100%;
}
html.hub-phone-html body.page-cond-orders .cond-actions {
flex-direction: column;
align-items: stretch;
}
html.hub-phone-html body.page-cond-orders .cond-actions .btn {
width: 100%;
}
.hub-phone-cond-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.hub-phone-cond {
padding: 12px 12px 10px;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.18);
background: rgba(8, 16, 30, 0.72);
transition: border-color 0.15s ease, background 0.15s ease;
}
.hub-phone-cond:hover {
border-color: rgba(56, 189, 248, 0.45);
background: rgba(56, 189, 248, 0.1);
}
.hub-phone-cond-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.hub-phone-cond-head strong {
font-size: 0.95rem;
}
.hub-phone-cond-main {
font-size: 0.98rem;
font-weight: 600;
line-height: 1.4;
}
.hub-phone-cond-meta,
.hub-phone-cond-msg {
margin-top: 4px;
font-size: 0.78rem;
line-height: 1.4;
}
.hub-phone-cond-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
html.hub-phone-html .login-wrap {
padding: 48px 16px 24px;
}
html.hub-phone-html .login-card {
width: 100%;
max-width: none;
}
+275 -19
View File
@@ -207,17 +207,26 @@
function isLiquidityWeak(p) {
if (!p) return false;
if (p.liquidity_ok === false || p.bid_invalid) return true;
// 仅明确残档/流动性不足时标黄;liquidity_ok==null 表示未同步,不标黄
if (p.bid_invalid || p.liquidity_ok === false) return true;
const uncovered = Number(p.uncovered_sheets);
return Number.isFinite(uncovered) && uncovered > 0;
}
function bidLiquidityLine(p) {
if (p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))) {
const noBid = p.bid_px == null && (p.bid_sz == null || p.bid_sz === "");
if (p.bid_invalid) {
const reason = (p.bid_invalid_reason || "无买一/残档").toString();
const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason;
return { text: short, weak: true };
}
if (noBid) {
// 已判定流动性不足 → 标黄;否则视为未同步,显示 —
if (p.liquidity_ok === false) {
return { text: "无买一", weak: true };
}
return { text: "—", weak: false };
}
const px = fmtBidPx(p.bid_px);
const sz = p.bid_sz != null ? String(p.bid_sz) : "—";
const uncovered = Number(p.uncovered_sheets);
@@ -298,6 +307,177 @@
);
}
function isHubPhone() {
return (
document.documentElement.classList.contains("hub-phone-html") ||
!!(document.body && document.body.classList.contains("hub-phone"))
);
}
function openClient(instanceId) {
if (!instanceId) return;
fetch("/api/instances/" + encodeURIComponent(instanceId) + "/open_url")
.then(function (r) {
return r.json();
})
.then(function (d) {
if (d && d.url) {
window.location.href = d.url;
return;
}
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
})
.catch(function () {
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
});
}
function bindOverviewActions(root) {
if (!root) return;
root.querySelectorAll("[data-close-opt]").forEach(function (btn) {
btn.addEventListener("click", function () {
requestClose(btn);
});
});
root.querySelectorAll("[data-open-inst]").forEach(function (btn) {
btn.addEventListener("click", function () {
openClient(btn.getAttribute("data-open-inst"));
});
});
}
function shortOptName(id) {
const s = String(id || "").trim();
if (!s) return "—";
const parts = s.split("-");
if (parts.length >= 5) {
const base = parts[0];
const strike = parts[3];
const ot = String(parts[4] || "").toUpperCase();
const side = ot === "P" ? "Put" : ot === "C" ? "Call" : ot;
return base + " " + side + " " + strike;
}
return s;
}
function formatExitBriefPhone(p) {
if (!p) return "";
if (p.target_monitor_text && String(p.target_monitor_text).trim() && p.target_monitor_text !== "—") {
return String(p.target_monitor_text).trim();
}
const tgt = Number(p.target_index);
if (Number.isFinite(tgt) && tgt > 0) {
const ot = String(p.opt_type || "").toUpperCase();
const side = ot === "P" ? "Put≤" : ot === "C" ? "Call≥" : "目标";
return side + tgt;
}
if (p.profit_exit_enabled) {
const m = Number(p.profit_exit_mult);
if (Number.isFinite(m) && m > 0) {
return (Math.abs(m - Math.round(m)) < 1e-9 ? Math.round(m) : m) + "倍出场";
}
return "利润出场";
}
return "";
}
function phonePosLine(p) {
const sheets = p.sheets != null ? String(p.sheets) : "—";
const lev = Number(p.contract_leverage != null ? p.contract_leverage : p.leverage);
const levTxt = Number.isFinite(lev) && lev > 0 ? " · " + lev.toFixed(1) + "×" : "";
return escapeHtml(shortOptName(p.inst_id) + " · " + sheets + "张" + levTxt);
}
function phonePositionBlock(brief, cond) {
const list = brief || [];
if (!list.length) {
if (cond) {
return (
'<div class="hub-phone-card-pos">' +
escapeHtml(condWaitLine(cond)) +
"</div>"
);
}
return '<div class="hub-phone-card-pos muted">暂无持仓</div>';
}
const shown = list.slice(0, 3).map(function (p) {
return '<div class="hub-phone-pos-line">' + phonePosLine(p) + "</div>";
});
if (list.length > 3) {
shown.push('<div class="muted">还有 ' + (list.length - 3) + " 笔</div>");
}
return '<div class="hub-phone-card-pos">' + shown.join("") + "</div>";
}
function phoneFootLine(brief) {
const list = brief || [];
if (!list.length) return "";
const bits = [];
const exitText = formatExitBriefPhone(list[0]);
if (exitText) bits.push(exitText);
const liq = bidLiquidityLine(list[0]);
if (liq && liq.text) bits.push((liq.weak ? "买一弱 " : "") + liq.text);
if (!bits.length) return "";
const cls = liq && liq.weak ? "hub-phone-card-foot liq-warn" : "hub-phone-card-foot muted";
return '<div class="' + cls + '">' + escapeHtml(bits.join(" · ")) + "</div>";
}
function renderPhoneCard(row) {
const snap = row.snapshot || {};
const inst = snap.instance || {};
const pnl =
snap.pnl && snap.pnl.unrealized_usdt != null
? snap.pnl.unrealized_usdt
: snap.upl_total_usdc;
const brief = snap.positions_brief || snap.positions || [];
const cond = activeCondOrder(snap);
const online = !!row.online;
const m = String(inst.mode || "").toLowerCase();
const modeHtml =
m === "sim" || m === "live"
? '<span class="overview-mode-tag overview-mode-tag--' + m + '">' + escapeHtml(m) + "</span>"
: inst.mode
? '<span class="muted">' + escapeHtml(inst.mode) + "</span>"
: "";
const err = row.error
? '<div class="muted hub-phone-card-err">错误: ' + escapeHtml(row.error) + "</div>"
: "";
return (
'<article class="hub-phone-card">' +
'<div class="hub-phone-card-head">' +
"<strong>" +
escapeHtml(row.name || "") +
"</strong>" +
'<div class="hub-phone-card-tags">' +
(online ? '<span class="status-on">在线</span>' : '<span class="status-off">离线</span>') +
modeHtml +
condTagHtml(cond) +
"</div></div>" +
err +
'<div class="hub-phone-card-pnl">' +
'<span class="muted">浮盈</span>' +
fmtPnlHtml(pnl) +
"</div>" +
'<div class="hub-phone-card-kpis">' +
"<div><span class=\"muted\">权利金</span>" +
premiumCell(snap) +
"</div>" +
"<div><span class=\"muted\">占比</span>" +
premiumRatioCell(snap) +
"</div>" +
"</div>" +
phonePositionBlock(brief, cond) +
phoneFootLine(brief) +
'<div class="hub-phone-card-actions">' +
'<button type="button" class="btn" data-open-inst="' +
escapeHtml(String(row.id || "")) +
'">打开</button>' +
closeActionButtons(brief, row.id, online) +
"</div>" +
"</article>"
);
}
function renderRow(row) {
const snap = row.snapshot || {};
const inst = snap.instance || {};
@@ -401,7 +581,7 @@
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
if (dd.fully_closed) msg += " · 已全平";
alert(msg);
await refresh();
await requestOverviewRefresh();
} else {
alert((dd && dd.msg) || "平仓失败");
btn.disabled = false;
@@ -579,21 +759,38 @@
return best;
}
async function refresh() {
let overviewVersion = 0;
let overviewLoading = false;
let overviewEventSource = null;
let overviewReconnectTimer = null;
async function refresh(opts) {
opts = opts || {};
const silent = !!opts.silent;
const status = document.getElementById("overview-status");
const grid = document.getElementById("overview-grid");
if (!grid) return;
if (status) status.textContent = "刷新中…";
if (overviewLoading) return;
overviewLoading = true;
if (status && !silent) status.textContent = "读取缓存…";
try {
const r = await fetch("/api/overview");
// 只读中控热缓存;后台常驻并行 pull,不在此请求内打实例
const r = await fetch("/api/overview", { credentials: "same-origin" });
const d = await r.json();
if (!d.ok) throw new Error(d.msg || "失败");
if (!d.ok) throw new Error(d.msg || d.error || "失败");
overviewVersion = Number(d.overview_version) || overviewVersion;
const list = sortInstancesByMode(d.instances || []);
renderOverviewSummary(list);
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
if (!list.length) {
grid.innerHTML =
'<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
d.pulling || !d.updated_at
? '<p class="muted">后台首次拉取中…</p>'
: '<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
} else if (isHubPhone()) {
grid.innerHTML =
'<div class="hub-phone-cards">' + list.map(renderPhoneCard).join("") + "</div>";
bindOverviewActions(grid);
} else {
grid.innerHTML =
'<div class="overview-table-wrap"><table class="overview-table">' +
@@ -602,23 +799,24 @@
"</tr></thead><tbody>" +
list.map(renderRow).join("") +
"</tbody></table></div>";
grid.querySelectorAll("[data-close-opt]").forEach(function (btn) {
btn.addEventListener("click", function () {
requestClose(btn);
});
});
bindOverviewActions(grid);
}
if (status) {
const s = summarizeOverview(list);
const pulled = latestPulledAt(list);
const pulled = latestPulledAt(list) || d.updated_at || "";
const sec = Number(d.poll_interval_sec) || 10;
const live = overviewEventSource && overviewEventSource.readyState === 1;
status.textContent =
"已更新 · 共 " +
(live ? "SSE 已连接 · " : "") +
"缓存 " +
list.length +
" 台 · 在线 " +
s.all.online +
" · 离线 " +
s.all.offline +
(pulled ? " · 拉取时间 " + pulled : "");
(pulled ? " · " + pulled : "") +
(d.pulling ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉") +
(d.error ? " · " + d.error : "");
}
} catch (e) {
if (status) status.textContent = e.message || "加载失败";
@@ -628,9 +826,60 @@
box.innerHTML = "";
}
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]);
} finally {
overviewLoading = false;
}
}
function closeOverviewStream() {
if (overviewEventSource) {
overviewEventSource.close();
overviewEventSource = null;
}
if (overviewReconnectTimer) {
clearTimeout(overviewReconnectTimer);
overviewReconnectTimer = null;
}
}
function connectOverviewStream() {
const grid = document.getElementById("overview-grid");
if (!grid || typeof EventSource === "undefined") return;
closeOverviewStream();
overviewEventSource = new EventSource("/api/overview/stream");
overviewEventSource.addEventListener("overview", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.overview_version) || 0;
if (ver && ver !== overviewVersion) {
refresh({ silent: true });
} else if (st.pulling) {
const status = document.getElementById("overview-status");
if (status) status.textContent = "后台并行拉取中…";
}
} catch (_) {}
});
overviewEventSource.onerror = function () {
closeOverviewStream();
const status = document.getElementById("overview-status");
if (status) status.textContent = "SSE 断开,8s 后重连…";
overviewReconnectTimer = setTimeout(function () {
connectOverviewStream();
refresh({ silent: true });
}, 8000);
};
}
async function requestOverviewRefresh() {
try {
await fetch("/api/overview/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
refresh({ silent: true });
}
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
const LUNAR_INFO = [
@@ -821,12 +1070,19 @@
function boot() {
const btn = document.getElementById("btn-refresh");
if (btn) btn.addEventListener("click", refresh);
if (btn) btn.addEventListener("click", function () {
requestOverviewRefresh();
});
tickBeijingClock();
setInterval(tickBeijingClock, 1000);
// 先进缓存秒开,再挂 SSE;不再前端定时现场拉实例
refresh();
setInterval(refresh, 10000);
connectOverviewStream();
}
global.HubOverview = { boot: boot, refresh: refresh };
global.HubOverview = {
boot: boot,
refresh: refresh,
requestRefresh: requestOverviewRefresh,
};
})(window);
+62
View File
@@ -0,0 +1,62 @@
(function (global) {
var MQ = "(max-width: 720px)";
function isMobile() {
try {
return window.matchMedia(MQ).matches;
} catch (e) {
return false;
}
}
function sync() {
var on = isMobile();
document.documentElement.classList.toggle("hub-phone-html", on);
if (document.body) document.body.classList.toggle("hub-phone", on);
if (!on) closeMore();
}
function closeMore() {
if (!document.body) return;
document.body.classList.remove("hub-phone-more-open");
var more = document.getElementById("hub-phone-more");
var btn = document.getElementById("hub-phone-more-btn");
if (more) more.setAttribute("aria-hidden", "true");
if (btn) btn.setAttribute("aria-expanded", "false");
}
function openMore() {
if (!isMobile()) return;
document.body.classList.add("hub-phone-more-open");
var more = document.getElementById("hub-phone-more");
var btn = document.getElementById("hub-phone-more-btn");
if (more) more.setAttribute("aria-hidden", "false");
if (btn) btn.setAttribute("aria-expanded", "true");
}
function boot() {
sync();
var moreBtn = document.getElementById("hub-phone-more-btn");
var backdrop = document.getElementById("hub-phone-more-backdrop");
var closeBtn = document.getElementById("hub-phone-more-close");
if (moreBtn) {
moreBtn.addEventListener("click", function (ev) {
ev.preventDefault();
if (document.body.classList.contains("hub-phone-more-open")) closeMore();
else openMore();
});
}
if (backdrop) backdrop.addEventListener("click", closeMore);
if (closeBtn) closeBtn.addEventListener("click", closeMore);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeMore();
});
var t = null;
window.addEventListener("resize", function () {
clearTimeout(t);
t = setTimeout(sync, 120);
});
}
global.HubPhone = { boot: boot, isMobile: isMobile, sync: sync };
})(window);
+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;
}
function paint(list) {
const root = document.getElementById("nav-spot-tickers");
if (!root) return;
const src = pickQuoteSource(list);
const tickers = src ? src.tickers : [];
function paintRoot(root, tickers, src) {
["ETH", "BTC"].forEach(function (base) {
const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]');
if (!row) return;
@@ -119,6 +115,14 @@
}
}
function paint(list) {
const src = pickQuoteSource(list);
const tickers = src ? src.tickers : [];
document.querySelectorAll("[data-hub-quotes]").forEach(function (root) {
paintRoot(root, tickers, src);
});
}
let timer = null;
let inflight = false;
@@ -137,7 +141,7 @@
}
function boot() {
if (!document.getElementById("nav-spot-tickers")) return;
if (!document.querySelector("[data-hub-quotes]")) return;
if (global.HubOverview) return;
refresh();
if (timer) clearInterval(timer);
Binary file not shown.

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",
"#2dd4bf",
];
const PICKER_KEY = "hub-stats-curve-machines";
let windowKey = "7d";
let curveData = null;
let summaryData = null;
let selectedIds = loadSelectedIds();
let statsVersion = 0;
let statsLoading = false;
let statsEventSource = null;
let statsReconnectTimer = null;
function loadSelectedIds() {
try {
const raw = localStorage.getItem(PICKER_KEY);
if (!raw) return null;
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return new Set(arr.map(String));
} catch (_) {}
return null;
}
function saveSelectedIds() {
try {
localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || [])));
} catch (_) {}
}
function escapeHtml(s) {
return String(s)
@@ -51,6 +73,13 @@
return (n / 3600).toFixed(1) + "h";
}
function modeLabel(mode) {
const m = String(mode || "").toLowerCase();
if (m === "sim") return "sim";
if (m === "live") return "live";
return mode ? String(mode) : "—";
}
function setStatus(msg, isErr) {
const el = document.getElementById("stats-status");
if (!el) return;
@@ -87,12 +116,26 @@
.join("");
}
function sortMachines(machines) {
return (machines || []).slice().sort(function (a, b) {
function rank(m) {
const mode = String((m && m.mode) || "").toLowerCase();
if (mode === "sim") return 0;
if (mode === "live") return 1;
return 2;
}
const d = rank(a) - rank(b);
if (d !== 0) return d;
return String((a && a.name) || "").localeCompare(String((b && b.name) || ""), "zh");
});
}
function renderTable(machines) {
const body = document.getElementById("stats-table-body");
if (!body) return;
const list = machines || [];
const list = sortMachines(machines);
if (!list.length) {
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>';
body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
return;
}
body.innerHTML = list
@@ -103,6 +146,8 @@
const err = m.error
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
: "";
const mode = String(m.mode || "").toLowerCase();
const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
return (
"<tr>" +
"<td><strong>" +
@@ -113,6 +158,11 @@
"<td>" +
status +
"</td>" +
'<td><span class="stats-mode-badge ' +
modeCls +
'">' +
escapeHtml(modeLabel(m.mode)) +
"</span></td>" +
"<td>" +
escapeHtml(fmtNum(m.cash_usdt)) +
"</td>" +
@@ -135,6 +185,13 @@
'">' +
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
"</td>" +
'<td class="' +
pnlClass(m.return_rate_pct) +
'">' +
escapeHtml(
m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
) +
"</td>" +
"<td>" +
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
"</td>" +
@@ -147,6 +204,61 @@
.join("");
}
function ensureSelectedIds(series) {
const ids = (series || []).map(function (s) {
return String(s.id);
});
if (selectedIds == null) {
selectedIds = new Set(ids);
saveSelectedIds();
return;
}
// 新机器默认勾选
ids.forEach(function (id) {
if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) {
selectedIds.add(id);
try {
localStorage.setItem(PICKER_KEY + ":seen:" + id, "1");
} catch (_) {}
}
});
saveSelectedIds();
}
function renderMachinePicker(data) {
const box = document.getElementById("stats-machine-picker");
if (!box) return;
const series = (data && data.series) || [];
ensureSelectedIds(series);
if (!series.length) {
box.innerHTML = '<span class="muted">暂无可选机器</span>';
return;
}
box.innerHTML =
'<button type="button" class="stats-pick-all" data-act="all">全选</button>' +
'<button type="button" class="stats-pick-all" data-act="none">全不选</button>' +
series
.map(function (s, i) {
const id = String(s.id);
const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
const color = COLORS[i % COLORS.length];
return (
'<label class="stats-machine-chip">' +
'<input type="checkbox" data-machine-id="' +
escapeHtml(id) +
'"' +
checked +
">" +
'<i style="background:' +
color +
'"></i>' +
escapeHtml(s.name || id) +
"</label>"
);
})
.join("");
}
function drawCurve(data) {
const canvas = document.getElementById("stats-curve-canvas");
const empty = document.getElementById("stats-curve-empty");
@@ -155,25 +267,28 @@
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
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.height = Math.floor(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked;
const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
const lines = [];
if (data && data.total && data.total.length) {
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 });
if (showTotal && data && data.total && data.total.length) {
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) {
const id = String(s.id);
if (selectedIds && !selectedIds.has(id)) return;
if (!s.points || !s.points.length) return;
lines.push({
name: s.name || String(s.id),
name: s.name || id,
color: COLORS[i % COLORS.length],
points: s.points,
width: 1.4,
width: 1.6,
});
});
}
@@ -230,7 +345,7 @@
maxV = 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 h = cssH - pad.t - pad.b;
@@ -243,13 +358,13 @@
ctx.strokeStyle = "rgba(56,189,248,0.15)";
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad.t + (h * i) / 4;
for (let i = 0; i <= 5; i++) {
const y = pad.t + (h * i) / 5;
ctx.beginPath();
ctx.moveTo(pad.l, y);
ctx.lineTo(pad.l + w, y);
ctx.stroke();
const val = maxV - ((maxV - minV) * i) / 4;
const val = maxV - ((maxV - minV) * i) / 5;
ctx.fillStyle = "#8aa0b8";
ctx.font = "11px IBM Plex Sans, sans-serif";
ctx.fillText(val.toFixed(0), 8, y + 4);
@@ -271,36 +386,93 @@
});
}
async function loadAll() {
setStatus("加载中…");
async function loadAll(opts) {
opts = opts || {};
if (statsLoading) return;
statsLoading = true;
if (!opts.silent) setStatus("读取缓存…");
try {
const [sumRes, curveRes] = await Promise.all([
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)),
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)),
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
]);
const sum = await sumRes.json();
const curve = await curveRes.json();
if (!sum.ok) 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 || "曲线失败");
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
summaryData = sum;
curveData = curve;
renderKpis(sum.totals);
renderTable(sum.machines);
renderKpis(sum.totals || {});
renderTable(sum.machines || []);
renderMachinePicker(curve);
drawCurve(curve);
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
const live = statsEventSource && statsEventSource.readyState === 1;
setStatus(
(live ? "SSE 已连接 · " : "") +
"窗口 " +
windowKey +
" · 在线 " +
(sum.totals.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) {
setStatus(String(e.message || e), true);
} finally {
statsLoading = false;
}
}
function closeStatsStream() {
if (statsEventSource) {
statsEventSource.close();
statsEventSource = null;
}
if (statsReconnectTimer) {
clearTimeout(statsReconnectTimer);
statsReconnectTimer = null;
}
}
function connectStatsStream() {
if (typeof EventSource === "undefined") return;
closeStatsStream();
statsEventSource = new EventSource("/api/stats/stream");
statsEventSource.addEventListener("stats", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.stats_version) || 0;
if (ver && ver !== statsVersion) {
loadAll({ silent: true });
} else if (st.refreshing) {
setStatus("后台刷新统计中…");
}
} catch (_) {}
});
statsEventSource.onerror = function () {
closeStatsStream();
setStatus("SSE 断开,8s 后重连…");
statsReconnectTimer = setTimeout(function () {
connectStatsStream();
loadAll({ silent: true });
}, 8000);
};
}
function bind() {
const tabs = document.getElementById("stats-window-tabs");
if (tabs) {
@@ -314,12 +486,38 @@
loadAll();
});
}
const toggle = document.getElementById("stats-show-machines");
if (toggle) {
toggle.addEventListener("change", function () {
const showTotal = document.getElementById("stats-show-total");
if (showTotal) {
showTotal.addEventListener("change", function () {
if (curveData) drawCurve(curveData);
});
}
const picker = document.getElementById("stats-machine-picker");
if (picker) {
picker.addEventListener("change", function (ev) {
const input = ev.target.closest("input[data-machine-id]");
if (!input) return;
const id = String(input.getAttribute("data-machine-id") || "");
if (!selectedIds) selectedIds = new Set();
if (input.checked) selectedIds.add(id);
else selectedIds.delete(id);
saveSelectedIds();
if (curveData) drawCurve(curveData);
});
picker.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-act]");
if (!btn || !curveData) return;
const act = btn.getAttribute("data-act");
const ids = ((curveData && curveData.series) || []).map(function (s) {
return String(s.id);
});
if (act === "all") selectedIds = new Set(ids);
else if (act === "none") selectedIds = new Set();
saveSelectedIds();
renderMachinePicker(curveData);
drawCurve(curveData);
});
}
window.addEventListener("resize", function () {
if (curveData) drawCurve(curveData);
});
@@ -327,4 +525,5 @@
bind();
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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{% block title %}加密货币|OKX 期权中控{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=36">
<meta name="theme-color" content="#070b14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="期权中控">
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=54">
<script>
(function () {
try {
if (window.matchMedia("(max-width: 720px)").matches) {
document.documentElement.classList.add("hub-phone-html");
}
} catch (e) {}
})();
</script>
</head>
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path.startswith('/instances') %}page-instances{% elif request.path.startswith('/settings') %}page-settings{% elif request.path.startswith('/cond_orders') %}page-cond-orders{% elif request.path == '/' %}page-dashboard{% endif %}">
{% if session.get('logged_in') %}
<a href="/logout" class="topbar-logout">退出</a>
<a href="/logout" class="topbar-logout hub-desk-chrome">退出</a>
{% endif %}
<main class="main">
{% if session.get('logged_in') %}
<a href="/" class="topbar-brand">加密货币|OKX 期权中控</a>
<div class="topbar-row">
<header class="hub-phone-bar hub-phone-chrome">
<div class="hub-phone-bar-title">期权中控</div>
<div class="hub-phone-quotes" id="nav-spot-tickers-phone" data-hub-quotes="1" aria-label="BTC ETH 报价">
<div class="nav-spot-row" data-base="ETH">
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
<span class="nav-spot-last" data-field="last">—</span>
<span class="nav-spot-pct" data-field="change_pct">—</span>
</div>
<div class="nav-spot-row" data-base="BTC">
<span class="nav-spot-icon nav-spot-icon--btc" aria-hidden="true">₿</span>
<span class="nav-spot-last" data-field="last">—</span>
<span class="nav-spot-pct" data-field="change_pct">—</span>
</div>
</div>
</header>
<a href="/" class="topbar-brand hub-desk-chrome">加密货币|OKX 期权中控</a>
<div class="topbar-row hub-desk-chrome">
<nav class="topbar-nav">
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
<a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a>
@@ -22,7 +53,7 @@
<a href="/stats" class="{% if request.path.startswith('/stats') %}active{% endif %}">数据统计</a>
<a href="/settings" class="{% if request.path.startswith('/settings') %}active{% endif %}">系统设置</a>
</nav>
<div class="top-nav-spot-tickers" id="nav-spot-tickers" aria-label="BTC ETH 报价" title="来自在线客户端 · 相对北京 08:00">
<div class="top-nav-spot-tickers" id="nav-spot-tickers" data-hub-quotes="1" aria-label="BTC ETH 报价" title="来自在线客户端 · 相对北京 08:00">
<div class="nav-spot-row" data-base="ETH">
<span class="nav-spot-ribbon" aria-hidden="true"></span>
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
@@ -47,12 +78,38 @@
{% endwith %}
{% block body %}{% endblock %}
</main>
{% if session.get('logged_in') %}
<nav id="hub-phone-tabbar" class="hub-phone-tabbar hub-phone-chrome" aria-label="手机导航">
<a href="/" class="hub-phone-tab{% if request.path == '/' %} is-active{% endif %}" data-hub-tab="/">总览</a>
<a href="/cond_orders" class="hub-phone-tab{% if request.path.startswith('/cond_orders') %} is-active{% endif %}" data-hub-tab="/cond_orders">条件单</a>
<button type="button" class="hub-phone-tab" id="hub-phone-more-btn" aria-haspopup="dialog" aria-expanded="false">更多</button>
</nav>
<div id="hub-phone-more" class="hub-phone-more hub-phone-chrome" aria-hidden="true">
<div class="hub-phone-more-backdrop" id="hub-phone-more-backdrop"></div>
<div class="hub-phone-more-sheet" role="dialog" aria-modal="true">
<div class="hub-phone-more-handle" aria-hidden="true"></div>
<h2 class="hub-phone-more-title">更多</h2>
<p class="hub-phone-more-hint">次要页面 · 完整操作请用电脑</p>
<nav class="hub-phone-more-nav">
<a href="/instances"{% if request.path.startswith('/instances') %} class="is-active"{% endif %}>实例</a>
<a href="/stats"{% if request.path.startswith('/stats') %} class="is-active"{% endif %}>数据统计</a>
<a href="/settings"{% if request.path.startswith('/settings') %} class="is-active"{% endif %}>系统设置</a>
<a href="/logout">退出登录</a>
</nav>
<button type="button" class="hub-phone-more-close" id="hub-phone-more-close">关闭</button>
</div>
</div>
{% endif %}
{% if session.get('logged_in') and not request.path.startswith('/desk') %}
<script src="{{ url_for('static', filename='hub_quotes.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='hub_quotes.js') }}?v=2"></script>
{% endif %}
{% block scripts %}{% endblock %}
{% if session.get('logged_in') and not request.path.startswith('/desk') %}
<script>if (window.HubQuotes) HubQuotes.boot();</script>
{% endif %}
<script src="{{ url_for('static', filename='hub_pwa.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='hub_phone.js') }}?v=1"></script>
<script>if (window.HubPwa) HubPwa.boot();</script>
<script>if (window.HubPhone) HubPhone.boot();</script>
</body>
</html>
+74 -36
View File
@@ -3,83 +3,121 @@
{% block body %}
<section class="page-head cond-head">
<div>
<h1>左侧条件开仓</h1>
<p class="muted cond-lead">中控只下发;盯盘、买币、开平仓都在客户端。有仓或已占用的机器不可选。</p>
<h1>条件单</h1>
<p class="muted cond-lead">中控下发到实例;列表由中控后台缓存并推送到浏览器。盯盘与开平仓在客户端。有仓或已占用的机器不可选。</p>
<p class="muted" id="cond-status">读取缓存…</p>
</div>
<div class="cond-head-actions">
<button type="button" class="btn" id="btn-cond-refresh">刷新</button>
<label for="cond-add-toggle" class="settings-primary inst-add-trigger">新建条件单</label>
</div>
</section>
<input type="checkbox" id="cond-add-toggle" class="cond-add-check">
<div class="cond-layout">
<section class="panel cond-card">
<section class="panel cond-card cond-add-panel">
<div class="cond-card-head">
<h2>新建条件单</h2>
<span class="cond-chip">左侧单</span>
</div>
<div class="cond-section">
<div class="cond-section-title">入场</div>
<div class="cond-grid">
<label>标的
<select id="cf-underlying">
<option value="ETH">ETH</option>
<div class="cond-create-split">
<div class="cond-create-left">
<div class="cond-col-title">策略</div>
<div class="cond-block">
<div class="cond-uly-side-row">
<div class="cond-uly-side-item">
<span class="cond-mini-label">标的</span>
<div class="cond-seg" id="cf-uly-seg" role="group" aria-label="标的">
<button type="button" class="cond-seg-btn on" data-uly="ETH">ETH</button>
<button type="button" class="cond-seg-btn" data-uly="BTC">BTC</button>
</div>
<select id="cf-underlying" class="cond-native-select" hidden>
<option value="ETH" selected>ETH</option>
<option value="BTC">BTC</option>
</select>
</label>
<label>方向
<select id="cf-opt-type">
<option value="P">看跌 Put</option>
</div>
<div class="cond-uly-side-item">
<span class="cond-mini-label">方向</span>
<div class="cond-seg" id="cf-side-seg" role="group" aria-label="方向">
<button type="button" class="cond-seg-btn on" data-side="P">看跌 Put</button>
<button type="button" class="cond-seg-btn" data-side="C">看涨 Call</button>
</div>
<select id="cf-opt-type" class="cond-native-select" hidden>
<option value="P" selected>看跌 Put</option>
<option value="C">看涨 Call</option>
</select>
</label>
<label>触发价
<input id="cf-trigger-px" type="number" step="any" placeholder="如 3000" required>
</label>
<label>偏离 %
<input id="cf-deviation" type="number" step="0.1" value="1" placeholder="1" required>
</label>
<label>最短到期(小时)
<input id="cf-expiry-h" type="number" step="1" value="24" placeholder="24" required>
</label>
<div class="cond-trigger-hint" id="cf-trigger-hint"></div>
</div>
</div>
</div>
<div class="cond-section">
<div class="cond-section-title">出场</div>
<div class="cond-grid">
<label>目标指数(可选)
<input id="cf-target" type="number" step="any" placeholder="如 2800">
<div class="cond-block">
<div class="cond-param-row">
<label class="cond-field">触发价
<input id="cf-trigger-px" type="number" step="any" placeholder="如 3000" required>
</label>
<label>翻倍倍数
<input id="cf-pe-mult" type="number" step="0.1" value="1" placeholder="1">
<label class="cond-field">偏离 %
<input id="cf-deviation" type="number" step="0.1" value="1" placeholder="1" required>
</label>
<label class="cond-field">最短到期 h
<input id="cf-expiry-h" type="number" step="1" value="24" placeholder="24" required>
</label>
</div>
<div class="cond-trigger-hint is-put" id="cf-trigger-hint"></div>
</div>
<div class="cond-block">
<div class="cond-col-title">出场</div>
<div class="cond-exit-row">
<label class="cond-field">目标指数(可选)
<input id="cf-target" type="number" step="any" placeholder="如 2800">
</label>
<label class="cond-switch">
<input id="cf-pe" type="checkbox" checked>
<span>翻倍出场</span>
</label>
<label class="cond-field cond-field-mult">倍数
<input id="cf-pe-mult" type="number" step="0.1" value="1" placeholder="1">
</label>
</div>
</div>
</div>
<div class="cond-section">
<div class="cond-section-title">客户端</div>
<div class="cond-create-right">
<div class="cond-right-head">
<div class="cond-col-title">下发到客户端</div>
<div class="muted cond-right-meta" id="cf-inst-meta">按标的过滤</div>
</div>
<p class="muted cond-right-lead">按标的白名单过滤;余额旁可本页划转。</p>
<div id="cf-instances"></div>
</div>
</div>
<div class="cond-actions">
<button type="button" class="btn" id="btn-cond-create">下发条件单</button>
<label for="cond-add-toggle" class="btn btn-secondary">取消</label>
<span class="muted" id="cf-status"></span>
</div>
</section>
<section class="panel cond-card">
<div class="cond-card-head">
<h2>条件单列表</h2>
<h2>进行中</h2>
</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>
</div>
{% endblock %}
{% 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>
{% endblock %}
+1 -1
View File
@@ -15,6 +15,6 @@
<div id="overview-grid"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='hub.js') }}?v=26"></script>
<script src="{{ url_for('static', filename='hub.js') }}?v=29"></script>
<script>HubOverview.boot();</script>
{% endblock %}
+35
View File
@@ -37,9 +37,44 @@
{% endif %}
</div>
</section>
<section class="hub-phone-desk hub-phone-chrome">
<p class="hub-phone-desk-hint muted">点开后进入该客户端(手机页),不经过中控工作台嵌页。</p>
<ul class="hub-phone-desk-list">
{% for inst in instances %}
<li>
<button type="button" class="btn hub-phone-desk-open" data-open-id="{{ inst.id }}">{{ inst.name }}</button>
</li>
{% else %}
<li class="muted">无启用实例</li>
{% endfor %}
</ul>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var phone = document.documentElement.classList.contains("hub-phone-html");
if (!phone) return;
var blank = document.getElementById("desk-open-blank");
if (blank && blank.getAttribute("href")) {
window.location.replace(blank.getAttribute("href"));
return;
}
document.querySelectorAll(".hub-phone-desk-open[data-open-id]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-open-id");
if (!id) return;
fetch("/api/instances/" + encodeURIComponent(id) + "/open_url")
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.url) window.location.href = d.url;
})
.catch(function () {});
});
});
})();
</script>
<script>
(function () {
var KEY = "hub_desk_side_collapsed";
var root = document.getElementById("desk-root");
+2 -2
View File
@@ -103,7 +103,7 @@
<div class="backup-block">
<div class="backup-block-head">
<h3>中控备份</h3>
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code>。换机时先上传家里下载的包,再点恢复。</p>
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code>、<code>hub.db</code>、当前登录用户名和密码。换机时先上传家里下载的包,再点恢复。</p>
<div class="settings-actions">
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
@@ -188,7 +188,7 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=3"></script>
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=4"></script>
<script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
<script>
HubSettingsBackup.boot();
+26 -23
View File
@@ -5,7 +5,7 @@
<div>
<p class="settings-eyebrow">Analytics</p>
<h1>数据统计</h1>
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p>
<p class="muted settings-lead">多机 KPI 对比与累计已实现盈亏曲线。划转/提现不影响曲线。后台常驻刷新,打开即读缓存。</p>
</div>
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
<button type="button" class="stats-win-btn" data-window="today">今日</button>
@@ -15,35 +15,17 @@
</div>
</section>
<p class="muted" id="stats-status">加载中…</p>
<p class="muted" id="stats-status">读取缓存…</p>
<section class="panel stats-kpi-panel" id="stats-kpi-panel">
<div class="stats-kpi-grid" id="stats-kpi-grid"></div>
</section>
<section class="panel stats-curve-panel">
<div class="stats-card-head-inline">
<div>
<h2>余额曲线</h2>
<p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。采样开启后往后累计。</p>
</div>
<label class="stats-curve-toggle">
<input type="checkbox" id="stats-show-machines" checked>
叠加各机
</label>
</div>
<div class="stats-chart-wrap">
<canvas id="stats-curve-canvas" width="960" height="320"></canvas>
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div>
</div>
<div class="stats-legend" id="stats-curve-legend"></div>
</section>
<section class="panel stats-table-panel">
<div class="stats-card-head-inline">
<div>
<h2>机器对比</h2>
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p>
<p class="muted">实时资金状态 + 当前窗口交易 KPI。排序:模拟在上、实盘在下。收益率 = 窗口已结算盈亏 ÷ 初始资金。</p>
</div>
</div>
<div class="overview-table-wrap">
@@ -52,23 +34,44 @@
<tr>
<th>机器</th>
<th>状态</th>
<th>模式</th>
<th>余额</th>
<th>权利金</th>
<th>浮盈</th>
<th>笔数</th>
<th>胜率</th>
<th>已实现</th>
<th>收益率</th>
<th>均持仓</th>
<th></th>
</tr>
</thead>
<tbody id="stats-table-body">
<tr><td colspan="10" class="muted">加载中…</td></tr>
<tr><td colspan="12" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</section>
<section class="panel stats-curve-panel stats-curve-panel--large">
<div class="stats-card-head-inline">
<div>
<h2>已实现盈亏曲线</h2>
<p class="muted">主指标为各机累计已实现盈亏(期权平仓真盈亏U累加)。可勾选机器叠加对比。</p>
</div>
<label class="stats-curve-toggle">
<input type="checkbox" id="stats-show-total" checked>
显示合计
</label>
</div>
<div class="stats-machine-picker" id="stats-machine-picker" aria-label="选择机器"></div>
<div class="stats-chart-wrap stats-chart-wrap--large">
<canvas id="stats-curve-canvas" width="960" height="480"></canvas>
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无已实现盈亏数据;实例更新并有平仓记录后显示。</div>
</div>
<div class="stats-legend" id="stats-curve-legend"></div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='stats.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='stats.js') }}?v=5"></script>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
"""条件单候选:带出交易户资金字段."""
from __future__ import annotations
import unittest
class TestCondCandidatesFunds(unittest.TestCase):
def test_candidates_include_trading_usdt(self):
from lib.cond_orders_cache_lib import _candidates_from_snaps
rows = [
{
"id": 1,
"name": "okx-a",
"online": True,
"error": "",
"snapshot": {
"positions_brief": [],
"cond_order_locked": False,
"instance": {"mode": "live"},
"funds": {"trading_usdt": 123.456, "funding_usdt": 50.0},
"transfer_blocked": False,
},
},
{
"id": 2,
"name": "okx-b",
"online": False,
"error": "timeout",
"snapshot": {
"positions_brief": [{"inst_id": "x"}],
"cond_order_locked": False,
"instance": {"mode": "sim"},
"funds": {},
"transfer_blocked": True,
"transfer_block_msg": "期权持仓中禁止划转",
},
},
]
out = _candidates_from_snaps(rows)
self.assertEqual(len(out), 2)
self.assertTrue(out[0]["selectable"])
self.assertAlmostEqual(out[0]["trading_usdt"], 123.456, places=3)
self.assertAlmostEqual(out[0]["funding_usdt"], 50.0, places=3)
self.assertFalse(out[0]["transfer_blocked"])
self.assertFalse(out[1]["selectable"])
self.assertIsNone(out[1]["trading_usdt"])
self.assertTrue(out[1]["transfer_blocked"])
self.assertEqual(out[0]["allowed_underlys"], ["ETH", "BTC"])
def test_candidates_carry_allowed_underlys(self):
from lib.cond_orders_cache_lib import _candidates_from_snaps
rows = [
{
"id": 3,
"name": "eth-only",
"online": True,
"error": "",
"snapshot": {
"positions_brief": [],
"cond_order_locked": False,
"instance": {"mode": "sim", "underly": "ETH", "allowed_underlys": ["ETH"]},
"options_allowed_underlys": ["ETH"],
"options_underly": "ETH",
"funds": {"trading_usdt": 1.0, "funding_usdt": 2.0},
},
}
]
out = _candidates_from_snaps(rows)
self.assertEqual(out[0]["allowed_underlys"], ["ETH"])
self.assertEqual(out[0]["default_underly"], "ETH")
def test_open_url_tab_appended(self):
from lib.sso_lib import build_embed_open_url
url = build_embed_open_url(
"https://okx.example.com",
bridge_token="tok",
embed_path="/embed",
tab="options",
)
self.assertIn("hub_sso?", url)
self.assertIn("next=", url)
# next 已 urlencode,解码后应含 tab=options
from urllib.parse import parse_qs, unquote, urlparse
q = parse_qs(urlparse(url).query)
next_path = unquote(q.get("next", [""])[0])
self.assertIn("tab=options", next_path)
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
"""中控统计曲线:各机累计已实现盈亏叠加合计."""
from __future__ import annotations
import unittest
from unittest.mock import patch
class TestStatsRealizedPnlCurve(unittest.TestCase):
def test_build_stats_curve_sums_machines(self):
from lib.stats_agg_lib import build_stats_curve
t0 = 1_700_000_040_000 # 已对齐到分钟桶
t1 = t0 + 60_000
t2 = t0 + 120_000
fake_rows = [
{
"id": "a",
"name": "A",
"online": True,
"error": "",
"points": [{"t": t0, "v": 10.0}, {"t": t2, "v": 15.0}],
"trade_count": 2,
"latest": 15.0,
"start_ms": t0,
"end_ms": t2,
},
{
"id": "b",
"name": "B",
"online": True,
"error": "",
"points": [{"t": t1, "v": 5.0}],
"trade_count": 1,
"latest": 5.0,
"start_ms": t1,
"end_ms": t2,
},
]
with patch("lib.stats_agg_lib.pull_all_pnl_curves", return_value=fake_rows):
out = build_stats_curve("all")
self.assertTrue(out["ok"])
self.assertEqual(out["metric"], "realized_pnl")
self.assertEqual(len(out["series"]), 2)
totals = {p["t"]: p["v"] for p in out["total"]}
# t0: only A=10; t1: A=10 + B=5; t2: A=15 + B=5
self.assertAlmostEqual(totals[t0], 10.0)
self.assertAlmostEqual(totals[t1], 15.0)
self.assertAlmostEqual(totals[t2], 20.0)
if __name__ == "__main__":
unittest.main()