diff --git a/backend/api/main.py b/backend/api/main.py index 13e8e04..83ced05 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -3,7 +3,7 @@ FastAPI应用主入口 """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin, public +from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin, public, data_management import os import logging from pathlib import Path @@ -228,6 +228,7 @@ app.include_router(dashboard.router, prefix="/api/dashboard", tags=["仪表板"] app.include_router(account.router, prefix="/api/account", tags=["账户数据"]) app.include_router(recommendations.router, tags=["交易推荐"]) app.include_router(system.router, tags=["系统控制"]) +app.include_router(data_management.router) app.include_router(public.router) diff --git a/backend/api/routes/data_management.py b/backend/api/routes/data_management.py new file mode 100644 index 0000000..1a2679c --- /dev/null +++ b/backend/api/routes/data_management.py @@ -0,0 +1,183 @@ +""" +数据管理:查询 DB 交易、从币安拉取订单/成交,供策略分析与导出。 +仅管理员可用。 +""" +import asyncio +from pathlib import Path + +from fastapi import APIRouter, Query, Depends, HTTPException +from typing import Optional + +from api.auth_deps import get_admin_user +from database.models import Trade, Account +from datetime import datetime, timezone, timedelta + +router = APIRouter(prefix="/api/admin/data", tags=["数据管理"]) + +BEIJING_TZ = timezone(timedelta(hours=8)) + + +def _get_timestamp_range(period: Optional[str], start_date: Optional[str], end_date: Optional[str]): + now = datetime.now(BEIJING_TZ) + end_ts = int(now.timestamp()) + start_ts = None + + if period: + if period == "today": + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + start_ts = int(today.timestamp()) + elif period == "1d": + start_ts = end_ts - 24 * 3600 + elif period == "7d": + start_ts = end_ts - 7 * 24 * 3600 + elif period == "30d": + start_ts = end_ts - 30 * 24 * 3600 + elif period == "week": + days = now.weekday() + week_start = (now - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) + start_ts = int(week_start.timestamp()) + elif period == "month": + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + start_ts = int(month_start.timestamp()) + + if start_date: + try: + s = start_date if len(start_date) > 10 else f"{start_date} 00:00:00" + dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ) + start_ts = int(dt.timestamp()) + except ValueError: + pass + if end_date: + try: + s = end_date if len(end_date) > 10 else f"{end_date} 23:59:59" + dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ) + end_ts = int(dt.timestamp()) + except ValueError: + pass + + if start_ts is None: + start_ts = end_ts - 7 * 24 * 3600 # 默认 7 天 + return start_ts, end_ts + + +@router.get("/accounts") +async def list_accounts(_admin=Depends(get_admin_user)): + """获取所有账号列表,供数据管理选择""" + rows = Account.list_all() + accounts = [{"id": r["id"], "name": r.get("name") or f"Account {r['id']}", "status": r.get("status") or "active"} for r in (rows or [])] + return {"accounts": accounts} + + +@router.get("/trades") +async def query_db_trades( + _admin=Depends(get_admin_user), + account_id: int = Query(..., ge=1, description="账号 ID"), + period: Optional[str] = Query(None, description="today/1d/7d/30d/week/month"), + date: Optional[str] = Query(None, description="YYYY-MM-DD,指定日期(等同于 start_date=end_date)"), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + symbol: Optional[str] = Query(None), + time_filter: str = Query("created", description="created/entry/exit"), + reconciled_only: bool = Query(False), + limit: int = Query(500, ge=1, le=2000), +): + """ + 查询 DB 交易记录(管理员可指定任意账号) + """ + sd, ed = start_date, end_date + if date: + sd, ed = date, date + start_ts, end_ts = _get_timestamp_range(period or "today", sd, ed) + trades = Trade.get_all( + start_timestamp=start_ts, + end_timestamp=end_ts, + symbol=symbol, + status=None, + account_id=account_id, + time_filter=time_filter, + limit=limit, + reconciled_only=reconciled_only, + include_sync=True, + ) + out = [] + for t in trades: + row = dict(t) + for k, v in row.items(): + if hasattr(v, "isoformat"): + row[k] = v.isoformat() + out.append(row) + return {"total": len(out), "trades": out} + + +@router.post("/binance-fetch") +async def fetch_binance_data( + _admin=Depends(get_admin_user), + account_id: int = Query(..., ge=1), + symbols: str = Query(..., description="交易对,逗号分隔,如 ASTERUSDT,FILUSDT"), + data_type: str = Query("trades", description="orders 或 trades"), + days: int = Query(7, ge=1, le=7), +): + """ + 从币安拉取订单/成交记录(需账号已配置 API) + """ + try: + import sys + proj = Path(__file__).resolve().parents[3] # backend/api/routes -> project root + if str(proj) not in sys.path: + sys.path.insert(0, str(proj)) + from trading_system.binance_client import BinanceClient + except ImportError as e: + raise HTTPException(status_code=500, detail=f"导入失败: {e}") + + api_key, api_secret, use_testnet, _ = Account.get_credentials(account_id) + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail="该账号未配置 API 密钥") + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + try: + await client.connect() + except Exception as e: + raise HTTPException(status_code=502, detail=f"连接币安失败: {e}") + + try: + sym_list = [s.strip().upper() for s in symbols.split(",") if s.strip()] + if not sym_list: + raise HTTPException(status_code=400, detail="请指定至少一个交易对") + + end_ms = int(datetime.now(BEIJING_TZ).timestamp() * 1000) + start_ms = end_ms - days * 24 * 3600 * 1000 + + all_data = [] + for sym in sym_list: + try: + if data_type == "trades": + rows = await client.client.futures_account_trades( + symbol=sym, + startTime=start_ms, + endTime=end_ms, + limit=1000, + recvWindow=20000, + ) + else: + rows = await client.client.futures_get_all_orders( + symbol=sym, + startTime=start_ms, + endTime=end_ms, + limit=1000, + recvWindow=20000, + ) + if isinstance(rows, list): + for r in rows: + r["_symbol"] = sym + all_data.extend(rows) + except Exception as e: + all_data.append({"_symbol": sym, "_error": str(e)}) + await asyncio.sleep(0.2) + + time_key = "time" if (all_data and "time" in (all_data[0] or {})) else "updateTime" + all_data.sort(key=lambda x: x.get(time_key, 0), reverse=True) + + return {"total": len(all_data), "data_type": data_type, "data": all_data} + finally: + if client.client: + await client.client.close_connection() diff --git a/docs/DB与币安订单对账说明.md b/docs/DB与币安订单对账说明.md index 54de34b..7ee8a65 100644 --- a/docs/DB与币安订单对账说明.md +++ b/docs/DB与币安订单对账说明.md @@ -93,7 +93,31 @@ grep "SYS_1737500000_abcd" logs/binance_order_events.log --- -## 三、对账流程建议 +## 三、从币安拉取订单/成交(DB 缺失时) + +当 DB 记录查不到或需直接从币安做策略分析时,可用脚本拉取: + +```bash +# 拉取最近 7 天成交记录(默认,适合策略分析) +python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT + +# 多个交易对 +python scripts/fetch_binance_orders.py --account 2 --symbols ASTERUSDT,FILUSDT,PENGUUSDT + +# 拉取订单列表 +python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --type orders + +# 指定天数、导出 +python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --days 7 -o binance_trades.json +``` + +- `--type trades`:成交记录(含价格、数量、盈亏,策略分析推荐) +- `--type orders`:订单列表(含 FILLED/CANCELED) +- 币安单次时间范围最多 7 天 + +--- + +## 四、对账流程建议 1. **查 DB 今日记录**:`python scripts/query_trades_today.py -o db_today.json` 2. **查币安推送日志**:`tail -f logs/binance_order_events.log` 或 `grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log` diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 42809da..33fc8d9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import StatsDashboard from './components/StatsDashboard' import AdminDashboard from './components/AdminDashboard.jsx' import Recommendations from './components/Recommendations' import LogMonitor from './components/LogMonitor' +import DataManagement from './components/DataManagement' import AccountSelector from './components/AccountSelector' import GlobalConfig from './components/GlobalConfig' import Login from './components/Login' @@ -82,6 +83,7 @@ function App() { {isAdmin && ( <> 全局配置 + 数据管理 日志监控 )} @@ -115,6 +117,7 @@ function App() { } /> } /> :
无权限
} /> + :
无权限
} /> :
无权限
} /> diff --git a/frontend/src/components/DataManagement.css b/frontend/src/components/DataManagement.css new file mode 100644 index 0000000..89973ab --- /dev/null +++ b/frontend/src/components/DataManagement.css @@ -0,0 +1,145 @@ +.data-management { + display: flex; + flex-direction: column; + gap: 24px; + padding: 16px; +} + +.data-management h2 { + margin: 0; +} + +.dm-subtitle { + color: #666; + font-size: 14px; + margin: 4px 0 0 0; +} + +.dm-section { + border: 1px solid #eee; + border-radius: 10px; + background: #fff; + padding: 16px; +} + +.dm-section h3 { + margin: 0 0 12px 0; + font-size: 16px; +} + +.dm-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.dm-controls label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #555; +} + +.dm-controls label input[type="text"], +.dm-controls label input[type="date"], +.dm-controls label select { + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 13px; +} + +.dm-check { + flex-direction: row !important; + align-items: center; +} + +.dm-check input { + margin-right: 6px; +} + +.dm-error { + color: #c00; + margin-top: 8px; + font-size: 13px; +} + +.dm-result { + margin-top: 16px; +} + +.dm-result-meta { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + font-size: 13px; + color: #555; +} + +.dm-table-wrap { + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.dm-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.dm-table th, +.dm-table td { + padding: 8px 10px; + border: 1px solid #eee; + text-align: left; +} + +.dm-table th { + background: #f8f8f8; + font-weight: 600; + position: sticky; + top: 0; +} + +.dm-table tbody tr:hover { + background: #f9f9f9; +} + +.dm-more { + padding: 8px 10px; + font-size: 12px; + color: #666; +} + +.btn { + padding: 8px 14px; + border-radius: 6px; + border: 1px solid #ddd; + background: #fff; + cursor: pointer; + font-size: 13px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: #0066cc; + color: #fff; + border-color: #0066cc; +} + +.btn-primary:hover:not(:disabled) { + background: #0052a3; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} diff --git a/frontend/src/components/DataManagement.jsx b/frontend/src/components/DataManagement.jsx new file mode 100644 index 0000000..d4da0b6 --- /dev/null +++ b/frontend/src/components/DataManagement.jsx @@ -0,0 +1,277 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../services/api' +import './DataManagement.css' + +function downloadJson(data, filename) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export default function DataManagement() { + const [accounts, setAccounts] = useState([]) + + // DB 交易 + const [dbAccountId, setDbAccountId] = useState('') + const [dbDate, setDbDate] = useState('') + const [dbTimeFilter, setDbTimeFilter] = useState('created') + const [dbReconciledOnly, setDbReconciledOnly] = useState(false) + const [dbSymbol, setDbSymbol] = useState('') + const [dbLoading, setDbLoading] = useState(false) + const [dbResult, setDbResult] = useState(null) + const [dbError, setDbError] = useState('') + + // 币安拉取 + const [bnAccountId, setBnAccountId] = useState('') + const [bnSymbols, setBnSymbols] = useState('') + const [bnDataType, setBnDataType] = useState('trades') + const [bnDays, setBnDays] = useState(7) + const [bnLoading, setBnLoading] = useState(false) + const [bnResult, setBnResult] = useState(null) + const [bnError, setBnError] = useState('') + + useEffect(() => { + api.getDataManagementAccounts().then((r) => { + const list = Array.isArray(r?.accounts) ? r.accounts : (Array.isArray(r) ? r : []) + setAccounts(list) + if (!dbAccountId && list.length) setDbAccountId(String(list[0].id)) + if (!bnAccountId && list.length) setBnAccountId(String(list[0].id)) + }).catch(() => {}) + }, []) + + const queryDbTrades = async () => { + const aid = parseInt(dbAccountId, 10) + if (!aid) { + setDbError('请选择账号') + return + } + setDbLoading(true) + setDbError('') + setDbResult(null) + try { + const params = { + account_id: aid, + date: dbDate || undefined, + time_filter: dbTimeFilter, + reconciled_only: dbReconciledOnly ? 'true' : undefined, + symbol: dbSymbol || undefined, + } + const res = await api.getDataManagementDbTrades(params) + setDbResult(res) + } catch (e) { + setDbError(e?.message || '查询失败') + } finally { + setDbLoading(false) + } + } + + const fetchBinance = async () => { + const aid = parseInt(bnAccountId, 10) + const syms = bnSymbols.trim() + if (!aid || !syms) { + setBnError('请选择账号并输入交易对') + return + } + setBnLoading(true) + setBnError('') + setBnResult(null) + try { + const res = await api.postDataManagementFetchBinance({ + account_id: aid, + symbols: syms, + data_type: bnDataType, + days: bnDays, + }) + setBnResult(res) + } catch (e) { + setBnError(e?.message || '拉取失败') + } finally { + setBnLoading(false) + } + } + + const exportDb = () => { + if (!dbResult?.trades) return + const filename = `db_trades_${dbAccountId}_${dbDate || 'today'}_${dbTimeFilter}.json` + downloadJson(dbResult, filename) + } + + const exportBinance = () => { + const items = bnResult?.data || bnResult?.items + if (!items) return + const filename = `binance_${bnResult.data_type}_${bnAccountId}.json` + downloadJson({ total: bnResult.total, data_type: bnResult.data_type, data: items }, filename) + } + + const today = new Date().toISOString().slice(0, 10) + + return ( +
+

数据管理

+

查询 DB 交易记录、从币安拉取订单/成交,支持导出 JSON 做策略分析与对账。

+ + {/* 1. 查询 DB 交易 */} +
+

查询 DB 交易

+
+ + + + + + +
+ {dbError &&
{dbError}
} + {dbResult && ( +
+
+ 共 {dbResult.total} 条 + +
+
+ + + + + + + + + + + + + + + {(dbResult.trades || []).slice(0, 100).map((t) => ( + + + + + + + + + + + ))} + +
ID交易对状态入场价出场价盈亏开仓时间平仓时间
{t.id}{t.symbol}{t.status}{t.entry_price}{t.exit_price}{t.realized_pnl}{t.entry_time || '-'}{t.exit_time || '-'}
+ {(dbResult.trades?.length || 0) > 100 && ( +
仅显示前 100 条,共 {dbResult.total} 条。导出可获取全部。
+ )} +
+
+ )} +
+ + {/* 2. 从币安拉取 */} +
+

从币安拉取

+
+ + + + + +
+ {bnError &&
{bnError}
} + {bnResult && ( +
+
+ 共 {bnResult.total} 条 + +
+
+ + + + + + + + + + + + + + {((bnResult.data || bnResult.items) || []).slice(0, 100).map((r, i) => ( + + + + + + + + + + ))} + +
交易对orderIdsidepriceqtyrealizedPnltime
{r._symbol || r.symbol}{r.orderId}{r.side}{r.price}{r.qty}{r.realizedPnl}{r.time ? new Date(r.time).toLocaleString() : r.updateTime ? new Date(r.updateTime).toLocaleString() : '-'}
+ {((bnResult.data || bnResult.items)?.length || 0) > 100 && ( +
仅显示前 100 条,共 {bnResult.total} 条。导出可获取全部。
+ )} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 5b5195e..6287769 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -929,4 +929,37 @@ export const api = { } return response.json(); }, + + // 数据管理(管理员专用) + getDataManagementAccounts: async () => { + const response = await fetch(buildUrl('/api/admin/data/accounts'), { headers: withAuthHeaders() }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '获取账号列表失败' })); + throw new Error(error.detail || '获取账号列表失败'); + } + return response.json(); + }, + getDataManagementDbTrades: async (params) => { + const query = new URLSearchParams(params).toString(); + const url = query ? `${buildUrl('/api/admin/data/trades')}?${query}` : buildUrl('/api/admin/data/trades'); + const response = await fetch(url, { headers: withAuthHeaders() }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '查询 DB 交易失败' })); + throw new Error(error.detail || '查询 DB 交易失败'); + } + return response.json(); + }, + postDataManagementFetchBinance: async (params) => { + const query = new URLSearchParams(params).toString(); + const url = query ? `${buildUrl('/api/admin/data/binance-fetch')}?${query}` : buildUrl('/api/admin/data/binance-fetch'); + const response = await fetch(url, { + method: 'POST', + headers: withAuthHeaders({ 'Content-Type': 'application/json' }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '从币安拉取失败' })); + throw new Error(error.detail || '从币安拉取失败'); + } + return response.json(); + }, }; diff --git a/scripts/fetch_binance_orders.py b/scripts/fetch_binance_orders.py new file mode 100644 index 0000000..dbb9814 --- /dev/null +++ b/scripts/fetch_binance_orders.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +从币安拉取最近订单/成交记录,供策略分析或与 DB 对照。 +当 DB 记录缺失时,可用此脚本直接查币安数据。 + +用法: + python scripts/fetch_binance_orders.py --account 2 + python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --days 7 + python scripts/fetch_binance_orders.py --account 2 --type orders -o binance_orders.json + python scripts/fetch_binance_orders.py --account 2 --symbols ASTERUSDT,FILUSDT,PENGUUSDT +""" +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path +from datetime import datetime, timezone, timedelta + +proj = Path(__file__).resolve().parent.parent +if (proj / "backend").exists(): + sys.path.insert(0, str(proj / "backend")) +sys.path.insert(0, str(proj)) + +BEIJING_TZ = timezone(timedelta(hours=8)) + + +def _serialize(obj): + if hasattr(obj, "isoformat"): + return obj.isoformat() + if isinstance(obj, datetime): + return obj.isoformat() + return obj + + +async def main(): + parser = argparse.ArgumentParser(description="从币安拉取订单/成交记录") + parser.add_argument("--account", "-a", type=int, default=None, + help="账号 ID,默认 ATS_ACCOUNT_ID 或 1") + parser.add_argument("--symbol", "-s", type=str, default=None, + help="交易对,如 BTCUSDT(单 symbol)") + parser.add_argument("--symbols", type=str, default=None, + help="多个交易对,逗号分隔,如 ASTERUSDT,FILUSDT") + parser.add_argument("--days", "-d", type=int, default=7, + help="拉取最近 N 天,默认 7(币安单次最多 7 天)") + parser.add_argument("--type", "-t", choices=["orders", "trades"], default="trades", + help="orders=订单列表, trades=成交记录(策略分析推荐 trades)") + parser.add_argument("-o", "--output", type=str, help="导出到 JSON 文件") + args = parser.parse_args() + + account_id = args.account or int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or "1") + + symbols = [] + if args.symbol: + symbols = [s.strip().upper() for s in args.symbol.split(",") if s.strip()] + elif args.symbols: + symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] + + if not symbols: + print("请指定 --symbol 或 --symbols(逗号分隔)") + print(" 示例: --symbol BTCUSDT 或 --symbols ASTERUSDT,FILUSDT,PENGUUSDT") + sys.exit(1) + + try: + from database.models import Account + from trading_system.binance_client import BinanceClient + except ImportError as e: + print(f"导入失败: {e}") + print("请确保在项目根目录运行,且 backend 可访问") + sys.exit(1) + + api_key, api_secret, use_testnet, _ = Account.get_credentials(account_id) + if not api_key or not api_secret: + print(f"账号 {account_id} 未配置 API 密钥") + sys.exit(1) + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + try: + await client.connect() + except Exception as e: + print(f"连接币安失败: {e}") + sys.exit(1) + + end_ms = int(datetime.now(BEIJING_TZ).timestamp() * 1000) + start_ms = end_ms - args.days * 24 * 3600 * 1000 + + all_data = [] + for sym in symbols: + try: + if args.type == "trades": + rows = await client.client.futures_account_trades( + symbol=sym, + startTime=start_ms, + endTime=end_ms, + limit=1000, + recvWindow=20000, + ) + for r in rows: + r["_symbol"] = sym + all_data.extend(rows) + print(f" {sym}: 成交 {len(rows)} 条") + else: + rows = await client.client.futures_get_all_orders( + symbol=sym, + startTime=start_ms, + endTime=end_ms, + limit=1000, + recvWindow=20000, + ) + for r in rows: + r["_symbol"] = sym + all_data.extend(rows) + print(f" {sym}: 订单 {len(rows)} 条") + except Exception as e: + print(f" {sym}: 失败 {e}") + await asyncio.sleep(0.2) + + # 按时间排序 + if all_data: + time_key = "time" if "time" in all_data[0] else "updateTime" + all_data.sort(key=lambda x: x.get(time_key, 0), reverse=True) + + # 转换大数/日期 + out = [] + for r in all_data: + row = dict(r) + for k, v in list(row.items()): + if isinstance(v, (datetime,)): + row[k] = v.isoformat() + out.append(row) + + print(f"\n账号 {account_id} | 类型 {args.type} | 共 {len(out)} 条") + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False, indent=2, default=_serialize) + print(f"已导出到 {args.output}") + else: + print(json.dumps(out[:50], ensure_ascii=False, indent=2, default=_serialize)) + if len(out) > 50: + print(f"... 仅显示前 50 条,共 {len(out)} 条。可用 -o 导出全部") + + if client.client: + await client.client.close_connection() + + +if __name__ == "__main__": + asyncio.run(main())