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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 16:50:47 +08:00
parent 6eb17b7ddc
commit 77c7bbbb13
14 changed files with 1069 additions and 112 deletions
+384
View File
@@ -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 "(暂无资金历史快照)"
+2
View File
@@ -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
+3 -2
View File
@@ -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 (:1520015203) 持仓、全平
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
```
+24 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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)
+13 -104
View File
@@ -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",
]
+129
View File
@@ -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;
+7
View File
@@ -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) {
+281
View File
@@ -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 = '<p class="funds-empty">暂无账户配置</p>';
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 (
'<article class="funds-ac-card' +
cls +
'" data-key="' +
(ac.key || "") +
'">' +
'<div class="funds-ac-head">' +
'<h3>' +
(ac.name || ac.key || "—") +
"</h3>" +
'<span class="funds-ac-badge">' +
status +
"</span>" +
"</div>" +
'<div class="funds-ac-stats">' +
'<div><span class="k">总资金</span><span class="v">' +
total +
"</span></div>" +
'<div><span class="k">资金户</span><span class="v">' +
funding +
"</span></div>" +
'<div><span class="k">交易户</span><span class="v">' +
trading +
"</span></div>" +
'<div><span class="k">最大回撤</span><span class="v">' +
ddU +
" / " +
ddPct +
"</span></div>" +
"</div>" +
'<div class="funds-ac-chart" aria-hidden="true"></div>' +
"</article>"
);
})
.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 };
})();
+33 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260609-market-day-split" />
<link rel="stylesheet" href="/assets/app.css?v=20260609-hub-funds" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -47,6 +47,7 @@
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/archive" id="nav-archive">币种档案</a>
<a href="/funds" id="nav-funds">资金概况</a>
<a href="/ai" id="nav-ai">AI 教练</a>
<a href="/settings" id="nav-settings">系统设置</a>
</nav>
@@ -330,6 +331,35 @@
</div>
</div>
<div id="page-funds" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">FND</span> 资金概况</h1>
<p class="page-desc">总资金 = 各监控户(资金账户 + 交易账户);按北京时间交易日切日快照,保留 180 天</p>
</div>
<div class="funds-toolbar toolbar">
<button type="button" id="funds-btn-refresh" class="primary">刷新</button>
<span id="funds-status" class="toolbar-meta funds-status"></span>
</div>
<section class="funds-summary">
<div class="funds-stat-card">
<div class="funds-stat-label">总资金</div>
<div id="funds-total-usdt" class="funds-stat-value"></div>
</div>
<div class="funds-stat-card">
<div class="funds-stat-label">较昨日</div>
<div id="funds-total-delta" class="funds-stat-val"></div>
</div>
<div class="funds-stat-card">
<div class="funds-stat-label">最大回撤</div>
<div class="funds-stat-value"><span id="funds-total-dd-u"></span> <small id="funds-total-dd-pct" class="funds-dd-pct"></small></div>
</div>
</section>
<p id="funds-meta" class="funds-meta"></p>
<div id="funds-chart-total" class="funds-chart-host"></div>
<h2 class="funds-section-title">分户资金</h2>
<div id="funds-accounts" class="funds-accounts"></div>
</div>
<div id="page-ai" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">AI</span> 教练</h1>
@@ -426,7 +456,8 @@
<script src="/assets/chart_draw.js?v=20260609-market-day-split"></script>
<script src="/assets/chart.js?v=20260609-market-day-split"></script>
<script src="/assets/archive.js?v=20260608-hub-archive-history"></script>
<script src="/assets/funds.js?v=20260609-hub-funds"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260609-hub-mobile-ai-v3"></script>
<script src="/assets/app.js?v=20260609-hub-funds"></script>
</body>
</html>
+16 -1
View File
@@ -1,6 +1,6 @@
# 多账户交易中控 — 使用说明
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **行情区 K 线****币种档案(永久 K 线复盘)**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。行情区细则见 **[行情区说明.md](./行情区说明.md)**;币种档案见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **资金概况**、**行情区 K 线** 与 **币种档案(永久 K 线复盘)**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。资金概况见 **[资金概况说明.md](./资金概况说明.md)**行情区细则见 **[行情区说明.md](./行情区说明.md)**;币种档案见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。
---
@@ -11,6 +11,7 @@
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
├─ /market 行情区(K 线、技术指标、持仓价格线)
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
├─ /funds 资金概况(总资金曲线、分户资金与回撤)
├─ /ai AI 教练(四户今日总结 + 聊天)
└─ /settings 系统设置(hub_settings.json
@@ -129,6 +130,7 @@ pm2 save
- 监控区:`http://127.0.0.1:5100/monitor`
- 行情区:`http://127.0.0.1:5100/market`
- 币种档案:`http://127.0.0.1:5100/archive`
- 资金概况:`http://127.0.0.1:5100/funds`
- 系统设置:`http://127.0.0.1:5100/settings`
验收:
@@ -193,6 +195,18 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
与行情区 `hub_kline.db`15 天滚动)**分离**,建档起 **只增不删**。细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。
### 4.2.2 资金概况 `/funds`
| 功能 | 说明 |
|------|------|
| **总资金** | 已监控账户的 **资金户 + 交易户** 合计(不含浮盈) |
| **总曲线** | 按北京时间交易日(默认 8:00 切日)每日一点,保留 **180** 天 |
| **最大回撤** | 基于总资金余额曲线(非平仓盈亏回撤) |
| **分户** | 每户资金/交易拆分、迷你曲线、分户回撤;**未监控** 不参与合计 |
| **快照** | 监控板聚合成功时写入 `hub_fund_history.json` |
细则见 **[资金概况说明.md](./资金概况说明.md)**。
### 4.3 AI 教练 `/ai`
| 功能 | 说明 |
@@ -318,6 +332,7 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key``symbol``timeframe`、可选 `refresh=1` |
| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金、180 日曲线、回撤 |
| GET | `/api/archive/meta` | 币种档案:周期、同步间隔 |
| GET | `/api/archive/list` | 币种列表(筛选 query |
| GET | `/api/archive/detail` | 单币种交易时间线 |
+89
View File
@@ -0,0 +1,89 @@
# 资金概况 — 使用说明
中控顶栏 **资金概况**`/funds`)汇总四所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示。
---
## 1. 口径
| 项目 | 规则 |
|------|------|
| **单户总资金** | `资金账户 USDT + 交易账户 USDT` |
| **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 |
| **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** |
| **缺数据** | 资金户、交易户任一侧缺失 → 该户当日快照 **跳过**(不估、不补 0 |
| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与四所统计一致 |
| **曲线粒度** | 每个交易日 **1 个点** |
| **历史保留** | 默认 **180** 个交易日(`HUB_FUND_HISTORY_DAYS` |
| **最大回撤** | 基于 **总资金曲线**(分户同理),峰值到谷底的最大跌幅(U 与 %) |
> 与实例统计页「最大回撤」不同:实例统计来自 **平仓盈亏累计**;资金概况来自 **账户余额曲线**
---
## 2. 页面说明
### 总览
- **总资金**:当前监控板最新一轮聚合的实时合计(资金户+交易户齐全才计入)
- **较昨日**:相对上一交易日快照点的变动(U
- **最大回撤**:总资金历史曲线的峰值回撤(U / %)
- **总资金曲线**:近 180 交易日
### 分户卡片
每户展示:总资金、资金户、交易户、最大回撤、迷你曲线。
- **已监控**:正常统计
- **未监控**:显示「未参与合计」,无曲线
- **余额未齐**:已监控但 API 未返回完整资金/交易户
---
## 3. 数据从哪来
```
监控板每 5 秒聚合(board_store
└→ 各实例 GET /api/hub/account
funding_usdt / trading_usdt
└→ 写入 hub_fund_history.json(按交易日去重更新当日)
资金概况页 GET /api/hub/fund-overview
├→ 实时:读 board 缓存
└→ 曲线/回撤:读 hub_fund_history.json
```
- 存储文件:`manual_trading_hub/hub_fund_history.json`(不在 Git 中)
- 旧 AI 快照 `hub_ai_fund_history.json` 会在首次读取时 **自动合并** 到新文件
- AI 教练生成上下文时也会写入同日快照(与监控板共用逻辑)
---
## 4. 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数 |
| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与四所 `.env` 建议一致 |
| `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 |
---
## 5. API
`GET /api/hub/fund-overview`(需中控登录,与监控区相同)
返回字段概要:
- `totals.total_usdt` — 当前总资金
- `totals.series[]``{ day, total_usdt }` 总曲线
- `totals.drawdown``{ peak_usdt, max_drawdown_u, max_drawdown_pct }`
- `accounts[]` — 分户实时余额、曲线、回撤、`monitored` 标记
---
## 6. 相关文档
- [使用说明.md](./使用说明.md) — 中控总览
- [AI教练说明.md](./AI教练说明.md) — AI 上下文中的资金快照文本
- [部署文档.md](./部署文档.md) — 重启 `manual-trading-hub` 后生效
+79
View File
@@ -0,0 +1,79 @@
"""hub_fund_history_lib:总资金、回撤与日快照。"""
from __future__ import annotations
from hub_fund_history_lib import (
account_total_usdt,
build_fund_overview,
compute_drawdown,
record_fund_snapshot,
)
def test_account_total_requires_both_sides():
assert account_total_usdt(10, 20) == 30.0
assert account_total_usdt(10, None) is None
assert account_total_usdt(None, 5) is None
def test_compute_drawdown():
dd = compute_drawdown([100, 120, 90, 110])
assert dd["peak_usdt"] == 120.0
assert dd["max_drawdown_u"] == 30.0
assert dd["max_drawdown_pct"] == 25.0
def test_build_fund_overview_skips_unmonitored(tmp_path, monkeypatch):
hist_path = tmp_path / "hub_fund_history.json"
monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path)
record_fund_snapshot(
"2026-06-01",
[
{
"key": "binance",
"name": "Binance",
"funding_usdt": 10,
"trading_usdt": 20,
"monitored": True,
}
],
keep_days=180,
)
record_fund_snapshot(
"2026-06-02",
[
{
"key": "binance",
"name": "Binance",
"funding_usdt": 12,
"trading_usdt": 18,
"monitored": True,
}
],
keep_days=180,
)
exchanges = [
{"id": "0", "key": "binance", "name": "Binance", "enabled": True},
{"id": "3", "key": "gate_bot", "name": "Gate Bot", "enabled": False},
]
board_rows = [
{
"key": "binance",
"name": "Binance",
"account_ok": True,
"funding_usdt": 15,
"trading_usdt": 25,
}
]
out = build_fund_overview(
exchanges,
board_rows=board_rows,
trading_day="2026-06-02",
keep_days=180,
)
assert out["totals"]["total_usdt"] == 40.0
assert out["totals"]["monitored_count"] == 1
assert len(out["accounts"]) == 2
off = next(a for a in out["accounts"] if a["key"] == "gate_bot")
assert off["monitored"] is False
assert off["total_usdt"] is None
assert out["totals"]["drawdown"]["max_drawdown_u"] == 0.0