diff --git a/. cursorignore b/. cursorignore new file mode 100644 index 0000000..b412595 --- /dev/null +++ b/. cursorignore @@ -0,0 +1,8 @@ +node_modules +__pycache__ +*.pyc +.venv +venv +.git +logs +*.log \ No newline at end of file diff --git a/backend/api/routes/stats.py b/backend/api/routes/stats.py index 1e8cc66..9f653fe 100644 --- a/backend/api/routes/stats.py +++ b/backend/api/routes/stats.py @@ -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), diff --git a/backend/database/models.py b/backend/database/models.py index fa7274c..67774ec 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -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: """账户快照模型""" diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 939b4d9..b33ac98 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -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; +} diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 60771e8..4f48c51 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -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 = () => {
暂无持仓
)} + + {/* 交易统计(最近 7 天):按交易对净盈亏/胜率、按小时净盈亏、白名单/黑名单建议 */} +
+

交易统计(最近 7 天)

+ {tradeStats ? ( + <> +
+

按交易对:净盈亏、胜率

+ {(tradeStats.by_symbol && tradeStats.by_symbol.length > 0) ? ( +
+ + + + + + + + + + + {tradeStats.by_symbol.map((row) => ( + + + + + + + ))} + +
交易对笔数胜率%净盈亏(USDT)
{row.symbol}{row.trade_count}{row.win_rate_pct}= 0 ? 'positive' : 'negative'}> + {Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)} +
+
+ ) : ( +

暂无按交易对统计(需先运行统计聚合脚本或策略内聚合)

+ )} +
+
+

按小时:净盈亏(0–23 时,北京时间)

+ {(tradeStats.hourly_agg && tradeStats.hourly_agg.length > 0) ? ( +
+ + + + + + + + + + {tradeStats.hourly_agg.map((row) => ( + + + + + + ))} + +
小时笔数净盈亏(USDT)
{row.hour}:00{row.trade_count}= 0 ? 'positive' : 'negative'}> + {Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)} +
+
+ ) : ( +

暂无按小时统计

+ )} +
+ {tradeStats.suggestions && (tradeStats.suggestions.blacklist?.length > 0 || tradeStats.suggestions.whitelist?.length > 0) && ( +
+

策略建议(仅供参考,不自动改策略)

+ {tradeStats.suggestions.blacklist?.length > 0 && ( +
+ 建议降权/观察: 近期净亏且笔数较多的标的,可考虑降低仓位或暂时跳过。 +
    + {tradeStats.suggestions.blacklist.map((item) => ( +
  • + {item.symbol} + 笔数 {item.trade_count},净盈亏 {item.net_pnl} USDT,胜率 {item.win_rate_pct}% — {item.suggestion} +
  • + ))} +
+
+ )} + {tradeStats.suggestions.whitelist?.length > 0 && ( +
+ 可优先考虑: 近期净盈且胜率尚可的标的。 +
    + {tradeStats.suggestions.whitelist.map((item) => ( +
  • + {item.symbol} + 笔数 {item.trade_count},净盈亏 +{item.net_pnl} USDT,胜率 {item.win_rate_pct}% +
  • + ))} +
+
+ )} +
+ )} + + ) : ( +

加载统计中…(若长期无数据,请先运行 scripts/aggregate_trade_stats.py 或依赖定时任务更新)

+ )} +
) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 24b613c..a925569 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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) {