feat(stats): Update admin dashboard stats to include recent 30-day account snapshots and enhance trade data source options

Modified the admin dashboard statistics to retrieve account snapshots from the last 30 days instead of just 1 day, ensuring more comprehensive data. Additionally, introduced a new data source option for trades, allowing users to select between 'binance' and 'local' records, with appropriate handling for each source. Updated the frontend components to reflect these changes and improve user experience in managing trade data.
This commit is contained in:
薇薇安 2026-03-01 12:58:49 +08:00
parent 007827464a
commit 74c21bea9b
8 changed files with 220 additions and 25 deletions

View File

@ -30,7 +30,8 @@ async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_use
active_accounts = 0
for acc in accounts:
aid = acc["id"]
snapshots = AccountSnapshot.get_recent(1, account_id=aid)
# 取最近 30 天内的快照,再取最新一条,避免“仅 1 天”导致无数据
snapshots = AccountSnapshot.get_recent(30, account_id=aid)
acc_stat = {
"id": aid,
"name": acc["name"],

View File

@ -18,6 +18,7 @@ sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / 'backend'))
from database.models import Trade, Account
from database.connection import db
from api.auth_deps import get_account_id
router = APIRouter()
@ -83,6 +84,7 @@ async def get_trades(
include_sync: bool = Query(True, description="是否包含 entry_reason 为 sync_recovered 的补建/同步单(默认包含,便于订单记录与币安一致)"),
reconciled_only: bool = Query(True, description="仅返回可对账记录(有 entry_order_id已平仓的还有 exit_order_id与币安一致统计真实"),
limit: int = Query(100, ge=1, le=1000, description="返回记录数限制"),
source: Optional[str] = Query(None, description="数据源: 'binance' 从 binance_trades 查(与交易所一致,需先运行 sync_binance_orders.py 同步)"),
):
"""
获取交易记录
@ -126,6 +128,59 @@ async def get_trades(
except ValueError:
logger.warning(f"无效的结束日期格式: {end_date}")
# 数据源币安成交binance_trades与管理员数据管理一致更可靠
if (source or "").strip().lower() == "binance":
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
end_ts = end_timestamp or int(now.timestamp())
start_ts = start_timestamp or (end_ts - 7 * 24 * 3600)
start_ms = start_ts * 1000
end_ms = end_ts * 1000
try:
q = """SELECT * FROM binance_trades
WHERE account_id = %s AND trade_time >= %s AND trade_time <= %s"""
params = [account_id, start_ms, end_ms]
if symbol:
q += " AND symbol = %s"
params.append(symbol.strip().upper())
q += " ORDER BY trade_time DESC LIMIT %s"
params.append(min(limit, 1000))
rows = db.execute_query(q, params)
except Exception as e:
logger.warning(f"查询 binance_trades 失败(请确认已执行 add_binance_sync_tables.sql 并运行过 sync_binance_orders.py: {e}")
rows = []
out = []
for r in (rows or []):
row = dict(r)
out.append({
"source": "binance",
"trade_time": row.get("trade_time"),
"symbol": row.get("symbol") or "",
"side": row.get("side") or "",
"price": float(row.get("price") or 0),
"qty": float(row.get("qty") or 0),
"quote_qty": float(row.get("quote_qty") or 0),
"realized_pnl": float(row.get("realized_pnl") or 0),
"commission": float(row.get("commission") or 0),
"commission_asset": row.get("commission_asset") or "",
"order_id": row.get("order_id"),
"trade_id": row.get("trade_id"),
"maker": bool(row.get("maker")),
})
return {
"total": len(out),
"trades": out,
"source": "binance",
"filters": {
"start_timestamp": start_ts,
"end_timestamp": end_ts,
"start_date": datetime.fromtimestamp(start_ts).strftime('%Y-%m-%d %H:%M:%S'),
"end_date": datetime.fromtimestamp(end_ts).strftime('%Y-%m-%d %H:%M:%S'),
"period": period,
"symbol": symbol,
},
}
trades = Trade.get_all(
start_timestamp, end_timestamp, symbol, status, trade_type, exit_reason,
account_id=account_id, time_filter=time_filter or "exit",

View File

@ -0,0 +1,37 @@
# trades 表 vs binance_trades 表:角色与使用建议
## 两张表的角色
### trades 表(本地交易记录)
- **数据来源**:本系统在**开仓/平仓、补建、同步**时写入或更新。
- **粒度**:一笔「回合」一条记录(开仓 → 平仓),含 entry_price、exit_price、entry_time、exit_time、pnl、exit_reason、entry_order_id、exit_order_id 等。
- **主要用途****主要支持交易与监控相关**——实盘运行时的持仓状态、风控、冷却、对账、pending 补全等;用户端「交易记录」可选「本地记录」查看本系统回合,但**查询/分析订单建议以 binance_trades 为准**。
### binance_trades 表(币安成交同步)
- **数据来源**:定时任务 `scripts/sync_binance_orders.py` 从币安 API 拉取 **userTrades成交** 入库。
- **同步节奏与延时**:脚本按 **每 3 小时的第 0 分钟** 执行crontab 示例:`0 */3 * * *`),存在一定延时性,页面看到的「币安成交」最多可能滞后约 3 小时;如需最新可手动执行一次脚本。
- **粒度****一笔成交一条记录**(含 trade_id、order_id、symbol、side、price、qty、realized_pnl、commission、trade_time 等)。
- **主要用途**
1. **管理员「数据管理」**:按账号/时间/交易对查询已同步的币安成交,做分析与导出。
2. **统计与分析**`TradeStats` 聚合7 天统计、按交易对、按小时等)**优先使用 binance_trades**,无数据时再回退到 trades。
## 结论与建议
1. **分析、统计、查询「订单/成交」**
**以 binance_trades 为准即可**。数据与交易所一致,更适合做盈亏分析、策略回看。系统已按「优先 binance_trades无则 trades」实现。
2. **用户端「交易记录」为何经常没数据?**
因为当前只查 **trades**。若实盘不全在本系统跑、或同步/补建不完整trades 会少或为空。
**建议**:用户端「交易记录」**默认从 binance_trades 查**(按当前账号 + 时间范围),与管理员在数据管理里看到的来源一致,更可靠。
3. **trades 表是否还有用?**
**有用,但角色已明确为「主要支持交易、监控」**开平仓状态、风控、冷却、对账、pending 补全等仍依赖 trades**订单查询与盈亏分析**以 binance_trades 为主。
## 实施情况
- 用户端「交易记录」支持数据源切换:
- **币安成交binance_trades**:默认。按当前账号从 DB 已同步的 binance_trades 查询,展示成交明细(时间、方向、价格、数量、已实现盈亏、手续费等)。需先在服务器运行 `scripts/sync_binance_orders.py` 同步。**数据由定时任务每 3 小时的第 0 分钟同步,存在一定延时(最多约 3 小时)。**
- **本地记录trades**:保留原有逻辑,展示回合级记录(开/平仓时间、入场/出场价、平仓原因等)。
- 后端:`GET /api/trades?source=binance&period=7d&symbol=...` 使用当前账号从 binance_trades 查询并返回列表,与管理员数据管理同源。

View File

@ -84,7 +84,6 @@ function App() {
)}
{isAdmin && (
<>
<Link to="/">仪表板</Link>
<div className="nav-dropdown">
<span className="nav-dropdown-trigger">管理中心</span>
<div className="nav-dropdown-menu">

View File

@ -201,7 +201,7 @@ export default function DataManagement() {
)}
</section>
{/* 2. 币安订单/成交(从 DB 查询,由定时任务 scripts/sync_binance_orders.py 同步) */}
{/* 2. 币安订单/成交(从 DB 查询,由定时任务 scripts/sync_binance_orders.py 每 3 小时同步,存在一定延时 */}
<section className="dm-section">
<h3>币安订单/成交查询</h3>
<div className="dm-controls">
@ -243,7 +243,7 @@ export default function DataManagement() {
<div className="dm-result">
<div className="dm-result-meta">
{bnResult.total} · 查询 {bnResult.symbols_queried ?? '-'} 个交易对
{bnResult.source === 'db' && <span className="dm-source-tag">来自 DB定时同步</span>}
{bnResult.source === 'db' && <span className="dm-source-tag" title="定时任务每 3 小时的第 0 分钟同步,存在一定延时">来自 DB定时同步</span>}
<button className="btn btn-sm" onClick={exportBinance}>导出 JSON含统计与全部字段</button>
</div>

View File

@ -26,16 +26,17 @@ const TradeList = () => {
const [syncResult, setSyncResult] = useState(null) //
const [syncDays, setSyncDays] = useState(7) //
const [syncAllSymbols, setSyncAllSymbols] = useState(false) //
const [dataSource, setDataSource] = useState('binance') // 'binance' | 'local'
useEffect(() => {
loadData()
}, [accountId, reconciledOnly, timeFilter, period, useCustomDate, startDate, endDate, symbol, status, tradeType, exitReason]) //
}, [accountId, dataSource, reconciledOnly, timeFilter, period, useCustomDate, startDate, endDate, symbol, status, tradeType, exitReason])
const loadData = async () => {
setLoading(true)
try {
const params = {
limit: 100
limit: 500
}
// 使
@ -48,18 +49,36 @@ const TradeList = () => {
}
if (symbol) params.symbol = symbol
if (status) params.status = status
if (tradeType) params.trade_type = tradeType
if (exitReason) params.exit_reason = exitReason
params.reconciled_only = reconciledOnly
params.time_filter = timeFilter || 'exit'
if (dataSource === 'binance') {
params.source = 'binance'
} else {
if (status) params.status = status
if (tradeType) params.trade_type = tradeType
if (exitReason) params.exit_reason = exitReason
params.reconciled_only = reconciledOnly
params.time_filter = timeFilter || 'exit'
}
const [tradesData, statsData] = await Promise.all([
api.getTrades(params),
api.getTradeStats(params)
])
setTrades(tradesData.trades || [])
setStats(statsData)
const tradesPromise = api.getTrades(params)
const statsPromise = dataSource === 'local' ? api.getTradeStats({ ...params }) : Promise.resolve(null)
const [tradesRes, statsData] = await Promise.all([tradesPromise, statsPromise])
const tradesList = tradesRes.trades || []
setTrades(tradesList)
if (dataSource === 'binance') {
const totalPnl = tradesList.reduce((s, t) => s + (Number(t.realized_pnl) || 0), 0)
const totalCommission = tradesList.reduce((s, t) => s + (Number(t.commission) || 0), 0)
setStats({
total_trades: tradesList.length,
total_pnl: totalPnl - totalCommission,
total_realized_pnl: totalPnl,
total_commission: totalCommission,
source: 'binance',
})
} else {
setStats(statsData)
}
} catch (error) {
console.error('Failed to load trades:', error)
} finally {
@ -141,7 +160,21 @@ const TradeList = () => {
return
}
const exportData = trades.map(trade => {
const isBinance = trades[0] && trades[0].source === 'binance'
const exportData = isBinance
? trades.map(t => ({
成交时间: t.trade_time ? new Date(t.trade_time).toISOString() : '',
交易对: t.symbol,
方向: t.side,
价格: t.price,
数量: t.qty,
已实现盈亏: t.realized_pnl,
手续费: t.commission,
手续费资产: t.commission_asset || 'USDT',
订单号: t.order_id,
成交ID: t.trade_id,
}))
: trades.map(trade => {
const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null
? parseFloat(trade.notional_usdt)
: (trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null
@ -361,7 +394,9 @@ const TradeList = () => {
<div>
<h2 style={{ margin: 0 }}>交易记录</h2>
<p style={{ color: '#666', fontSize: '14px', marginTop: '5px', marginBottom: '0' }}>
说明每条记录代表一笔完整的交易开仓+平仓统计总盈亏时每条记录只计算一次默认仅可对账只显示有开仓/平仓订单号的记录统计与币安一致时间依据按平仓时间=今日平仓+今日开仓未平仓按开仓时间=实际入场时间适合策略分析按创建时间=记录写入 DB 时间
{dataSource === 'binance'
? '数据源币安成交binance_trades 表,与交易所一致)。由定时任务每 3 小时的第 0 分钟同步,存在一定延时(最多约 3 小时);需先在服务器运行 scripts/sync_binance_orders.py。'
: '说明:每条记录代表一笔完整的交易(开仓+平仓),统计总盈亏时每条记录只计算一次。默认「仅可对账」:只显示有开仓/平仓订单号的记录,统计与币安一致。时间依据:按平仓时间=今日平仓+今日开仓未平仓;按开仓时间=实际入场时间,适合策略分析;按创建时间=记录写入 DB 时间。'}
</p>
</div>
<div style={{
@ -422,6 +457,25 @@ const TradeList = () => {
{/* 筛选面板 */}
<div className="filter-panel">
<div className="filter-section">
<label>数据源</label>
<div className="period-buttons">
<button
className={dataSource === 'binance' ? 'active' : ''}
onClick={() => setDataSource('binance')}
title="从已同步的币安成交表查询,与交易所一致(需先运行 sync_binance_orders.py"
>
币安成交
</button>
<button
className={dataSource === 'local' ? 'active' : ''}
onClick={() => setDataSource('local')}
title="从本系统 trades 表查询(开仓/平仓回合记录)"
>
本地记录
</button>
</div>
</div>
<div className="filter-section">
<label>时间依据</label>
<div className="period-buttons">
@ -650,7 +704,7 @@ const TradeList = () => {
</div>
{
stats && (
stats && stats.source !== 'binance' && (
<div className="stats-section">
{/* 卡片式统计(始终显示) */}
<div className="stats-summary">
@ -818,7 +872,6 @@ const TradeList = () => {
</tbody>
</table>
</div>
)}
</div>
)
}
@ -827,12 +880,61 @@ const TradeList = () => {
trades.length === 0 ? (
<div className="no-data">
<div>暂无交易记录</div>
{reconciledOnly && (
{dataSource === 'binance' && (
<p style={{ marginTop: '10px', fontSize: '13px', color: '#666', maxWidth: '480px' }}>
暂无已同步的币安成交记录数据由定时任务每 3 小时同步存在一定延时请先在服务器运行 scripts/sync_binance_orders.py 或稍后刷新也可切换为本地记录查看本系统 trades
</p>
)}
{dataSource === 'local' && reconciledOnly && (
<p style={{ marginTop: '10px', fontSize: '13px', color: '#666', maxWidth: '420px' }}>
若币安今日有订单但此处为空可先点击右上角同步订单补全开仓/平仓订单号或取消勾选仅可对账查看全部记录
</p>
)}
</div>
) : (trades[0] && trades[0].source === 'binance') ? (
<>
<div className="table-wrapper">
<table className="trades-table">
<thead>
<tr>
<th>成交时间</th>
<th>交易对</th>
<th>方向</th>
<th>价格</th>
<th>数量</th>
<th>已实现盈亏</th>
<th>手续费</th>
<th>订单号</th>
</tr>
</thead>
<tbody>
{trades.map((t, i) => {
const ts = t.trade_time
const timeStr = ts ? new Date(ts).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'
const pnl = Number(t.realized_pnl) || 0
const comm = Number(t.commission) || 0
return (
<tr key={t.trade_id || t.order_id || i}>
<td style={{ fontSize: '13px' }}>{timeStr}</td>
<td>{t.symbol}</td>
<td><span className={`side-badge ${(t.side || '').toUpperCase() === 'BUY' ? 'buy' : 'sell'}`}>{(t.side || '').toUpperCase() === 'BUY' ? '买' : '卖'}</span></td>
<td>{Number(t.price).toFixed(4)}</td>
<td>{Number(t.qty).toFixed(4)}</td>
<td className={pnl >= 0 ? 'positive' : 'negative'}>{pnl.toFixed(2)}</td>
<td>{comm.toFixed(4)} {t.commission_asset || 'USDT'}</td>
<td className="order-id" style={{ fontSize: '12px' }}>{t.order_id ?? '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{stats && stats.source === 'binance' && (
<div className="stats-summary" style={{ marginTop: '12px', padding: '10px', background: '#f5f5f5', borderRadius: '6px', fontSize: '14px' }}>
{stats.total_trades} 已实现盈亏 {Number(stats.total_realized_pnl || 0).toFixed(2)} USDT手续费 {Number(stats.total_commission || 0).toFixed(2)} USDT净盈亏 {Number(stats.total_pnl || 0).toFixed(2)} USDT
</div>
)}
</>
) : (
<>
{/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}

View File

@ -28,12 +28,14 @@ python scripts/sync_binance_orders.py --delay-between-accounts 60
## 3. Crontab 配置示例
每 3 小时执行一次(与 6 小时拉取窗口重叠,便于去重):
每 3 小时的第 0 分钟执行一次(与 6 小时拉取窗口重叠,便于去重):
```cron
0 */3 * * * cd /path/to/auto_trade_sys && /path/to/.venv/bin/python scripts/sync_binance_orders.py >> logs/sync_binance.log 2>&1
```
**延时说明**:因定时任务为每 3 小时跑一次,页面或统计里看到的「币安成交」数据存在一定延时,最多可能滞后约 3 小时;如需最新可手动执行一次脚本。
或每 6 小时:
```cron

View File

@ -1,8 +1,7 @@
#!/usr/bin/env python3
"""
定时任务从币安拉取各账号最近 6 小时的订单/成交数据去重写入 DB
crontab 定时执行: 0 */3 * * * cd /path/to/project && python scripts/sync_binance_orders.py
crontab 定时执行建议每 3 小时的第 0 分钟执行 0 */3 * * *存在一定延时性
用法:
python scripts/sync_binance_orders.py # 所有有效账号,最近 6 小时
python scripts/sync_binance_orders.py -a 2 # 指定账号