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:
@@ -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 "(暂无资金历史快照)"
|
||||
@@ -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
|
||||
|
||||
@@ -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 只读聚合
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
from hub_fund_history_lib import (
|
||||
FUND_HISTORY_DAYS,
|
||||
format_fund_history_text,
|
||||
get_fund_history,
|
||||
record_fund_snapshot,
|
||||
)
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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` | 单币种交易时间线 |
|
||||
|
||||
@@ -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` 后生效
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user