1
This commit is contained in:
parent
75ff75a9a6
commit
db2080a8ef
@ -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:
|
||||
|
||||
17
app/db/migrations/0022_live_account_equity_history.sql
Normal file
17
app/db/migrations/0022_live_account_equity_history.sql
Normal 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);
|
||||
@ -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
@ -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",
|
||||
|
||||
@ -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"] == []
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user