feat(stats): 添加交易统计API和前端展示

在后端新增了交易统计API,支持获取最近N天的交易数据,包括按交易对和按小时的聚合统计。同时,前端组件StatsDashboard更新,展示交易统计信息,包括净盈亏、胜率及策略建议。此改动旨在提升交易数据分析能力,帮助用户更好地理解交易表现。
This commit is contained in:
薇薇安 2026-02-26 20:48:04 +08:00
parent e2e7effca2
commit dfe29d70dc
6 changed files with 396 additions and 1 deletions

8
. cursorignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
__pycache__
*.pyc
.venv
venv
.git
logs
*.log

View File

@ -11,7 +11,7 @@ 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
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
@ -72,6 +72,117 @@ async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_use
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),

View File

@ -1485,6 +1485,48 @@ class TradeStats:
if daily or hourly:
TradeStats._upsert_stats(aid, daily or {}, hourly or {})
@staticmethod
def get_daily_stats(account_id: int = None, days: int = 7):
"""查询最近 N 天按交易对聚合统计,供 API/仪表盘展示。表不存在或无数据时返回 []。"""
aid = int(account_id or DEFAULT_ACCOUNT_ID)
try:
rows = db.execute_query(
"""SELECT trade_date, symbol, trade_count, win_count, loss_count,
gross_pnl, net_pnl, total_commission, avg_pnl_per_trade
FROM trade_stats_daily
WHERE account_id = %s AND trade_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
ORDER BY trade_date DESC, net_pnl DESC
LIMIT 500""",
(aid, max(1, int(days))),
)
if not rows:
return []
return [dict(r) for r in rows]
except Exception as e:
logger.debug(f"[TradeStats] get_daily_stats 失败: {e}")
return []
@staticmethod
def get_hourly_stats(account_id: int = None, days: int = 7):
"""查询最近 N 天按小时聚合统计,供 API/仪表盘展示。表不存在或无数据时返回 []。"""
aid = int(account_id or DEFAULT_ACCOUNT_ID)
try:
rows = db.execute_query(
"""SELECT trade_date, hour, trade_count, win_count, loss_count,
gross_pnl, net_pnl, total_commission
FROM trade_stats_time_bucket
WHERE account_id = %s AND trade_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
ORDER BY trade_date DESC, hour ASC
LIMIT 500""",
(aid, max(1, int(days))),
)
if not rows:
return []
return [dict(r) for r in rows]
except Exception as e:
logger.debug(f"[TradeStats] get_hourly_stats 失败: {e}")
return []
class AccountSnapshot:
"""账户快照模型"""

View File

