This commit is contained in:
aaron 2026-06-07 23:53:07 +08:00
parent 75ff75a9a6
commit db2080a8ef
6 changed files with 258 additions and 15 deletions

View File

@ -332,6 +332,76 @@ def get_live_account_snapshot(account_id: int) -> dict:
return _row(row)
def record_live_account_equity_snapshot(
account_id: int,
*,
equity_usdt: float = 0,
wallet_balance_usdt: float = 0,
available_usdt: float = 0,
used_margin_usdt: float = 0,
unrealized_pnl_usdt: float = 0,
open_position_value_usdt: float = 0,
position_count: int = 0,
snapshot_at: str = "",
) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {"ok": False, "reason": "invalid_account_id"}
snapshot_at = snapshot_at or _now()
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO live_account_equity_history (
account_id, equity_usdt, wallet_balance_usdt, available_usdt,
used_margin_usdt, unrealized_pnl_usdt, open_position_value_usdt,
position_count, snapshot_at
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
account_id,
_safe_float(equity_usdt),
_safe_float(wallet_balance_usdt),
_safe_float(available_usdt),
_safe_float(used_margin_usdt),
_safe_float(unrealized_pnl_usdt),
_safe_float(open_position_value_usdt),
_safe_int(position_count),
snapshot_at,
),
).fetchone()
conn.commit()
finally:
conn.close()
item = _row(row)
item["ok"] = True
return item
def list_live_account_equity_history(account_id: int, limit: int = 500) -> list[dict]:
account_id = _safe_int(account_id)
if account_id <= 0:
return []
limit = max(1, min(_safe_int(limit, 500), 2000))
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT *
FROM live_account_equity_history
WHERE account_id=%s
ORDER BY snapshot_at ASC, id ASC
LIMIT %s
""",
(account_id, limit),
).fetchall()
finally:
conn.close()
return [_row(r) for r in rows]
def _config_for_account(account: dict | None = None) -> dict:
cfg = get_effective_live_trading_config()
if account:

View File

@ -0,0 +1,17 @@
-- Equity history for live account performance dashboards.
CREATE TABLE IF NOT EXISTS live_account_equity_history (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES live_trade_accounts(id) ON DELETE CASCADE,
equity_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
wallet_balance_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
available_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
used_margin_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
unrealized_pnl_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
open_position_value_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
position_count INTEGER NOT NULL DEFAULT 0,
snapshot_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_live_account_equity_history_account_time
ON live_account_equity_history(account_id, snapshot_at DESC);

View File

@ -8,9 +8,11 @@ from app.db.live_trading import (
_safe_float,
get_live_account,
get_live_account_snapshot,
list_live_account_equity_history,
list_enabled_live_accounts,
list_live_order_events,
list_live_order_intents,
record_live_account_equity_snapshot,
upsert_live_account_snapshot,
)
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
@ -52,6 +54,14 @@ def _position_side_label(side: str) -> str:
return "--"
def _position_pnl_pct(unrealized_pnl: float, margin: float, position_value: float) -> float:
if margin > 0:
return round(unrealized_pnl / margin * 100.0, 6)
if position_value > 0:
return round(unrealized_pnl / position_value * 100.0, 6)
return 0.0
def _compact_position(item: dict, account: dict | None = None) -> dict:
info = item.get("info") if isinstance(item.get("info"), dict) else {}
contracts = _safe_float(item.get("contracts") or info.get("positionAmt"))
@ -107,10 +117,31 @@ def _compact_order(item: dict) -> dict:
"amount": _safe_float(item.get("amount") or info.get("origQty")),
"filled": _safe_float(item.get("filled") or info.get("executedQty")),
"average": _safe_float(item.get("average") or info.get("avgPrice")),
"realized_pnl": _safe_float(item.get("realizedPnl") or info.get("realizedPnl") or info.get("realizedProfit")),
"reduce_only": bool(item.get("reduceOnly") or info.get("reduceOnly")),
"position_side": item.get("positionSide") or info.get("positionSide") or "",
"timestamp": item.get("datetime") or item.get("timestamp") or info.get("updateTime") or info.get("time"),
}
def _enrich_positions(positions: list[dict]) -> list[dict]:
enriched = []
for item in positions or []:
row = dict(item)
unrealized = _safe_float(row.get("unrealized_pnl"))
margin = _safe_float(row.get("margin_usdt"))
value = _safe_float(row.get("position_value_usdt"))
row["pnl_pct"] = _position_pnl_pct(unrealized, margin, value)
if unrealized > 0:
row["pnl_status"] = "profit"
elif unrealized < 0:
row["pnl_status"] = "loss"
else:
row["pnl_status"] = "flat"
enriched.append(row)
return enriched
def _normalize_symbol(symbol: str) -> str:
value = str(symbol or "").strip().upper()
if value and "/" not in value and value.endswith("USDT"):
@ -198,14 +229,109 @@ def _exchange_snapshot_payload(overview: dict) -> dict:
"order_history": overview.get("order_history") or [],
"exchange_cache": overview.get("exchange_cache") or {},
"errors": overview.get("errors") or [],
"performance": overview.get("performance") or {},
"historical_positions": overview.get("historical_positions") or [],
}
def _current_equity_metrics(overview: dict) -> dict:
balance = overview.get("balance") if isinstance(overview.get("balance"), dict) else {}
usdt = balance.get("usdt") if isinstance(balance.get("usdt"), dict) else {}
positions = overview.get("positions") or []
equity = _safe_float(usdt.get("total"))
available = _safe_float(usdt.get("free"))
used = _safe_float(usdt.get("used"))
unrealized = sum(_safe_float(x.get("unrealized_pnl")) for x in positions)
position_value = sum(abs(_safe_float(x.get("position_value_usdt"))) for x in positions)
return {
"equity_usdt": round(equity, 8),
"wallet_balance_usdt": round(equity - unrealized, 8),
"available_usdt": round(available, 8),
"used_margin_usdt": round(used, 8),
"unrealized_pnl_usdt": round(unrealized, 8),
"open_position_value_usdt": round(position_value, 8),
"position_count": len(positions),
}
def _max_drawdown(history: list[dict]) -> tuple[float, float]:
peak = 0.0
max_dd_pct = 0.0
max_dd_usdt = 0.0
for row in history:
equity = _safe_float(row.get("equity_usdt"))
if equity <= 0:
continue
peak = max(peak, equity)
if peak <= 0:
continue
dd_usdt = max(0.0, peak - equity)
dd_pct = dd_usdt / peak * 100.0
if dd_pct > max_dd_pct:
max_dd_pct = dd_pct
max_dd_usdt = dd_usdt
return round(max_dd_pct, 6), round(max_dd_usdt, 8)
def _historical_positions_from_orders(orders: list[dict]) -> list[dict]:
rows = []
for order in orders or []:
status = str(order.get("status") or "").lower()
if status not in {"closed", "filled"}:
continue
pnl = _safe_float(order.get("realized_pnl"))
result = "盈利" if pnl > 0 else "亏损" if pnl < 0 else "未知"
rows.append({
"time": order.get("timestamp") or "",
"symbol": order.get("symbol") or "",
"side": order.get("side") or "",
"side_label": _position_side_label(order.get("side")),
"price": _safe_float(order.get("average") or order.get("price")),
"amount": _safe_float(order.get("filled") or order.get("amount")),
"realized_pnl": pnl,
"result": result,
"status": order.get("status") or "",
})
return rows[:30]
def _attach_performance(overview: dict, account_id: int, *, record_history: bool = False, snapshot_at: str = "") -> dict:
overview["positions"] = _enrich_positions(overview.get("positions") or [])
metrics = _current_equity_metrics(overview)
if record_history and metrics["equity_usdt"] > 0:
record_live_account_equity_snapshot(account_id, **metrics, snapshot_at=snapshot_at or _now())
history = list_live_account_equity_history(account_id)
if not history and metrics["equity_usdt"] > 0:
history = [{**metrics, "snapshot_at": snapshot_at or _now()}]
baseline = _safe_float(history[0].get("equity_usdt")) if history else metrics["equity_usdt"]
peak = max([_safe_float(x.get("equity_usdt")) for x in history] + [metrics["equity_usdt"], 0.0])
total_pnl = metrics["equity_usdt"] - baseline if baseline > 0 else 0.0
return_pct = total_pnl / baseline * 100.0 if baseline > 0 else 0.0
current_dd_usdt = max(0.0, peak - metrics["equity_usdt"])
current_dd_pct = current_dd_usdt / peak * 100.0 if peak > 0 else 0.0
max_dd_pct, max_dd_usdt = _max_drawdown(history + [{**metrics, "snapshot_at": snapshot_at or _now()}])
overview["performance"] = {
**metrics,
"baseline_equity_usdt": round(baseline, 8),
"total_pnl_usdt": round(total_pnl, 8),
"return_pct": round(return_pct, 6),
"peak_equity_usdt": round(peak, 8),
"current_drawdown_usdt": round(current_dd_usdt, 8),
"current_drawdown_pct": round(current_dd_pct, 6),
"max_drawdown_usdt": max_dd_usdt,
"max_drawdown_pct": max_dd_pct,
"history_points": len(history),
"basis": "按首次同步净值计算,未单独扣除充值/提现影响",
}
overview["historical_positions"] = _historical_positions_from_orders(overview.get("order_history") or [])
return overview
def _merge_snapshot(overview: dict, snapshot_row: dict) -> dict:
payload = snapshot_row.get("snapshot") if isinstance(snapshot_row.get("snapshot"), dict) else {}
if not payload:
return overview
for key in ("balance", "positions", "open_orders", "order_history", "errors"):
for key in ("balance", "positions", "open_orders", "order_history", "errors", "performance", "historical_positions"):
if key in payload:
overview[key] = payload.get(key)
synced_at = snapshot_row.get("synced_at") or ""
@ -222,7 +348,7 @@ def _merge_snapshot(overview: dict, snapshot_row: dict) -> dict:
}
if status == "error" and error_message and error_message not in (overview.get("errors") or []):
overview.setdefault("errors", []).append(error_message)
return overview
return _attach_performance(overview, int(overview.get("account", {}).get("id") or 0))
def get_live_account_overview(account_id: int, *, history_limit: int = 30, refresh: bool = False, client_factory=None) -> dict:
@ -236,8 +362,10 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
"positions": [],
"open_orders": [],
"order_history": [],
"historical_positions": [],
"intent_history": list_live_order_intents(limit=history_limit, account_id=account_id).get("items", []),
"events": list_live_order_events(limit=history_limit).get("items", []),
"performance": {},
"exchange_cache": {"cached": False, "loaded": False, "requires_refresh": True},
"errors": [],
}
@ -270,6 +398,7 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
"status": "error",
"synced_at": synced_at,
}
overview = _attach_performance(overview, account_id, record_history=False, snapshot_at=synced_at)
upsert_live_account_snapshot(
account_id,
_exchange_snapshot_payload(overview),
@ -305,6 +434,7 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
"status": "error" if overview["errors"] else "ok",
"synced_at": synced_at,
}
overview = _attach_performance(overview, account_id, record_history=True, snapshot_at=synced_at)
upsert_live_account_snapshot(
account_id,
_exchange_snapshot_payload(overview),

File diff suppressed because one or more lines are too long

View File

@ -78,6 +78,7 @@ _ID_TABLES = {
"paper_trades",
"paper_trade_events",
"live_account_snapshots",
"live_account_equity_history",
"live_trade_accounts",
"live_order_intents",
"live_order_events",

View File

@ -60,7 +60,9 @@ def test_live_trading_admin_can_access_page_and_seed_account():
assert page.status_code == 200
assert "实盘控制台" in page.text
assert "账户总览" in page.text
assert "账户表现" in page.text
assert "当前净值" in page.text
assert "历史仓位" in page.text
assert "留空=全部" in page.text
assert created.status_code == 200
assert created.json()["account_code"] == "binance_sub_1"
@ -173,6 +175,9 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage
assert pos["position_value_usdt"] == 1520
assert pos["leverage"] == 3
assert pos["leverage_source"] == "account_config"
assert round(pos["pnl_pct"], 2) == 1.32
assert overview["performance"]["equity_usdt"] == 1000
assert overview["performance"]["unrealized_pnl_usdt"] == 20
def test_live_account_overview_refresh_persists_database_snapshot_and_reads_without_exchange(monkeypatch):
@ -252,6 +257,7 @@ def test_live_account_overview_fetches_order_history_per_symbol():
"amount": 2,
"filled": 2,
"datetime": "2026-05-30T08:00:00",
"info": {"realizedPnl": "3.5"},
}]
client = Client()
@ -259,6 +265,7 @@ def test_live_account_overview_fetches_order_history_per_symbol():
assert client.order_symbols == ["BTC/USDT", "ETH/USDT"]
assert len(overview["order_history"]) == 2
assert overview["historical_positions"][0]["result"] == "盈利"
assert overview["errors"] == []