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:
parent
007827464a
commit
74c21bea9b
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
37
docs/trades_vs_binance_trades.md
Normal file
37
docs/trades_vs_binance_trades.md
Normal 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 查询并返回列表,与管理员数据管理同源。
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 # 指定账号
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user