feat(data_management): 增强交易数据统计与推算功能

在后端 API 中新增按小时和星期的交易统计功能,优化 `_compute_binance_stats` 函数以支持更细致的统计分析。同时,新增 `_enrich_trades_with_derived` 函数,补充交易记录的推算字段,包括入场价、交易小时和星期,提升策略分析的便利性。前端 `DataManagement` 组件更新,展示按小时和星期的统计信息,增强用户对交易数据的可视化理解。
This commit is contained in:
薇薇安 2026-02-22 11:16:33 +08:00
parent 1e478c8428
commit 3b0526f392
5 changed files with 93 additions and 7 deletions

View File

@ -116,6 +116,24 @@ def _compute_binance_stats(data: list, data_type: str) -> dict:
} }
for k, v in sorted(by_symbol.items()) for k, v in sorted(by_symbol.items())
} }
by_hour = {}
by_weekday = {}
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
for r in valid:
t = r.get("time") or r.get("trade_time") or 0
if t:
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
h = dt.hour
wd = dt.weekday()
by_hour[h] = by_hour.get(h, {"count": 0, "pnl": 0.0})
by_hour[h]["count"] += 1
by_hour[h]["pnl"] += float(r.get("realizedPnl") or 0)
by_weekday[wd] = by_weekday.get(wd, {"count": 0, "pnl": 0.0})
by_weekday[wd]["count"] += 1
by_weekday[wd]["pnl"] += float(r.get("realizedPnl") or 0)
stats["by_hour"] = {str(k): {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_hour.items())}
stats["by_weekday"] = {weekday_names[k]: {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_weekday.items())}
else: else:
by_status = {} by_status = {}
by_type = {} by_type = {}
@ -223,6 +241,32 @@ async def query_db_trades(
return {"total": len(out), "trades": out} return {"total": len(out), "trades": out}
def _enrich_trades_with_derived(trades: list) -> list:
"""补充推算字段:入场价、交易小时、星期,便于策略分析"""
result = []
for r in trades:
out = dict(r)
t = r.get("time") or 0
if t:
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
out["_trade_hour"] = dt.hour
out["_trade_weekday"] = dt.weekday()
out["_trade_date"] = dt.strftime("%Y-%m-%d")
pnl = float(r.get("realizedPnl") or 0)
qty = float(r.get("qty") or 0)
price = float(r.get("price") or 0)
side = (r.get("side") or "").upper()
if qty and pnl != 0 and side:
if side == "SELL":
out["_approx_entry_price"] = round(price - pnl / qty, 8)
else:
out["_approx_entry_price"] = round(price + pnl / qty, 8)
else:
out["_approx_entry_price"] = None
result.append(out)
return result
def _binance_row_to_api_format(row: dict, data_type: str) -> dict: def _binance_row_to_api_format(row: dict, data_type: str) -> dict:
"""将 DB 行转换为前端/导出期望的币安 API 格式""" """将 DB 行转换为前端/导出期望的币安 API 格式"""
if data_type == "trades": if data_type == "trades":
@ -315,6 +359,8 @@ async def query_binance_data_from_db(
raise HTTPException(status_code=500, detail=f"查询失败(请确认已执行 add_binance_sync_tables.sql 并运行过同步脚本): {e}") raise HTTPException(status_code=500, detail=f"查询失败(请确认已执行 add_binance_sync_tables.sql 并运行过同步脚本): {e}")
all_data = [_binance_row_to_api_format(dict(r), data_type) for r in (rows or [])] all_data = [_binance_row_to_api_format(dict(r), data_type) for r in (rows or [])]
if data_type == "trades":
all_data = _enrich_trades_with_derived(all_data)
symbols_queried = len(symbol_list) if symbol_list else len({(r or {}).get("symbol") for r in (rows or []) if (r or {}).get("symbol")}) symbols_queried = len(symbol_list) if symbol_list else len({(r or {}).get("symbol") for r in (rows or []) if (r or {}).get("symbol")})
stats = _compute_binance_stats(all_data, data_type) stats = _compute_binance_stats(all_data, data_type)

View File

@ -105,6 +105,7 @@
.dm-stat-profit { color: #059669; font-weight: 600; } .dm-stat-profit { color: #059669; font-weight: 600; }
.dm-stat-loss { color: #dc2626; font-weight: 600; } .dm-stat-loss { color: #dc2626; font-weight: 600; }
.dm-stat-symbols { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; } .dm-stat-symbols { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
.dm-stat-hours { max-height: 120px; overflow-y: auto; }
.dm-stat-sym { background: #fff; padding: 2px 8px; border-radius: 4px; } .dm-stat-sym { background: #fff; padding: 2px 8px; border-radius: 4px; }
.dm-table .dm-profit { color: #059669; } .dm-table .dm-profit { color: #059669; }
.dm-table .dm-loss { color: #dc2626; } .dm-table .dm-loss { color: #dc2626; }

View File

@ -301,6 +301,26 @@ export default function DataManagement() {
</div> </div>
</div> </div>
)} )}
{bnStats.by_weekday && Object.keys(bnStats.by_weekday).length > 0 && (
<div className="dm-stat-item dm-stat-wide">
<span className="dm-stat-label">按星期</span>
<div className="dm-stat-symbols">
{Object.entries(bnStats.by_weekday).map(([day, v]) => (
<span key={day} className="dm-stat-sym">{day}: {v.count}, 盈亏{v.pnl}</span>
))}
</div>
</div>
)}
{bnStats.by_hour && Object.keys(bnStats.by_hour).length > 0 && (
<div className="dm-stat-item dm-stat-wide">
<span className="dm-stat-label">按小时(北京)</span>
<div className="dm-stat-symbols dm-stat-hours">
{Object.entries(bnStats.by_hour).map(([h, v]) => (
<span key={h} className="dm-stat-sym">{h}: {v.count}, 盈亏{v.pnl}</span>
))}
</div>
</div>
)}
</> </>
)} )}
{!isTrades && (bnStats.by_status || bnStats.filled_count !== undefined) && ( {!isTrades && (bnStats.by_status || bnStats.filled_count !== undefined) && (
@ -349,11 +369,13 @@ export default function DataManagement() {
{isTrades ? ( {isTrades ? (
<> <>
<th>positionSide</th> <th>positionSide</th>
<th>price</th> <th>出场价</th>
<th>推算入场价</th>
<th>qty</th> <th>qty</th>
<th>quoteQty</th> <th>quoteQty</th>
<th>realizedPnl</th> <th>realizedPnl</th>
<th>commission</th> <th>commission</th>
<th>小时</th>
<th>buyer</th> <th>buyer</th>
<th>maker</th> <th>maker</th>
</> </>
@ -383,10 +405,12 @@ export default function DataManagement() {
<> <>
<td>{r.positionSide || '-'}</td> <td>{r.positionSide || '-'}</td>
<td>{r.price}</td> <td>{r.price}</td>
<td>{r._approx_entry_price != null ? r._approx_entry_price : '-'}</td>
<td>{r.qty}</td> <td>{r.qty}</td>
<td>{r.quoteQty}</td> <td>{r.quoteQty}</td>
<td className={(r.realizedPnl || 0) >= 0 ? 'dm-profit' : 'dm-loss'}>{r.realizedPnl}</td> <td className={(r.realizedPnl || 0) >= 0 ? 'dm-profit' : 'dm-loss'}>{r.realizedPnl}</td>
<td>{r.commission}</td> <td>{r.commission}</td>
<td>{r._trade_hour != null ? `${r._trade_hour}` : '-'}</td>
<td>{r.buyer ? '买' : '卖'}</td> <td>{r.buyer ? '买' : '卖'}</td>
<td>{r.maker ? '是' : '否'}</td> <td>{r.maker ? '是' : '否'}</td>
</> </>

View File

@ -21,6 +21,9 @@ python scripts/sync_binance_orders.py -a 2
# 拉取最近 12 小时 # 拉取最近 12 小时
python scripts/sync_binance_orders.py --hours 12 python scripts/sync_binance_orders.py --hours 12
# 多账号时减少账号间隔(默认 90 秒,避免限频)
python scripts/sync_binance_orders.py --delay-between-accounts 60
``` ```
## 3. Crontab 配置示例 ## 3. Crontab 配置示例
@ -37,6 +40,12 @@ python scripts/sync_binance_orders.py --hours 12
0 */6 * * * cd /path/to/auto_trade_sys && /path/to/.venv/bin/python scripts/sync_binance_orders.py >> logs/sync_binance.log 2>&1 0 */6 * * * cd /path/to/auto_trade_sys && /path/to/.venv/bin/python scripts/sync_binance_orders.py >> logs/sync_binance.log 2>&1
``` ```
## 4. 数据管理 ## 4. 限频说明
- 多账号时每个账号之间默认等待 90 秒,可用 `--delay-between-accounts` 调整
- 单账号内已降低并发Semaphore 2和请求间隔减少触发 "Way too many requests" 封 IP
- 若已被封,需等待提示时间后重试;建议 crontab 间隔不少于 3 小时
## 5. 数据管理
管理后台「数据管理」-「币安订单/成交查询」从 DB 读取,不再调用币安 API。 管理后台「数据管理」-「币安订单/成交查询」从 DB 读取,不再调用币安 API。

View File

@ -46,7 +46,7 @@ async def _get_active_symbols(client, start_ms: int, end_ms: int) -> list:
current_end = oldest - 1 current_end = oldest - 1
if current_end < start_ms: if current_end < start_ms:
break break
await asyncio.sleep(0.15) await asyncio.sleep(0.4)
return sorted(symbols) return sorted(symbols)
except Exception: except Exception:
return [] return []
@ -79,7 +79,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple:
if not sym_list: if not sym_list:
return 0, 0, "无法获取交易对列表" return 0, 0, "无法获取交易对列表"
sem = asyncio.Semaphore(5) sem = asyncio.Semaphore(2)
async def _fetch_trades(sym): async def _fetch_trades(sym):
async with sem: async with sem:
@ -95,7 +95,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple:
except Exception: except Exception:
return [] return []
finally: finally:
await asyncio.sleep(0.12) await asyncio.sleep(0.35)
async def _fetch_orders(sym): async def _fetch_orders(sym):
async with sem: async with sem:
@ -111,7 +111,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple:
except Exception: except Exception:
return [] return []
finally: finally:
await asyncio.sleep(0.12) await asyncio.sleep(0.35)
trades_chunks = await asyncio.gather(*[_fetch_trades(s) for s in sym_list]) trades_chunks = await asyncio.gather(*[_fetch_trades(s) for s in sym_list])
orders_chunks = await asyncio.gather(*[_fetch_orders(s) for s in sym_list]) orders_chunks = await asyncio.gather(*[_fetch_orders(s) for s in sym_list])
@ -209,8 +209,10 @@ def main():
parser = argparse.ArgumentParser(description="同步币安订单/成交到 DB供 crontab 定时执行)") parser = argparse.ArgumentParser(description="同步币安订单/成交到 DB供 crontab 定时执行)")
parser.add_argument("-a", "--account", type=int, default=None, help="指定账号 ID不传则同步所有有效账号") parser.add_argument("-a", "--account", type=int, default=None, help="指定账号 ID不传则同步所有有效账号")
parser.add_argument("--hours", type=int, default=6, help="拉取最近 N 小时,默认 6") parser.add_argument("--hours", type=int, default=6, help="拉取最近 N 小时,默认 6")
parser.add_argument("--delay-between-accounts", type=int, default=90, help="多账号时,每个账号之间等待秒数,默认 90避免触发币安限频")
args = parser.parse_args() args = parser.parse_args()
hours = args.hours hours = args.hours
delay_between = max(0, args.delay_between_accounts)
try: try:
from database.models import Account from database.models import Account
@ -245,7 +247,11 @@ def main():
sys.stdout.flush() sys.stdout.flush()
async def run_all(): async def run_all():
for acc in to_sync: for i, acc in enumerate(to_sync):
if i > 0 and delay_between > 0:
print(f" 等待 {delay_between} 秒后同步下一账号(避免限频)...")
sys.stdout.flush()
await asyncio.sleep(delay_between)
aid = acc["id"] aid = acc["id"]
name = acc.get("name") or f"账号{aid}" name = acc.get("name") or f"账号{aid}"
tr, ord_cnt, err = await sync_account(aid, hours) tr, ord_cnt, err = await sync_account(aid, hours)