在后端新增了交易统计API,支持获取最近N天的交易数据,包括按交易对和按小时的聚合统计。同时,前端组件StatsDashboard更新,展示交易统计信息,包括净盈亏、胜率及策略建议。此改动旨在提升交易数据分析能力,帮助用户更好地理解交易表现。
443 lines
20 KiB
Python
443 lines
20 KiB
Python
"""
|
||
统计分析API
|
||
"""
|
||
from fastapi import APIRouter, Query, Header, Depends
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
import logging
|
||
|
||
project_root = Path(__file__).parent.parent.parent.parent
|
||
sys.path.insert(0, str(project_root))
|
||
sys.path.insert(0, str(project_root / 'backend'))
|
||
|
||
from database.models import AccountSnapshot, Trade, MarketScan, TradingSignal, Account, TradeStats
|
||
from fastapi import HTTPException
|
||
from api.auth_deps import get_account_id, get_admin_user
|
||
from typing import Dict, Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/admin/dashboard")
|
||
async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)):
|
||
"""获取管理员仪表板数据(所有用户统计)"""
|
||
try:
|
||
accounts = Account.list_all()
|
||
stats = []
|
||
|
||
total_assets = 0
|
||
total_pnl = 0
|
||
active_accounts = 0
|
||
|
||
for acc in accounts:
|
||
aid = acc['id']
|
||
# 获取最新快照
|
||
snapshots = AccountSnapshot.get_recent(1, account_id=aid)
|
||
acc_stat = {
|
||
"id": aid,
|
||
"name": acc['name'],
|
||
"status": acc['status'],
|
||
"total_balance": 0,
|
||
"total_pnl": 0,
|
||
"open_positions": 0
|
||
}
|
||
|
||
if snapshots:
|
||
snap = snapshots[0]
|
||
acc_stat["total_balance"] = snap.get('total_balance', 0)
|
||
acc_stat["total_pnl"] = snap.get('total_pnl', 0)
|
||
acc_stat["open_positions"] = snap.get('open_positions', 0)
|
||
|
||
total_assets += float(acc_stat["total_balance"])
|
||
total_pnl += float(acc_stat["total_pnl"])
|
||
|
||
if acc['status'] == 'active':
|
||
active_accounts += 1
|
||
|
||
stats.append(acc_stat)
|
||
|
||
return {
|
||
"summary": {
|
||
"total_accounts": len(accounts),
|
||
"active_accounts": active_accounts,
|
||
"total_assets_usdt": total_assets,
|
||
"total_pnl_usdt": total_pnl
|
||
},
|
||
"accounts": stats
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
def _aggregate_daily_by_symbol(daily: list) -> list:
|
||
"""将 daily(按 date+symbol)聚合成按 symbol 的汇总。"""
|
||
from collections import defaultdict
|
||
agg = defaultdict(lambda: {"trade_count": 0, "win_count": 0, "loss_count": 0, "net_pnl": 0.0})
|
||
for row in daily:
|
||
sym = (row.get("symbol") or "").strip()
|
||
if not sym:
|
||
continue
|
||
agg[sym]["trade_count"] += int(row.get("trade_count") or 0)
|
||
agg[sym]["win_count"] += int(row.get("win_count") or 0)
|
||
agg[sym]["loss_count"] += int(row.get("loss_count") or 0)
|
||
try:
|
||
agg[sym]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
out = []
|
||
for symbol, v in agg.items():
|
||
tc = v["trade_count"]
|
||
win_rate = (100.0 * v["win_count"] / tc) if tc > 0 else 0.0
|
||
out.append({
|
||
"symbol": symbol,
|
||
"trade_count": tc,
|
||
"win_count": v["win_count"],
|
||
"loss_count": v["loss_count"],
|
||
"net_pnl": round(v["net_pnl"], 4),
|
||
"win_rate_pct": round(win_rate, 1),
|
||
})
|
||
return sorted(out, key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||
|
||
|
||
def _aggregate_hourly(by_hour: list) -> list:
|
||
"""将 by_hour(按 date+hour)聚合成按 hour 0-23 的汇总。"""
|
||
from collections import defaultdict
|
||
agg = defaultdict(lambda: {"trade_count": 0, "net_pnl": 0.0})
|
||
for row in by_hour:
|
||
h = row.get("hour")
|
||
if h is None:
|
||
continue
|
||
try:
|
||
h = int(h)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if 0 <= h <= 23:
|
||
agg[h]["trade_count"] += int(row.get("trade_count") or 0)
|
||
try:
|
||
agg[h]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return [{"hour": h, "trade_count": agg[h]["trade_count"], "net_pnl": round(agg[h]["net_pnl"], 4)} for h in range(24)]
|
||
|
||
|
||
def _build_suggestions(by_symbol: list) -> dict:
|
||
"""
|
||
根据按交易对汇总生成白名单/黑名单建议(仅展示,不自动改策略)。
|
||
- 黑名单:净亏且笔数多 → 建议降权或观察
|
||
- 白名单:净盈且胜率较高、笔数足够 → 可优先考虑
|
||
"""
|
||
blacklist = []
|
||
whitelist = []
|
||
for row in by_symbol:
|
||
sym = row.get("symbol", "")
|
||
tc = int(row.get("trade_count") or 0)
|
||
net_pnl = float(row.get("net_pnl") or 0)
|
||
win_rate = float(row.get("win_rate_pct") or 0)
|
||
if tc < 2:
|
||
continue
|
||
if net_pnl < 0:
|
||
blacklist.append({
|
||
"symbol": sym,
|
||
"trade_count": tc,
|
||
"net_pnl": round(net_pnl, 2),
|
||
"win_rate_pct": round(win_rate, 1),
|
||
"suggestion": "近期净亏且笔数较多,建议降权或观察后再开仓",
|
||
})
|
||
elif net_pnl > 0 and win_rate >= 50:
|
||
whitelist.append({
|
||
"symbol": sym,
|
||
"trade_count": tc,
|
||
"net_pnl": round(net_pnl, 2),
|
||
"win_rate_pct": round(win_rate, 1),
|
||
"suggestion": "近期净盈且胜率尚可,可优先考虑",
|
||
})
|
||
return {"blacklist": blacklist, "whitelist": whitelist}
|
||
|
||
|
||
@router.get("/trade-stats")
|
||
async def get_trade_stats(
|
||
days: int = Query(7, ge=1, le=90),
|
||
account_id: int = Depends(get_account_id),
|
||
):
|
||
"""获取交易统计:最近 N 天按交易对、按小时聚合(来自 trade_stats_daily / trade_stats_time_bucket)。
|
||
返回原始 daily/by_hour、按交易对汇总 by_symbol、按小时汇总 hourly_agg、以及白名单/黑名单建议。"""
|
||
try:
|
||
daily = TradeStats.get_daily_stats(account_id=account_id, days=days)
|
||
by_hour = TradeStats.get_hourly_stats(account_id=account_id, days=days)
|
||
by_symbol = _aggregate_daily_by_symbol(daily)
|
||
hourly_agg = _aggregate_hourly(by_hour)
|
||
suggestions = _build_suggestions(by_symbol)
|
||
return {
|
||
"days": days,
|
||
"daily": daily,
|
||
"by_hour": by_hour,
|
||
"by_symbol": by_symbol,
|
||
"hourly_agg": hourly_agg,
|
||
"suggestions": suggestions,
|
||
}
|
||
except Exception as e:
|
||
logger.exception("get_trade_stats 失败")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/performance")
|
||
async def get_performance_stats(
|
||
days: int = Query(7, ge=1, le=365),
|
||
account_id: int = Depends(get_account_id),
|
||
):
|
||
"""获取性能统计"""
|
||
try:
|
||
# 账户快照
|
||
snapshots = AccountSnapshot.get_recent(days, account_id=account_id)
|
||
|
||
# 交易统计(时间范围 + limit 防内存暴增)
|
||
start_ts = int((datetime.now() - timedelta(days=days)).timestamp())
|
||
end_ts = int(datetime.now().timestamp())
|
||
trades = Trade.get_all(
|
||
start_timestamp=start_ts,
|
||
end_timestamp=end_ts,
|
||
account_id=account_id,
|
||
time_filter="exit",
|
||
limit=10000,
|
||
)
|
||
|
||
return {
|
||
"snapshots": snapshots,
|
||
"trades": trades,
|
||
"period": f"Last {days} days"
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/dashboard")
|
||
async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
||
"""获取仪表板数据"""
|
||
logger.info("=" * 60)
|
||
logger.info(f"获取仪表板数据 - account_id={account_id}")
|
||
logger.info("=" * 60)
|
||
try:
|
||
account_data = None
|
||
account_error = None
|
||
|
||
# 优先请求币安实时余额;失败时(如 -1003 IP 封禁)再回退到数据库快照
|
||
try:
|
||
from api.routes.account import get_realtime_account_data
|
||
account_data = await get_realtime_account_data(account_id=account_id)
|
||
if account_data and account_data.get('total_balance') is not None:
|
||
logger.info("使用币安实时账户数据")
|
||
else:
|
||
account_data = None
|
||
account_error = "实时余额返回为空"
|
||
except Exception as live_err:
|
||
account_error = str(live_err)
|
||
logger.warning(f"获取实时账户数据失败 (account_id={account_id}),回退到数据库快照: {live_err}")
|
||
|
||
# 实时请求失败或无数据时,使用数据库快照
|
||
if not account_data or account_data.get('total_balance') is None:
|
||
try:
|
||
snapshots = AccountSnapshot.get_recent(1, account_id=account_id)
|
||
if snapshots:
|
||
account_data = {
|
||
"total_balance": snapshots[0].get('total_balance', 0),
|
||
"available_balance": snapshots[0].get('available_balance', 0),
|
||
"total_position_value": snapshots[0].get('total_position_value', 0),
|
||
"total_pnl": snapshots[0].get('total_pnl', 0),
|
||
"open_positions": snapshots[0].get('open_positions', 0)
|
||
}
|
||
logger.info("使用数据库快照作为账户数据")
|
||
else:
|
||
if not account_data:
|
||
account_data = {}
|
||
account_data.setdefault("total_balance", 0)
|
||
account_data.setdefault("available_balance", 0)
|
||
account_data.setdefault("total_position_value", 0)
|
||
account_data.setdefault("total_pnl", 0)
|
||
account_data.setdefault("open_positions", 0)
|
||
logger.warning("数据库中没有账户快照数据,仪表板显示 0;交易进程会定期写入快照")
|
||
except Exception as db_error:
|
||
logger.error(f"从数据库获取账户快照失败: {db_error}")
|
||
if not account_data:
|
||
account_data = {
|
||
"total_balance": 0,
|
||
"available_balance": 0,
|
||
"total_position_value": 0,
|
||
"total_pnl": 0,
|
||
"open_positions": 0
|
||
}
|
||
|
||
# 获取持仓数据:优先「币安实时持仓」(含本系统下的挂单),失败时回退到数据库列表
|
||
open_trades = []
|
||
positions_error = None
|
||
try:
|
||
try:
|
||
from api.routes.account import fetch_realtime_positions
|
||
open_trades = await fetch_realtime_positions(account_id)
|
||
except Exception as fetch_err:
|
||
logger.warning(f"获取币安实时持仓失败,回退到数据库列表: {fetch_err}")
|
||
open_trades = []
|
||
if not open_trades:
|
||
db_trades = Trade.get_all(status='open', account_id=account_id, limit=500)
|
||
for trade in db_trades:
|
||
entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0))
|
||
leverage = float(trade.get('leverage', 1))
|
||
pnl = float(trade.get('pnl', 0))
|
||
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt
|
||
pnl_percent = (pnl / margin * 100) if margin > 0 else 0
|
||
open_trades.append({
|
||
**trade,
|
||
'entry_value_usdt': entry_value_usdt,
|
||
'mark_price': trade.get('entry_price', 0),
|
||
'pnl': pnl,
|
||
'pnl_percent': pnl_percent
|
||
})
|
||
try:
|
||
from api.routes.account import fetch_live_positions_pnl
|
||
live_list = await fetch_live_positions_pnl(account_id)
|
||
by_symbol = {p["symbol"]: p for p in live_list}
|
||
for t in open_trades:
|
||
sym = t.get("symbol")
|
||
if sym and sym in by_symbol:
|
||
lp = by_symbol[sym]
|
||
t["mark_price"] = lp.get("mark_price", t.get("entry_price"))
|
||
t["pnl"] = lp.get("pnl", 0)
|
||
t["pnl_percent"] = lp.get("pnl_percent", 0)
|
||
except Exception as merge_err:
|
||
logger.debug(f"合并实时持仓盈亏失败: {merge_err}")
|
||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||
else:
|
||
logger.info(f"使用币安实时持仓作为列表: {len(open_trades)} 个持仓")
|
||
except Exception as db_error:
|
||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||
|
||
# 最近的扫描记录
|
||
recent_scans = []
|
||
try:
|
||
recent_scans = MarketScan.get_recent(10)
|
||
except Exception as e:
|
||
logger.error(f"获取扫描记录失败: {e}")
|
||
|
||
# 最近的信号
|
||
recent_signals = []
|
||
try:
|
||
recent_signals = TradingSignal.get_recent(20)
|
||
except Exception as e:
|
||
logger.error(f"获取交易信号失败: {e}")
|
||
|
||
# 计算仓位占比信息
|
||
position_stats = None
|
||
if account_data:
|
||
try:
|
||
from database.models import TradingConfig
|
||
total_balance = float(account_data.get('total_balance', 0))
|
||
max_total_position_percent = float(TradingConfig.get_value('MAX_TOTAL_POSITION_PERCENT', 0.30, account_id=account_id))
|
||
|
||
# 名义仓位(notional)与保证金占用(margin)是两个口径:
|
||
# - 名义仓位可以 > 100%(高杠杆下非常正常)
|
||
# - MAX_TOTAL_POSITION_PERCENT 在当前系统语义里是“保证金占用比例”
|
||
total_notional_value = float(account_data.get('total_position_value', 0))
|
||
|
||
# 优先使用 account_data 里的 total_margin_value;如果没有则从 open_trades 汇总兜底
|
||
total_margin_value = account_data.get('total_margin_value', None)
|
||
try:
|
||
total_margin_value = float(total_margin_value) if total_margin_value is not None else None
|
||
except Exception:
|
||
total_margin_value = None
|
||
|
||
if total_margin_value is None:
|
||
total_margin_value = 0.0
|
||
for t in open_trades or []:
|
||
try:
|
||
mv = t.get("margin_usdt", None)
|
||
if mv is None:
|
||
# fallback:名义/杠杆
|
||
nv = float(t.get("notional_usdt", 0) or 0)
|
||
lv = float(t.get("leverage", 0) or 0)
|
||
if lv <= 0:
|
||
lv = 1.0
|
||
mv = nv / lv
|
||
total_margin_value += float(mv or 0)
|
||
except Exception:
|
||
continue
|
||
|
||
# 当前仓位占比(保证金口径,与你的 MAX_TOTAL_POSITION_PERCENT 对齐)
|
||
current_margin_percent = (total_margin_value / total_balance * 100) if total_balance > 0 else 0
|
||
# 名义占比(仅用于参考)
|
||
current_notional_percent = (total_notional_value / total_balance * 100) if total_balance > 0 else 0
|
||
|
||
# 最大允许保证金(USDT)
|
||
max_margin_value = total_balance * max_total_position_percent
|
||
|
||
position_stats = {
|
||
# 兼容旧字段:current_position_percent 现在代表“保证金占比”
|
||
"current_position_percent": round(current_margin_percent, 2),
|
||
"current_position_percent_type": "margin",
|
||
"current_notional_percent": round(current_notional_percent, 2),
|
||
"max_position_percent": round(max_total_position_percent * 100, 2),
|
||
# 兼容旧字段:max_position_value/total_position_value 现在代表“保证金(USDT)”
|
||
"max_position_value": round(max_margin_value, 2),
|
||
"total_balance": round(total_balance, 2),
|
||
"total_position_value": round(total_margin_value, 2),
|
||
# 额外信息:名义价值(USDT),用于解释“名义占比可能>100%”
|
||
"total_notional_value": round(total_notional_value, 2),
|
||
"total_margin_value": round(total_margin_value, 2),
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"计算仓位占比信息失败: {e}")
|
||
|
||
# 获取交易配置(用于前端显示止损止盈等参数)
|
||
trading_config = {}
|
||
try:
|
||
from database.models import TradingConfig
|
||
config_keys = ['STOP_LOSS_PERCENT', 'TAKE_PROFIT_PERCENT', 'LEVERAGE', 'MAX_POSITION_PERCENT']
|
||
for key in config_keys:
|
||
config = TradingConfig.get(key, account_id=account_id)
|
||
if config:
|
||
trading_config[key] = {
|
||
'value': TradingConfig._convert_value(config['config_value'], config['config_type']),
|
||
'type': config['config_type']
|
||
}
|
||
except Exception as e:
|
||
logger.debug(f"获取交易配置失败: {e}")
|
||
|
||
# 本系统持仓数 = 数据库 status=open 条数,与下方「当前持仓」列表一致;币安持仓数 = 接口/快照中的 open_positions,可能与币安页面一致
|
||
open_trades_count = len(open_trades)
|
||
result = {
|
||
"account": account_data,
|
||
"open_trades": open_trades,
|
||
"open_trades_count": open_trades_count, # 本系统持仓数,与列表条数一致
|
||
"recent_scans": recent_scans,
|
||
"recent_signals": recent_signals,
|
||
"position_stats": position_stats,
|
||
"trading_config": trading_config, # 添加交易配置
|
||
"_debug": { # 添加调试信息
|
||
"account_id": account_id,
|
||
"account_data_total_balance": account_data.get('total_balance', 'N/A') if account_data else 'N/A',
|
||
"open_trades_count": open_trades_count,
|
||
}
|
||
}
|
||
|
||
# 如果有错误,在响应中包含错误信息(但不影响返回)
|
||
if account_error or positions_error:
|
||
result["warnings"] = {}
|
||
if account_error:
|
||
result["warnings"]["account"] = account_error
|
||
if positions_error:
|
||
result["warnings"]["positions"] = positions_error
|
||
|
||
logger.info(f"返回仪表板数据:")
|
||
logger.info(f" - account_id: {account_id}")
|
||
logger.info(f" - total_balance: {account_data.get('total_balance', 'N/A') if account_data else 'N/A'}")
|
||
logger.info(f" - available_balance: {account_data.get('available_balance', 'N/A') if account_data else 'N/A'}")
|
||
logger.info(f" - open_trades count: {len(open_trades)}")
|
||
if open_trades and len(open_trades) > 0:
|
||
logger.info(f" - 第一个持仓: {open_trades[0].get('symbol', 'N/A')}")
|
||
logger.info("=" * 60)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"获取仪表板数据失败: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=f"获取仪表板数据失败: {str(e)}")
|