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