feat(stats): 添加交易统计API和前端展示
在后端新增了交易统计API,支持获取最近N天的交易数据,包括按交易对和按小时的聚合统计。同时,前端组件StatsDashboard更新,展示交易统计信息,包括净盈亏、胜率及策略建议。此改动旨在提升交易数据分析能力,帮助用户更好地理解交易表现。
This commit is contained in:
parent
e2e7effca2
commit
dfe29d70dc
8
. cursorignore
Normal file
8
. cursorignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
.git
|
||||
logs
|
||||
*.log
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""账户快照模型"""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>按小时:净盈亏(0–23 时,北京时间)</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user