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 一致,定义「今日总结」的日期)
|
||||||
TRADING_DAY_RESET_HOUR=8
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
|
||||||
|
# HUB_FUND_HISTORY_DAYS=180
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 复盘系统中控(manual_trading_hub)
|
# 复盘系统中控(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)。
|
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
|
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
|
||||||
|
| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 |
|
||||||
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
|
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
|
||||||
| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
||||||
| 紧急全平 | 单户 / 全局市价减仓 |
|
| 紧急全平 | 单户 / 全局市价减仓 |
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
浏览器 → hub.py (:5100) 监控 / 行情 / **AI 教练** / 设置 / 登录
|
浏览器 → hub.py (:5100) 监控 / 资金概况 / 行情 / **AI 教练** / 设置 / 登录
|
||||||
├→ agent.py × N (:15200~15203) 持仓、全平
|
├→ agent.py × N (:15200~15203) 持仓、全平
|
||||||
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
|
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ async def _run_chart_poll() -> dict:
|
|||||||
async def _run_board_aggregate() -> dict:
|
async def _run_board_aggregate() -> dict:
|
||||||
try:
|
try:
|
||||||
body = await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT)
|
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}
|
return {"ok": True, **body}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return {
|
return {
|
||||||
@@ -641,6 +647,7 @@ def root_redirect():
|
|||||||
@app.get("/monitor")
|
@app.get("/monitor")
|
||||||
@app.get("/market")
|
@app.get("/market")
|
||||||
@app.get("/archive")
|
@app.get("/archive")
|
||||||
|
@app.get("/funds")
|
||||||
@app.get("/ai")
|
@app.get("/ai")
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
def shell_pages():
|
def shell_pages():
|
||||||
@@ -2067,6 +2074,22 @@ async def api_archive_sync():
|
|||||||
return body
|
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")
|
@app.get("/api/ping")
|
||||||
def api_ping():
|
def api_ping():
|
||||||
return {
|
return {
|
||||||
@@ -2074,7 +2097,7 @@ def api_ping():
|
|||||||
"service": "manual-trading-hub",
|
"service": "manual-trading-hub",
|
||||||
"build": HUB_BUILD,
|
"build": HUB_BUILD,
|
||||||
"trade_ui": False,
|
"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_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
|
||||||
"board_version": board_store.version,
|
"board_version": board_store.version,
|
||||||
"board_aggregating": board_store.aggregating,
|
"board_aggregating": board_store.aggregating,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ CHAT_TEMPERATURE = 0.5
|
|||||||
CHAT_MAX_HISTORY_TURNS = 20
|
CHAT_MAX_HISTORY_TURNS = 20
|
||||||
SUMMARY_RETENTION_DAYS = 90
|
SUMMARY_RETENTION_DAYS = 90
|
||||||
CHAT_SESSION_RETENTION_DAYS = 60
|
CHAT_SESSION_RETENTION_DAYS = 60
|
||||||
FUND_HISTORY_DAYS = 15
|
FUND_HISTORY_DAYS = 180
|
||||||
CHAT_MAX_ATTACHMENTS = 3
|
CHAT_MAX_ATTACHMENTS = 3
|
||||||
CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
||||||
CHAT_MAX_TEXT_FILE_BYTES = 200 * 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_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,
|
"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)
|
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}
|
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)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
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
|
__all__ = [
|
||||||
FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
|
"FUND_HISTORY_DAYS",
|
||||||
|
"format_fund_history_text",
|
||||||
|
"get_fund_history",
|
||||||
def _now_str() -> str:
|
"record_fund_snapshot",
|
||||||
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 "(暂无资金历史快照)"
|
|
||||||
|
|||||||
@@ -4428,6 +4428,135 @@ body.hub-page-ai #page-ai {
|
|||||||
opacity: 0.65;
|
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 {
|
.archive-toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -625,6 +625,7 @@
|
|||||||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||||||
if (p.includes("settings")) return "settings";
|
if (p.includes("settings")) return "settings";
|
||||||
if (p.includes("archive")) return "archive";
|
if (p.includes("archive")) return "archive";
|
||||||
|
if (p.includes("funds")) return "funds";
|
||||||
if (p.includes("market")) return "market";
|
if (p.includes("market")) return "market";
|
||||||
if (p.includes("/ai")) return "ai";
|
if (p.includes("/ai")) return "ai";
|
||||||
return "monitor";
|
return "monitor";
|
||||||
@@ -633,6 +634,7 @@
|
|||||||
function pageElementId(page) {
|
function pageElementId(page) {
|
||||||
if (page === "settings") return "page-settings";
|
if (page === "settings") return "page-settings";
|
||||||
if (page === "archive") return "page-archive";
|
if (page === "archive") return "page-archive";
|
||||||
|
if (page === "funds") return "page-funds";
|
||||||
if (page === "market") return "page-market";
|
if (page === "market") return "page-market";
|
||||||
if (page === "ai") return "page-ai";
|
if (page === "ai") return "page-ai";
|
||||||
return "page-monitor";
|
return "page-monitor";
|
||||||
@@ -662,6 +664,11 @@
|
|||||||
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
||||||
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) {
|
if (page === "market" && window.hubMarketChart) {
|
||||||
window.hubMarketChart.init();
|
window.hubMarketChart.init();
|
||||||
} else if (window.hubMarketChart) {
|
} 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 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'" />
|
<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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||||
<a href="/market" id="nav-market">行情区</a>
|
<a href="/market" id="nav-market">行情区</a>
|
||||||
<a href="/archive" id="nav-archive">币种档案</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="/ai" id="nav-ai">AI 教练</a>
|
||||||
<a href="/settings" id="nav-settings">系统设置</a>
|
<a href="/settings" id="nav-settings">系统设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -330,6 +331,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="page-ai" class="page hidden">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">AI</span> 教练</h1>
|
<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_draw.js?v=20260609-market-day-split"></script>
|
||||||
<script src="/assets/chart.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/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/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>
|
</body>
|
||||||
</html>
|
</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 监控区(持仓、关键位、趋势计划、全平)
|
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
||||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||||
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
|
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
|
||||||
|
├─ /funds 资金概况(总资金曲线、分户资金与回撤)
|
||||||
├─ /ai AI 教练(四户今日总结 + 聊天)
|
├─ /ai AI 教练(四户今日总结 + 聊天)
|
||||||
└─ /settings 系统设置(hub_settings.json)
|
└─ /settings 系统设置(hub_settings.json)
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ pm2 save
|
|||||||
- 监控区:`http://127.0.0.1:5100/monitor`
|
- 监控区:`http://127.0.0.1:5100/monitor`
|
||||||
- 行情区:`http://127.0.0.1:5100/market`
|
- 行情区:`http://127.0.0.1:5100/market`
|
||||||
- 币种档案:`http://127.0.0.1:5100/archive`
|
- 币种档案:`http://127.0.0.1:5100/archive`
|
||||||
|
- 资金概况:`http://127.0.0.1:5100/funds`
|
||||||
- 系统设置:`http://127.0.0.1:5100/settings`
|
- 系统设置:`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)**。
|
与行情区 `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`
|
### 4.3 AI 教练 `/ai`
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
@@ -318,6 +332,7 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
|
|||||||
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
|
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
|
||||||
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
|
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
|
||||||
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) |
|
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) |
|
||||||
|
| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金、180 日曲线、回撤 |
|
||||||
| GET | `/api/archive/meta` | 币种档案:周期、同步间隔 |
|
| GET | `/api/archive/meta` | 币种档案:周期、同步间隔 |
|
||||||
| GET | `/api/archive/list` | 币种列表(筛选 query) |
|
| GET | `/api/archive/list` | 币种列表(筛选 query) |
|
||||||
| GET | `/api/archive/detail` | 单币种交易时间线 |
|
| 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