diff --git a/backend/api/routes/data_management.py b/backend/api/routes/data_management.py index 94b7307..2cd4b38 100644 --- a/backend/api/routes/data_management.py +++ b/backend/api/routes/data_management.py @@ -116,6 +116,24 @@ def _compute_binance_stats(data: list, data_type: str) -> dict: } 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: by_status = {} by_type = {} @@ -223,6 +241,32 @@ async def query_db_trades( 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: """将 DB 行转换为前端/导出期望的币安 API 格式""" 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}") 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")}) stats = _compute_binance_stats(all_data, data_type) diff --git a/frontend/src/components/DataManagement.css b/frontend/src/components/DataManagement.css index a2bf9d2..8f8b241 100644 --- a/frontend/src/components/DataManagement.css +++ b/frontend/src/components/DataManagement.css @@ -105,6 +105,7 @@ .dm-stat-profit { color: #059669; 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-hours { max-height: 120px; overflow-y: auto; } .dm-stat-sym { background: #fff; padding: 2px 8px; border-radius: 4px; } .dm-table .dm-profit { color: #059669; } .dm-table .dm-loss { color: #dc2626; } diff --git a/frontend/src/components/DataManagement.jsx b/frontend/src/components/DataManagement.jsx index dfe9f75..edf73cd 100644 --- a/frontend/src/components/DataManagement.jsx +++ b/frontend/src/components/DataManagement.jsx @@ -301,6 +301,26 @@ export default function DataManagement() { )} + {bnStats.by_weekday && Object.keys(bnStats.by_weekday).length > 0 && ( +
+ 按星期 +
+ {Object.entries(bnStats.by_weekday).map(([day, v]) => ( + {day}: {v.count}笔, 盈亏{v.pnl} + ))} +
+
+ )} + {bnStats.by_hour && Object.keys(bnStats.by_hour).length > 0 && ( +
+ 按小时(北京) +
+ {Object.entries(bnStats.by_hour).map(([h, v]) => ( + {h}时: {v.count}笔, 盈亏{v.pnl} + ))} +
+
+ )} )} {!isTrades && (bnStats.by_status || bnStats.filled_count !== undefined) && ( @@ -349,11 +369,13 @@ export default function DataManagement() { {isTrades ? ( <> positionSide - price + 出场价 + 推算入场价 qty quoteQty realizedPnl commission + 小时 buyer maker @@ -383,10 +405,12 @@ export default function DataManagement() { <> {r.positionSide || '-'} {r.price} + {r._approx_entry_price != null ? r._approx_entry_price : '-'} {r.qty} {r.quoteQty} = 0 ? 'dm-profit' : 'dm-loss'}>{r.realizedPnl} {r.commission} + {r._trade_hour != null ? `${r._trade_hour}时` : '-'} {r.buyer ? '买' : '卖'} {r.maker ? '是' : '否'} diff --git a/scripts/SYNC_BINANCE_README.md b/scripts/SYNC_BINANCE_README.md index 0a93612..52aeded 100644 --- a/scripts/SYNC_BINANCE_README.md +++ b/scripts/SYNC_BINANCE_README.md @@ -21,6 +21,9 @@ python scripts/sync_binance_orders.py -a 2 # 拉取最近 12 小时 python scripts/sync_binance_orders.py --hours 12 + +# 多账号时减少账号间隔(默认 90 秒,避免限频) +python scripts/sync_binance_orders.py --delay-between-accounts 60 ``` ## 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 ``` -## 4. 数据管理 +## 4. 限频说明 + +- 多账号时每个账号之间默认等待 90 秒,可用 `--delay-between-accounts` 调整 +- 单账号内已降低并发(Semaphore 2)和请求间隔,减少触发 "Way too many requests" 封 IP +- 若已被封,需等待提示时间后重试;建议 crontab 间隔不少于 3 小时 + +## 5. 数据管理 管理后台「数据管理」-「币安订单/成交查询」从 DB 读取,不再调用币安 API。 diff --git a/scripts/sync_binance_orders.py b/scripts/sync_binance_orders.py index fa1687c..c9415da 100644 --- a/scripts/sync_binance_orders.py +++ b/scripts/sync_binance_orders.py @@ -46,7 +46,7 @@ async def _get_active_symbols(client, start_ms: int, end_ms: int) -> list: current_end = oldest - 1 if current_end < start_ms: break - await asyncio.sleep(0.15) + await asyncio.sleep(0.4) return sorted(symbols) except Exception: return [] @@ -79,7 +79,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple: if not sym_list: return 0, 0, "无法获取交易对列表" - sem = asyncio.Semaphore(5) + sem = asyncio.Semaphore(2) async def _fetch_trades(sym): async with sem: @@ -95,7 +95,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple: except Exception: return [] finally: - await asyncio.sleep(0.12) + await asyncio.sleep(0.35) async def _fetch_orders(sym): async with sem: @@ -111,7 +111,7 @@ async def sync_account(account_id: int, hours: int = 6) -> tuple: except Exception: return [] finally: - await asyncio.sleep(0.12) + await asyncio.sleep(0.35) 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]) @@ -209,8 +209,10 @@ def main(): parser = argparse.ArgumentParser(description="同步币安订单/成交到 DB(供 crontab 定时执行)") 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("--delay-between-accounts", type=int, default=90, help="多账号时,每个账号之间等待秒数,默认 90(避免触发币安限频)") args = parser.parse_args() hours = args.hours + delay_between = max(0, args.delay_between_accounts) try: from database.models import Account @@ -245,7 +247,11 @@ def main(): sys.stdout.flush() 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"] name = acc.get("name") or f"账号{aid}" tr, ord_cnt, err = await sync_account(aid, hours)