feat(data_management): 添加数据管理功能与接口
在后端 API 中新增数据管理路由,支持从币安拉取订单和成交记录的功能。前端应用中引入数据管理组件,并在路由中添加相应的链接。更新了 API 服务,提供获取账号列表和查询 DB 交易的接口,增强了系统的数据处理能力与用户体验。
This commit is contained in:
parent
fa7208f5f3
commit
f2b04911a2
|
|
@ -3,7 +3,7 @@ FastAPI应用主入口
|
||||||
"""
|
"""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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(account.router, prefix="/api/account", tags=["账户数据"])
|
||||||
app.include_router(recommendations.router, tags=["交易推荐"])
|
app.include_router(recommendations.router, tags=["交易推荐"])
|
||||||
app.include_router(system.router, tags=["系统控制"])
|
app.include_router(system.router, tags=["系统控制"])
|
||||||
|
app.include_router(data_management.router)
|
||||||
app.include_router(public.router)
|
app.include_router(public.router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
183
backend/api/routes/data_management.py
Normal file
183
backend/api/routes/data_management.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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`
|
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`
|
2. **查币安推送日志**:`tail -f logs/binance_order_events.log` 或 `grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log`
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import StatsDashboard from './components/StatsDashboard'
|
||||||
import AdminDashboard from './components/AdminDashboard.jsx'
|
import AdminDashboard from './components/AdminDashboard.jsx'
|
||||||
import Recommendations from './components/Recommendations'
|
import Recommendations from './components/Recommendations'
|
||||||
import LogMonitor from './components/LogMonitor'
|
import LogMonitor from './components/LogMonitor'
|
||||||
|
import DataManagement from './components/DataManagement'
|
||||||
import AccountSelector from './components/AccountSelector'
|
import AccountSelector from './components/AccountSelector'
|
||||||
import GlobalConfig from './components/GlobalConfig'
|
import GlobalConfig from './components/GlobalConfig'
|
||||||
import Login from './components/Login'
|
import Login from './components/Login'
|
||||||
|
|
@ -82,6 +83,7 @@ function App() {
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link to="/global-config">全局配置</Link>
|
<Link to="/global-config">全局配置</Link>
|
||||||
|
<Link to="/data-management">数据管理</Link>
|
||||||
<Link to="/logs">日志监控</Link>
|
<Link to="/logs">日志监控</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -115,6 +117,7 @@ function App() {
|
||||||
<Route path="/config/guide" element={<ConfigGuide />} />
|
<Route path="/config/guide" element={<ConfigGuide />} />
|
||||||
<Route path="/trades" element={<TradeList />} />
|
<Route path="/trades" element={<TradeList />} />
|
||||||
<Route path="/global-config" element={isAdmin ? <GlobalConfig /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
<Route path="/global-config" element={isAdmin ? <GlobalConfig /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
|
<Route path="/data-management" element={isAdmin ? <DataManagement /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
<Route path="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
<Route path="/logs" element={isAdmin ? <LogMonitor /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
145
frontend/src/components/DataManagement.css
Normal file
145
frontend/src/components/DataManagement.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
277
frontend/src/components/DataManagement.jsx
Normal file
277
frontend/src/components/DataManagement.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="data-management">
|
||||||
|
<h2>数据管理</h2>
|
||||||
|
<p className="dm-subtitle">查询 DB 交易记录、从币安拉取订单/成交,支持导出 JSON 做策略分析与对账。</p>
|
||||||
|
|
||||||
|
{/* 1. 查询 DB 交易 */}
|
||||||
|
<section className="dm-section">
|
||||||
|
<h3>查询 DB 交易</h3>
|
||||||
|
<div className="dm-controls">
|
||||||
|
<label>
|
||||||
|
账号
|
||||||
|
<select value={dbAccountId} onChange={(e) => setDbAccountId(e.target.value)}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name || `账号${a.id}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
日期 (YYYY-MM-DD)
|
||||||
|
<input type="date" value={dbDate} onChange={(e) => setDbDate(e.target.value)} max={today} placeholder="留空=今天" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
时间筛选
|
||||||
|
<select value={dbTimeFilter} onChange={(e) => setDbTimeFilter(e.target.value)}>
|
||||||
|
<option value="created">按创建时间</option>
|
||||||
|
<option value="entry">按开仓时间</option>
|
||||||
|
<option value="exit">按平仓时间</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dm-check">
|
||||||
|
<input type="checkbox" checked={dbReconciledOnly} onChange={(e) => setDbReconciledOnly(e.target.checked)} />
|
||||||
|
仅可对账(有 entry/exit orderId)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
交易对
|
||||||
|
<input type="text" value={dbSymbol} onChange={(e) => setDbSymbol(e.target.value)} placeholder="留空=全部" />
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-primary" onClick={queryDbTrades} disabled={dbLoading}>
|
||||||
|
{dbLoading ? '查询中...' : '查询'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dbError && <div className="dm-error">{dbError}</div>}
|
||||||
|
{dbResult && (
|
||||||
|
<div className="dm-result">
|
||||||
|
<div className="dm-result-meta">
|
||||||
|
共 {dbResult.total} 条
|
||||||
|
<button className="btn btn-sm" onClick={exportDb}>导出 JSON</button>
|
||||||
|
</div>
|
||||||
|
<div className="dm-table-wrap">
|
||||||
|
<table className="dm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>交易对</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>入场价</th>
|
||||||
|
<th>出场价</th>
|
||||||
|
<th>盈亏</th>
|
||||||
|
<th>开仓时间</th>
|
||||||
|
<th>平仓时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(dbResult.trades || []).slice(0, 100).map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td>{t.id}</td>
|
||||||
|
<td>{t.symbol}</td>
|
||||||
|
<td>{t.status}</td>
|
||||||
|
<td>{t.entry_price}</td>
|
||||||
|
<td>{t.exit_price}</td>
|
||||||
|
<td>{t.realized_pnl}</td>
|
||||||
|
<td>{t.entry_time || '-'}</td>
|
||||||
|
<td>{t.exit_time || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{(dbResult.trades?.length || 0) > 100 && (
|
||||||
|
<div className="dm-more">仅显示前 100 条,共 {dbResult.total} 条。导出可获取全部。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. 从币安拉取 */}
|
||||||
|
<section className="dm-section">
|
||||||
|
<h3>从币安拉取</h3>
|
||||||
|
<div className="dm-controls">
|
||||||
|
<label>
|
||||||
|
账号
|
||||||
|
<select value={bnAccountId} onChange={(e) => setBnAccountId(e.target.value)}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name || `账号${a.id}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
交易对
|
||||||
|
<input type="text" value={bnSymbols} onChange={(e) => setBnSymbols(e.target.value)} placeholder="ASTERUSDT,FILUSDT 逗号分隔" style={{ minWidth: 220 }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
数据类型
|
||||||
|
<select value={bnDataType} onChange={(e) => setBnDataType(e.target.value)}>
|
||||||
|
<option value="trades">成交记录</option>
|
||||||
|
<option value="orders">订单列表</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最近天数
|
||||||
|
<select value={bnDays} onChange={(e) => setBnDays(Number(e.target.value))}>
|
||||||
|
{[1, 2, 3, 5, 7].map((d) => (
|
||||||
|
<option key={d} value={d}>{d} 天</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-primary" onClick={fetchBinance} disabled={bnLoading}>
|
||||||
|
{bnLoading ? '拉取中...' : '拉取'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bnError && <div className="dm-error">{bnError}</div>}
|
||||||
|
{bnResult && (
|
||||||
|
<div className="dm-result">
|
||||||
|
<div className="dm-result-meta">
|
||||||
|
共 {bnResult.total} 条
|
||||||
|
<button className="btn btn-sm" onClick={exportBinance}>导出 JSON</button>
|
||||||
|
</div>
|
||||||
|
<div className="dm-table-wrap">
|
||||||
|
<table className="dm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>交易对</th>
|
||||||
|
<th>orderId</th>
|
||||||
|
<th>side</th>
|
||||||
|
<th>price</th>
|
||||||
|
<th>qty</th>
|
||||||
|
<th>realizedPnl</th>
|
||||||
|
<th>time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{((bnResult.data || bnResult.items) || []).slice(0, 100).map((r, i) => (
|
||||||
|
<tr key={r.orderId || r.id || i}>
|
||||||
|
<td>{r._symbol || r.symbol}</td>
|
||||||
|
<td>{r.orderId}</td>
|
||||||
|
<td>{r.side}</td>
|
||||||
|
<td>{r.price}</td>
|
||||||
|
<td>{r.qty}</td>
|
||||||
|
<td>{r.realizedPnl}</td>
|
||||||
|
<td>{r.time ? new Date(r.time).toLocaleString() : r.updateTime ? new Date(r.updateTime).toLocaleString() : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{((bnResult.data || bnResult.items)?.length || 0) > 100 && (
|
||||||
|
<div className="dm-more">仅显示前 100 条,共 {bnResult.total} 条。导出可获取全部。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -929,4 +929,37 @@ export const api = {
|
||||||
}
|
}
|
||||||
return response.json();
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
147
scripts/fetch_binance_orders.py
Normal file
147
scripts/fetch_binance_orders.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in New Issue
Block a user