From 77c7bbbb13c25b2d265a6a971f4fac9edb5b07c6 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 10 Jun 2026 16:50:47 +0800 Subject: [PATCH] feat: add hub fund overview tab with 180-day equity curves Add /funds page for total and per-account balance (funding+trading), drawdown, and daily snapshots from monitor board aggregation. Co-authored-by: Cursor --- hub_fund_history_lib.py | 384 ++++++++++++++++++++++ manual_trading_hub/.env.example | 2 + manual_trading_hub/README.md | 5 +- manual_trading_hub/hub.py | 25 +- manual_trading_hub/hub_ai/config.py | 2 +- manual_trading_hub/hub_ai/context.py | 9 +- manual_trading_hub/hub_ai/fund_history.py | 117 +------ manual_trading_hub/static/app.css | 129 ++++++++ manual_trading_hub/static/app.js | 7 + manual_trading_hub/static/funds.js | 281 ++++++++++++++++ manual_trading_hub/static/index.html | 35 +- manual_trading_hub/使用说明.md | 17 +- manual_trading_hub/资金概况说明.md | 89 +++++ tests/test_hub_fund_history_lib.py | 79 +++++ 14 files changed, 1069 insertions(+), 112 deletions(-) create mode 100644 hub_fund_history_lib.py create mode 100644 manual_trading_hub/static/funds.js create mode 100644 manual_trading_hub/资金概况说明.md create mode 100644 tests/test_hub_fund_history_lib.py diff --git a/hub_fund_history_lib.py b/hub_fund_history_lib.py new file mode 100644 index 0000000..e0804fc --- /dev/null +++ b/hub_fund_history_lib.py @@ -0,0 +1,384 @@ +"""中控资金概况:分户日快照(180 交易日)、总资金曲线与回撤。""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional + +from hub_trades_lib import current_trading_day + +HUB_DIR = Path(__file__).resolve().parent / "manual_trading_hub" +FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json" +LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json" + +try: + FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180")) +except ValueError: + FUND_HISTORY_DAYS = 180 + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _safe_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + """资金户 + 交易户;任一侧缺失则不计入(返回 None)。""" + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def compute_drawdown(values: list[float]) -> dict[str, Any]: + """基于资金权益序列计算峰值回撤(U 与 %)。""" + peak = 0.0 + max_dd_u = 0.0 + peak_at_end = 0.0 + for v in values: + if not isinstance(v, (int, float)): + continue + fv = float(v) + if fv > peak: + peak = fv + dd = peak - fv + if dd > max_dd_u: + max_dd_u = dd + peak_at_end = peak + max_dd_u = round(max_dd_u, 4) + peak_at_end = round(peak_at_end, 4) + max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None + return { + "peak_usdt": peak_at_end, + "max_drawdown_u": max_dd_u, + "max_drawdown_pct": max_dd_pct, + } + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _prune_days(days: dict, *, keep_days: int, anchor_day: str) -> dict: + try: + anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d") + except ValueError: + anchor = datetime.now() + cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d") + return {k: v for k, v in (days or {}).items() if str(k) >= cutoff} + + +def _migrate_legacy_store(days: dict) -> dict: + if not LEGACY_FUND_HISTORY_PATH.is_file(): + return days + try: + loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8")) + legacy_days = loaded.get("days") if isinstance(loaded, dict) else {} + if not isinstance(legacy_days, dict): + return days + merged = dict(days) + for day, block in legacy_days.items(): + if day in merged: + continue + if isinstance(block, dict) and block.get("accounts"): + merged[day] = block + return merged + except Exception: + return days + + +def _load_store() -> dict: + if not FUND_HISTORY_PATH.is_file(): + store = {"version": 1, "days": _migrate_legacy_store({})} + if store["days"]: + _atomic_write(FUND_HISTORY_PATH, store) + return store + try: + loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + loaded.setdefault("version", 1) + days = dict(loaded.get("days") or {}) + loaded["days"] = _migrate_legacy_store(days) + return loaded + except Exception: + pass + return {"version": 1, "days": {}} + + +def record_fund_snapshot( + trading_day: str, + accounts: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """写入当日各户资金账户/交易账户余额,并裁剪历史。""" + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + store = _load_store() + days = dict(store.get("days") or {}) + row_accounts: dict[str, dict] = {} + for ac in accounts or []: + key = str(ac.get("key") or ac.get("id") or "").strip() + if not key: + continue + if not ac.get("monitored"): + continue + fu = _safe_float(ac.get("funding_usdt")) + tu = _safe_float(ac.get("trading_usdt")) + total = account_total_usdt(fu, tu) + if total is None: + continue + row_accounts[key] = { + "name": ac.get("name"), + "funding_usdt": fu, + "trading_usdt": tu, + "total_usdt": total, + "recorded_at": _now_str(), + } + if row_accounts: + days[day] = {"accounts": row_accounts, "updated_at": _now_str()} + days = _prune_days(days, keep_days=keep_days, anchor_day=day) + _atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days}) + return days + + +def record_fund_snapshot_from_board( + rows: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """监控板行写入当日快照(仅 account_ok 且资金/交易户齐全)。""" + day = current_trading_day(reset_hour=reset_hour) + accounts = [] + for row in rows or []: + if not isinstance(row, dict): + continue + if not row.get("account_ok"): + continue + accounts.append( + { + "key": row.get("key") or row.get("id"), + "name": row.get("name"), + "funding_usdt": row.get("funding_usdt"), + "trading_usdt": row.get("trading_usdt"), + "monitored": True, + } + ) + return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour) + + +def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]: + store = _load_store() + return _prune_days( + dict(store.get("days") or {}), + keep_days=keep_days, + anchor_day=anchor_day, + ) + + +def _exchange_monitored(ex: dict) -> bool: + return bool(ex.get("enabled")) and not bool(ex.get("env_disabled")) + + +def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]: + key = str(ex.get("key") or "").strip() + if not key: + return None + return rows_by_key.get(key) + + +def _series_from_history( + history: dict[str, dict], + account_keys: list[str], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + total = 0.0 + n = 0 + for key in account_keys: + ac = ac_map.get(key) or {} + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + total += t + n += 1 + if n > 0: + out.append({"day": day, "total_usdt": round(total, 4)}) + return out + + +def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + ac = (history.get(day) or {}).get("accounts", {}).get(key) or {} + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + out.append( + { + "day": day, + "total_usdt": t, + "funding_usdt": _safe_float(ac.get("funding_usdt")), + "trading_usdt": _safe_float(ac.get("trading_usdt")), + } + ) + return out + + +def build_fund_overview( + exchanges: list[dict], + *, + board_rows: Optional[list[dict]] = None, + trading_day: Optional[str] = None, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, + updated_at: Optional[str] = None, +) -> dict[str, Any]: + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + history = get_fund_history(anchor_day=day, keep_days=keep_days) + rows_by_key: dict[str, dict] = {} + for row in board_rows or []: + if isinstance(row, dict): + k = str(row.get("key") or "").strip() + if k: + rows_by_key[k] = row + + monitored_keys: list[str] = [] + accounts_out: list[dict[str, Any]] = [] + live_total = 0.0 + live_known = 0 + + for ex in exchanges or []: + key = str(ex.get("key") or "").strip() + monitored = _exchange_monitored(ex) + row = _live_row_for_exchange(ex, rows_by_key) if monitored else None + fu = tu = total = None + data_ok = False + if monitored and row and row.get("account_ok"): + fu = _safe_float(row.get("funding_usdt")) + tu = _safe_float(row.get("trading_usdt")) + total = account_total_usdt(fu, tu) + data_ok = total is not None + if data_ok: + live_total += total + live_known += 1 + + series = _account_series(history, key) if monitored and key else [] + dd = compute_drawdown([p["total_usdt"] for p in series]) if series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + day_delta = None + if series: + if len(series) >= 2: + day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4) + elif data_ok and total is not None: + day_delta = round(total - series[-1]["total_usdt"], 4) + + accounts_out.append( + { + "id": ex.get("id"), + "key": key, + "name": ex.get("name") or key, + "monitored": monitored, + "data_ok": data_ok, + "funding_usdt": fu, + "trading_usdt": tu, + "total_usdt": total, + "series": series, + "drawdown": dd, + "day_delta_usdt": day_delta, + } + ) + if monitored and key: + monitored_keys.append(key) + + total_series = _series_from_history(history, monitored_keys) + if live_known > 0: + last_day = total_series[-1]["day"] if total_series else None + live_point = round(live_total, 4) + if last_day == day and total_series: + total_series[-1]["total_usdt"] = live_point + total_series[-1]["live"] = True + else: + total_series.append({"day": day, "total_usdt": live_point, "live": True}) + + total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + total_day_delta = None + if total_series: + if len(total_series) >= 2: + total_day_delta = round( + total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4 + ) + + return { + "ok": True, + "trading_day": day, + "reset_hour": reset_hour, + "keep_days": keep_days, + "updated_at": updated_at, + "totals": { + "monitored_count": len(monitored_keys), + "live_known_count": live_known, + "total_usdt": round(live_total, 4) if live_known > 0 else None, + "day_delta_usdt": total_day_delta, + "series": total_series, + "drawdown": total_dd, + }, + "accounts": accounts_out, + } + + +def format_fund_history_text( + history: dict[str, dict], + *, + account_names: Optional[dict[str, str]] = None, +) -> str: + if not history: + return "(暂无资金历史快照)" + names = account_names or {} + lines = ["【资金快照(资金账户 + 交易账户 USDT)】"] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + if not ac_map: + continue + parts = [] + for key, ac in ac_map.items(): + label = names.get(key) or ac.get("name") or key + fu = ac.get("funding_usdt") + tu = ac.get("trading_usdt") + tot = ac.get("total_usdt") + if tot is None: + tot = account_total_usdt(fu, tu) + fu_txt = f"{fu}U" if fu is not None else "未知" + tu_txt = f"{tu}U" if tu is not None else "未知" + tot_txt = f"{tot}U" if tot is not None else "未知" + parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt})") + lines.append(f"- {day}: " + ";".join(parts)) + return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)" diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index e5f8b71..b3d5616 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -97,3 +97,5 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest # 交易日切分(与四实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期) TRADING_DAY_RESET_HOUR=8 +# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180) +# HUB_FUND_HISTORY_DAYS=180 diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md index 23ae07e..7c1dda8 100644 --- a/manual_trading_hub/README.md +++ b/manual_trading_hub/README.md @@ -1,6 +1,6 @@ # 复盘系统中控(manual_trading_hub) -> **完整说明**:[使用说明.md](./使用说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md) +> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md) 多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。 @@ -11,6 +11,7 @@ | 能力 | 说明 | |------|------| | 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) | +| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 | | 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) | | **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) | | 紧急全平 | 单户 / 全局市价减仓 | @@ -23,7 +24,7 @@ ## 架构 ``` -浏览器 → hub.py (:5100) 监控 / 行情 / **AI 教练** / 设置 / 登录 +浏览器 → hub.py (:5100) 监控 / 资金概况 / 行情 / **AI 教练** / 设置 / 登录 ├→ agent.py × N (:15200~15203) 持仓、全平 └→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合 ``` diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 1f97acc..feb8509 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -248,6 +248,12 @@ async def _run_chart_poll() -> dict: async def _run_board_aggregate() -> dict: try: body = await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT) + try: + from hub_fund_history_lib import record_fund_snapshot_from_board + + await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or []) + except Exception: + pass return {"ok": True, **body} except asyncio.TimeoutError: return { @@ -641,6 +647,7 @@ def root_redirect(): @app.get("/monitor") @app.get("/market") @app.get("/archive") +@app.get("/funds") @app.get("/ai") @app.get("/settings") def shell_pages(): @@ -2067,6 +2074,22 @@ async def api_archive_sync(): return body +@app.get("/api/hub/fund-overview") +def api_hub_fund_overview(): + from hub_fund_history_lib import build_fund_overview + from hub_ai.config import trading_day_reset_hour + + settings = load_settings() + snap = board_store.snapshot_dict() + payload = build_fund_overview( + settings.get("exchanges") or [], + board_rows=snap.get("rows") or [], + reset_hour=trading_day_reset_hour(), + updated_at=snap.get("updated_at"), + ) + return payload + + @app.get("/api/ping") def api_ping(): return { @@ -2074,7 +2097,7 @@ def api_ping(): "service": "manual-trading-hub", "build": HUB_BUILD, "trade_ui": False, - "features": ["monitor", "settings", "auth", "board_sse", "archive"], + "features": ["monitor", "settings", "auth", "board_sse", "archive", "funds"], "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, "board_version": board_store.version, "board_aggregating": board_store.aggregating, diff --git a/manual_trading_hub/hub_ai/config.py b/manual_trading_hub/hub_ai/config.py index 50a8aa9..abaf6ff 100644 --- a/manual_trading_hub/hub_ai/config.py +++ b/manual_trading_hub/hub_ai/config.py @@ -10,7 +10,7 @@ CHAT_TEMPERATURE = 0.5 CHAT_MAX_HISTORY_TURNS = 20 SUMMARY_RETENTION_DAYS = 90 CHAT_SESSION_RETENTION_DAYS = 60 -FUND_HISTORY_DAYS = 15 +FUND_HISTORY_DAYS = 180 CHAT_MAX_ATTACHMENTS = 3 CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024 CHAT_MAX_TEXT_FILE_BYTES = 200 * 1024 diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 7db2a54..d78b655 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -391,7 +391,14 @@ def build_daily_context( "total_funding_usdt": round(total_funding, 4) if total_funding is not None else None, "total_trading_usdt": round(total_trading, 4) if total_trading is not None else None, } - record_fund_snapshot(day, accounts, keep_days=FUND_HISTORY_DAYS) + snap_accounts = [ + { + **ac, + "monitored": ac.get("status") != "未监控", + } + for ac in accounts + ] + record_fund_snapshot(day, snap_accounts, keep_days=FUND_HISTORY_DAYS) fund_history = get_fund_history(anchor_day=day, keep_days=FUND_HISTORY_DAYS) account_names = {str(ac.get("key") or ac.get("id")): ac.get("name") for ac in accounts} fund_history_text = format_fund_history_text(fund_history, account_names=account_names) diff --git a/manual_trading_hub/hub_ai/fund_history.py b/manual_trading_hub/hub_ai/fund_history.py index 30590dc..c40a883 100644 --- a/manual_trading_hub/hub_ai/fund_history.py +++ b/manual_trading_hub/hub_ai/fund_history.py @@ -1,109 +1,18 @@ -"""中控 AI:分户资金快照(保留 15 天,供总结/聊天上下文)。""" +"""中控 AI:分户资金快照(委托 hub_fund_history_lib,保留 180 交易日)。""" from __future__ import annotations -import json -import os -from datetime import datetime, timedelta -from pathlib import Path from typing import Any, Optional -from hub_ai.config import FUND_HISTORY_DAYS +from hub_fund_history_lib import ( + FUND_HISTORY_DAYS, + format_fund_history_text, + get_fund_history, + record_fund_snapshot, +) -HUB_DIR = Path(__file__).resolve().parent.parent -FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json" - - -def _now_str() -> str: - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - -def _atomic_write(path: Path, data: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - os.replace(tmp, path) - - -def _load_store() -> dict: - if not FUND_HISTORY_PATH.is_file(): - return {"version": 1, "days": {}} - try: - loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - loaded.setdefault("version", 1) - loaded.setdefault("days", {}) - return loaded - except Exception: - pass - return {"version": 1, "days": {}} - - -def _prune_days(days: dict, *, keep_days: int, anchor_day: str) -> dict: - try: - anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d") - except ValueError: - anchor = datetime.now() - cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d") - out = {k: v for k, v in (days or {}).items() if str(k) >= cutoff} - return out - - -def record_fund_snapshot( - trading_day: str, - accounts: list[dict], - *, - keep_days: int = FUND_HISTORY_DAYS, -) -> dict[str, Any]: - """写入当日各户资金账户/交易账户余额,并裁剪历史。""" - day = (trading_day or "").strip()[:10] - if not day: - return {} - store = _load_store() - days = dict(store.get("days") or {}) - row_accounts: dict[str, dict] = {} - for ac in accounts or []: - key = str(ac.get("key") or ac.get("id") or "") - if not key: - continue - row_accounts[key] = { - "name": ac.get("name"), - "funding_usdt": ac.get("funding_usdt"), - "trading_usdt": ac.get("trading_usdt"), - "recorded_at": _now_str(), - } - days[day] = {"accounts": row_accounts, "updated_at": _now_str()} - days = _prune_days(days, keep_days=keep_days, anchor_day=day) - _atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days}) - return days - - -def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]: - store = _load_store() - days = _prune_days( - dict(store.get("days") or {}), - keep_days=keep_days, - anchor_day=anchor_day, - ) - return days - - -def format_fund_history_text(history: dict[str, dict], *, account_names: Optional[dict[str, str]] = None) -> str: - if not history: - return "(暂无资金历史快照)" - names = account_names or {} - lines = ["【近15日资金快照(资金账户 / 交易账户 USDT)】"] - for day in sorted(history.keys()): - block = history.get(day) or {} - ac_map = block.get("accounts") or {} - if not ac_map: - continue - parts = [] - for key, ac in ac_map.items(): - label = names.get(key) or ac.get("name") or key - fu = ac.get("funding_usdt") - tu = ac.get("trading_usdt") - fu_txt = f"{fu}U" if fu is not None else "未知" - tu_txt = f"{tu}U" if tu is not None else "未知" - parts.append(f"{label}: 资金{fu_txt} / 交易{tu_txt}") - lines.append(f"- {day}: " + ";".join(parts)) - return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)" +__all__ = [ + "FUND_HISTORY_DAYS", + "format_fund_history_text", + "get_fund_history", + "record_fund_snapshot", +] diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 1039955..2b2336d 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -4428,6 +4428,135 @@ body.hub-page-ai #page-ai { opacity: 0.65; } +/* —— 资金概况 —— */ +.funds-toolbar { + margin-bottom: 12px; +} +.funds-status.err { + color: var(--red); +} +.funds-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 10px; +} +.funds-stat-card { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 14px; +} +.funds-stat-label { + font-size: 0.75rem; + color: var(--muted); + margin-bottom: 4px; +} +.funds-stat-value { + font-size: 1.35rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.funds-stat-val { + font-size: 1.1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.funds-stat-val.pos { + color: var(--green); +} +.funds-stat-val.neg { + color: var(--red); +} +.funds-dd-pct { + font-size: 0.82rem; + color: var(--muted); + font-weight: 500; +} +.funds-meta { + font-size: 0.78rem; + color: var(--muted); + margin: 0 0 12px; +} +.funds-chart-host { + height: 280px; + min-height: 220px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--chart-surface, var(--panel)); + margin-bottom: 18px; + overflow: hidden; +} +.funds-section-title { + margin: 0 0 10px; + font-size: 0.95rem; + font-weight: 600; +} +.funds-accounts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} +.funds-ac-card { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} +.funds-ac-card.is-off { + opacity: 0.72; +} +.funds-ac-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.funds-ac-head h3 { + margin: 0; + font-size: 0.92rem; +} +.funds-ac-badge { + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-soft); + color: var(--muted); +} +.funds-ac-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 10px; + font-size: 0.78rem; +} +.funds-ac-stats .k { + color: var(--muted); + margin-right: 6px; +} +.funds-ac-stats .v { + font-variant-numeric: tabular-nums; +} +.funds-ac-chart { + height: 72px; + min-height: 72px; + border-radius: 6px; + background: var(--inset-surface); + font-size: 0.72rem; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.funds-empty { + color: var(--muted); + font-size: 0.85rem; + padding: 12px 0; +} + /* —— 币种档案 —— */ .archive-toolbar { flex-wrap: wrap; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 6471561..913d622 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -625,6 +625,7 @@ const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("settings")) return "settings"; if (p.includes("archive")) return "archive"; + if (p.includes("funds")) return "funds"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; return "monitor"; @@ -633,6 +634,7 @@ function pageElementId(page) { if (page === "settings") return "page-settings"; if (page === "archive") return "page-archive"; + if (page === "funds") return "page-funds"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; return "page-monitor"; @@ -662,6 +664,11 @@ } else if (window.hubArchivePage && window.hubArchivePage.destroy) { window.hubArchivePage.destroy(); } + if (page === "funds" && window.hubFundsPage) { + window.hubFundsPage.init(); + } else if (window.hubFundsPage && window.hubFundsPage.destroy) { + window.hubFundsPage.destroy(); + } if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); } else if (window.hubMarketChart) { diff --git a/manual_trading_hub/static/funds.js b/manual_trading_hub/static/funds.js new file mode 100644 index 0000000..473f216 --- /dev/null +++ b/manual_trading_hub/static/funds.js @@ -0,0 +1,281 @@ +/** + * 中控资金概况:总资金曲线、分户资金与回撤(资金户+交易户,不含浮盈)。 + */ +(function () { + const page = document.getElementById("page-funds"); + if (!page) return; + + const elStatus = document.getElementById("funds-status"); + const elTotal = document.getElementById("funds-total-usdt"); + const elDdU = document.getElementById("funds-total-dd-u"); + const elDdPct = document.getElementById("funds-total-dd-pct"); + const elDelta = document.getElementById("funds-total-delta"); + const elMeta = document.getElementById("funds-meta"); + const elChartHost = document.getElementById("funds-chart-total"); + const elAccounts = document.getElementById("funds-accounts"); + const elBtnRefresh = document.getElementById("funds-btn-refresh"); + + let chart = null; + let lineSeries = null; + let inited = false; + let loading = false; + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function fmtDelta(n) { + if (n == null || !Number.isFinite(Number(n))) return "—"; + const v = Number(n); + const sign = v > 0 ? "+" : ""; + return sign + v.toFixed(2) + " U"; + } + + function deltaClass(n) { + if (!Number.isFinite(Number(n))) return ""; + if (Number(n) > 0) return "pos"; + if (Number(n) < 0) return "neg"; + return ""; + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "funds-status" + (isErr ? " err" : ""); + } + + function seriesToChartData(series) { + return (series || []) + .filter(function (p) { + return p && p.day && Number.isFinite(Number(p.total_usdt)); + }) + .map(function (p) { + return { time: String(p.day), value: Number(p.total_usdt) }; + }); + } + + function destroyChart() { + if (chart) { + chart.remove(); + chart = null; + lineSeries = null; + } + if (elChartHost) elChartHost.innerHTML = ""; + } + + function chartPalette() { + const light = document.documentElement.getAttribute("data-theme") === "light"; + return light + ? { bg: "#f0f4f9", text: "#4a6078", border: "#b8c8d8", line: "#006e9a" } + : { bg: "#0b0e18", text: "#9aa4b8", border: "#2a3348", line: "#3b82f6" }; + } + + function ensureChart() { + if (!elChartHost || !window.LightweightCharts) return; + if (chart) return; + const p = chartPalette(); + chart = LightweightCharts.createChart(elChartHost, { + layout: { background: { color: p.bg }, textColor: p.text }, + grid: { + vertLines: { color: p.border, visible: true }, + horzLines: { color: p.border, visible: true }, + }, + rightPriceScale: { borderColor: p.border }, + timeScale: { borderColor: p.border, timeVisible: true }, + crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, + handleScroll: { mouseWheel: true, pressedMouseMove: true }, + handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, + }); + lineSeries = chart.addAreaSeries({ + lineColor: p.line, + topColor: p.line + "44", + bottomColor: p.line + "08", + lineWidth: 2, + priceFormat: { type: "price", precision: 2, minMove: 0.01 }, + }); + new ResizeObserver(function () { + if (chart && elChartHost) { + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + }).observe(elChartHost); + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + + function renderMiniChart(host, series) { + if (!host || !window.LightweightCharts) return; + host.innerHTML = ""; + const data = seriesToChartData(series); + if (data.length < 2) { + host.textContent = "历史不足"; + return; + } + const p = chartPalette(); + const mini = LightweightCharts.createChart(host, { + layout: { background: { color: "transparent" }, textColor: p.text }, + grid: { vertLines: { visible: false }, horzLines: { visible: false } }, + rightPriceScale: { visible: false }, + timeScale: { visible: false }, + crosshair: { mode: LightweightCharts.CrosshairMode.Hidden }, + handleScroll: false, + handleScale: false, + }); + const s = mini.addLineSeries({ color: p.line, lineWidth: 1.5 }); + s.setData(data); + mini.timeScale().fitContent(); + const w = host.clientWidth; + const h = host.clientHeight; + if (w > 0 && h > 0) mini.applyOptions({ width: w, height: h }); + } + + function renderAccounts(accounts) { + if (!elAccounts) return; + if (!accounts || !accounts.length) { + elAccounts.innerHTML = '

暂无账户配置

'; + return; + } + elAccounts.innerHTML = accounts + .map(function (ac) { + const monitored = !!ac.monitored; + const cls = monitored ? "" : " is-off"; + const total = monitored && ac.data_ok ? fmt(ac.total_usdt, 2) + " U" : "—"; + const funding = monitored && ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) : "—"; + const trading = monitored && ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) : "—"; + const dd = ac.drawdown || {}; + const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; + const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; + const status = monitored ? (ac.data_ok ? "已监控" : "余额未齐") : "未监控"; + return ( + '
' + + '
' + + '

' + + (ac.name || ac.key || "—") + + "

" + + '' + + status + + "" + + "
" + + '
' + + '
总资金' + + total + + "
" + + '
资金户' + + funding + + "
" + + '
交易户' + + trading + + "
" + + '
最大回撤' + + ddU + + " / " + + ddPct + + "
" + + "
" + + '' + + "
" + ); + }) + .join(""); + + elAccounts.querySelectorAll(".funds-ac-card").forEach(function (card, idx) { + const ac = accounts[idx]; + const host = card.querySelector(".funds-ac-chart"); + if (ac && ac.monitored && host) { + renderMiniChart(host, ac.series || []); + } else if (host) { + host.textContent = monitoredLabel(ac); + } + }); + } + + function monitoredLabel(ac) { + return ac && ac.monitored ? "暂无曲线" : "未参与合计"; + } + + function renderOverview(data) { + const totals = data.totals || {}; + const dd = totals.drawdown || {}; + if (elTotal) { + elTotal.textContent = + totals.total_usdt != null ? fmt(totals.total_usdt, 2) + " U" : "—"; + } + if (elDdU) elDdU.textContent = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; + if (elDdPct) { + elDdPct.textContent = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; + } + if (elDelta) { + elDelta.textContent = fmtDelta(totals.day_delta_usdt); + elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt); + } + if (elMeta) { + const parts = [ + "交易日 " + (data.trading_day || "—"), + "切日 " + (data.reset_hour != null ? data.reset_hour : 8) + ":00 北京", + "历史 " + (data.keep_days || 180) + " 天", + ]; + if (data.updated_at) parts.push("刷新 " + data.updated_at); + if (totals.live_known_count != null) { + parts.push("合计含 " + totals.live_known_count + " 户"); + } + elMeta.textContent = parts.join(" · "); + } + ensureChart(); + if (lineSeries) { + const pts = seriesToChartData(totals.series || []); + if (pts.length) { + lineSeries.setData(pts); + chart.timeScale().fitContent(); + } else { + lineSeries.setData([]); + } + } + renderAccounts(data.accounts || []); + } + + async function load() { + if (loading) return; + loading = true; + setStatus("加载中…"); + try { + const r = await fetch("/api/hub/fund-overview", { credentials: "same-origin" }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || j.msg || "加载失败", true); + return; + } + renderOverview(j); + setStatus(""); + } catch (e) { + setStatus(String(e.message || e), true); + } finally { + loading = false; + } + } + + function bind() { + if (elBtnRefresh) elBtnRefresh.addEventListener("click", load); + document.addEventListener("hub-theme-change", function () { + destroyChart(); + load(); + }); + } + + function init() { + if (!page || page.classList.contains("hidden")) return; + if (!inited) { + bind(); + inited = true; + } + load(); + } + + function destroy() { + destroyChart(); + } + + window.hubFundsPage = { init: init, destroy: destroy, reload: load }; +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 82e890c..37ad78c 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -47,6 +47,7 @@ 监控区 行情区 币种档案 + 资金概况 AI 教练 系统设置 @@ -330,6 +331,35 @@ + +