@ -687,3 +687,112 @@
border-radius: 2px;
margin-left: 2px;
}
/* 交易统计卡片 */
.stats-card {
grid-column: 1 / -1;
}
.stats-card h3 {
margin-bottom: 0.75rem;
}
.stats-card h4 {
font-size: 0.95rem;
color: #555;
margin: 1rem 0 0.5rem 0;
}
.stats-card h4:first-of-type {
margin-top: 0;
}
.stats-section {
margin-bottom: 1rem;
}
.stats-table-wrap {
overflow-x: auto;
margin-top: 0.5rem;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.stats-table th,
.stats-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.stats-table th {
font-weight: 600;
color: #495057;
background: #f1f3f5;
}
.stats-table td.positive {
color: #28a745;
font-weight: 500;
}
.stats-table td.negative {
color: #dc3545;
font-weight: 500;
}
.stats-symbol {
font-weight: 600;
color: #2c3e50;
}
.stats-empty {
color: #6c757d;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.hourly-table .stats-table {
max-width: 320px;
}
.suggestions-section h4 {
margin-top: 1.25rem;
}
.suggestion-block {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
}
.suggestion-block.blacklist {
background: #fff5f5;
border-left: 4px solid #dc3545;
}
.suggestion-block.whitelist {
background: #f0fff4;
border-left: 4px solid #28a745;
}
.suggestion-block ul {
margin: 0.5rem 0 0 1rem;
padding: 0;
}
.suggestion-block li {
margin: 0.35rem 0;
line-height: 1.4;
}
.suggestion-symbol {
font-weight: 700;
color: #2c3e50;
margin-right: 0.25rem;
}

View File

@ -8,6 +8,7 @@ import './StatsDashboard.css'
const StatsDashboard = () => {
const accountId = useSelector(selectAccountId) // Redux ID
const [dashboardData, setDashboardData] = useState(null)
const [tradeStats, setTradeStats] = useState(null)
const [loading, setLoading] = useState(true)
const [closingSymbol, setClosingSymbol] = useState(null)
const [sltpSymbol, setSltpSymbol] = useState(null)
@ -55,10 +56,12 @@ const StatsDashboard = () => {
loadDashboard()
loadTradingConfig()
loadTradeStats()
const interval = setInterval(() => {
if (accountId) {
loadDashboard()
loadTradingConfig() //
loadTradeStats()
}
}, 30000) // 30
@ -91,6 +94,17 @@ const StatsDashboard = () => {
}
}
const loadTradeStats = async () => {
if (!accountId) return
try {
const data = await api.getTradeStats(7)
setTradeStats(data)
} catch (error) {
console.error('Failed to load trade stats:', error)
setTradeStats(null)
}
}
const handleClosePosition = async (symbol) => {
if (!window.confirm(`确定要平仓 ${symbol} 吗?`)) {
return
@ -834,6 +848,108 @@ const StatsDashboard = () => {
<div>暂无持仓</div>
)}
</div>
{/* 交易统计(最近 7 天):按交易对净盈亏/胜率、按小时净盈亏、白名单/黑名单建议 */}
<div className="dashboard-card stats-card">
<h3>交易统计最近 7 </h3>
{tradeStats ? (
<>
<div className="stats-section">
<h4>按交易对净盈亏胜率</h4>
{(tradeStats.by_symbol && tradeStats.by_symbol.length > 0) ? (
<div className="stats-table-wrap">
<table className="stats-table">
<thead>
<tr>
<th>交易对</th>
<th>笔数</th>
<th>胜率%</th>
<th>净盈亏(USDT)</th>
</tr>
</thead>
<tbody>
{tradeStats.by_symbol.map((row) => (
<tr key={row.symbol}>
<td className="stats-symbol">{row.symbol}</td>
<td>{row.trade_count}</td>
<td>{row.win_rate_pct}</td>
<td className={Number(row.net_pnl) >= 0 ? 'positive' : 'negative'}>
{Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="stats-empty">暂无按交易对统计需先运行统计聚合脚本或策略内聚合</p>
)}
</div>
<div className="stats-section">
<h4>按小时净盈亏023 北京时间</h4>
{(tradeStats.hourly_agg && tradeStats.hourly_agg.length > 0) ? (
<div className="stats-table-wrap hourly-table">
<table className="stats-table">
<thead>
<tr>
<th>小时</th>
<th>笔数</th>
<th>净盈亏(USDT)</th>
</tr>
</thead>
<tbody>
{tradeStats.hourly_agg.map((row) => (
<tr key={row.hour}>
<td>{row.hour}:00</td>
<td>{row.trade_count}</td>
<td className={Number(row.net_pnl) >= 0 ? 'positive' : 'negative'}>
{Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="stats-empty">暂无按小时统计</p>
)}
</div>
{tradeStats.suggestions && (tradeStats.suggestions.blacklist?.length > 0 || tradeStats.suggestions.whitelist?.length > 0) && (
<div className="stats-section suggestions-section">
<h4>策略建议仅供参考不自动改策略</h4>
{tradeStats.suggestions.blacklist?.length > 0 && (
<div className="suggestion-block blacklist">
<strong>建议降权/观察</strong> 近期净亏且笔数较多的标的可考虑降低仓位或暂时跳过
<ul>
{tradeStats.suggestions.blacklist.map((item) => (
<li key={item.symbol}>
<span className="suggestion-symbol">{item.symbol}</span>
笔数 {item.trade_count}净盈亏 {item.net_pnl} USDT胜率 {item.win_rate_pct}% {item.suggestion}
</li>
))}
</ul>
</div>
)}
{tradeStats.suggestions.whitelist?.length > 0 && (
<div className="suggestion-block whitelist">
<strong>可优先考虑</strong> 近期净盈且胜率尚可的标的
<ul>
{tradeStats.suggestions.whitelist.map((item) => (
<li key={item.symbol}>
<span className="suggestion-symbol">{item.symbol}</span>
笔数 {item.trade_count}净盈亏 +{item.net_pnl} USDT胜率 {item.win_rate_pct}%
</li>
))}
</ul>
</div>
)}
</div>
)}
</>
) : (
<p className="stats-empty">加载统计中若长期无数据请先运行 scripts/aggregate_trade_stats.py 或依赖定时任务更新</p>
)}
</div>
</div>
</div>
)

View File

@ -458,6 +458,15 @@ export const api = {
return response.json();
},
getTradeStats: async (days = 7) => {
const response = await fetch(buildUrl(`/api/stats/trade-stats?days=${days}`), { headers: withAccountHeaders() });
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取交易统计失败' }));
throw new Error(error.detail || '获取交易统计失败');
}
return response.json();
},
getAdminDashboard: async () => {
const response = await fetch(buildUrl('/api/stats/admin/dashboard'), { headers: withAuthHeaders() });
if (!response.ok) {