feat(data_management): 优化数据管理功能与API接口

在后端 API 中新增 `_get_active_symbols_from_income` 函数,通过收益历史 API 获取有交易活动的交易对,减少后续请求数。更新 `fetch_binance_data` 函数以支持动态获取交易对,并优化前端 `DataManagement` 组件,确保仅显示状态为 active 的账号。调整 API 服务以支持可选参数 `activeOnly`,提升数据查询的灵活性与用户体验。
This commit is contained in:
薇薇安 2026-02-22 10:43:37 +08:00
parent 69be629369
commit aaef73c2b3
3 changed files with 111 additions and 51 deletions

View File

@ -60,11 +60,46 @@ def _get_timestamp_range(period: Optional[str], start_date: Optional[str], end_d
return start_ts, end_ts
async def _get_active_symbols_from_income(binance_client, start_ms: int, end_ms: int) -> list:
"""
通过收益历史 API 获取该时间段内有交易活动的交易对避免全量遍历 250+ 交易对
一次 API 调用weight 100即可拿到有成交/盈亏的 symbol 列表大幅减少后续 trades/orders 的请求数
"""
try:
symbols = set()
current_end = end_ms
for _ in range(10): # 最多分页 10 次(单次最多 1000 条)
rows = await binance_client.futures_income_history(
startTime=start_ms,
endTime=current_end,
limit=1000,
recvWindow=20000,
)
if not rows:
break
for r in rows:
sym = (r.get("symbol") or "").strip()
if sym and sym.endswith("USDT"):
symbols.add(sym)
if len(rows) < 1000:
break
oldest = min(r.get("time", current_end) for r in rows)
current_end = oldest - 1
if current_end < start_ms:
break
await asyncio.sleep(0.15)
return sorted(symbols)
except Exception:
return []
@router.get("/accounts")
async def list_accounts(_admin=Depends(get_admin_user)):
"""获取所有账号列表,供数据管理选择"""
async def list_accounts(_admin=Depends(get_admin_user), active_only: bool = Query(False)):
"""获取账号列表,供数据管理选择。active_only=true 时仅返回 status=active 的账号"""
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 [])]
if active_only:
accounts = [a for a in accounts if (a.get("status") or "").lower() == "active"]
return {"accounts": accounts}
@ -114,9 +149,9 @@ async def query_db_trades(
async def fetch_binance_data(
_admin=Depends(get_admin_user),
account_id: int = Query(..., ge=1),
symbols: str = Query(..., description="交易对,逗号分隔,如 ASTERUSDT,FILUSDT"),
symbols: Optional[str] = Query(None, description="交易对,逗号分隔;留空则拉取该时间段内全部交易对的订单/成交"),
data_type: str = Query("trades", description="orders 或 trades"),
days: int = Query(7, ge=1, le=7),
days: int = Query(7, ge=0, le=7),
):
"""
从币安拉取订单/成交记录需账号已配置 API
@ -141,44 +176,67 @@ async def fetch_binance_data(
raise HTTPException(status_code=502, detail=f"连接币安失败: {e}")
try:
sym_list = [s.strip().upper() for s in symbols.split(",") if s.strip()]
now = datetime.now(BEIJING_TZ)
end_ms = int(now.timestamp() * 1000)
if days == 0:
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_ms = int(today_start.timestamp() * 1000)
else:
start_ms = end_ms - days * 24 * 3600 * 1000
sym_list = [s.strip().upper() for s in (symbols or "").split(",") if s.strip()]
if not sym_list:
raise HTTPException(status_code=400, detail="请指定至少一个交易对")
sym_list = await _get_active_symbols_from_income(client.client, start_ms, end_ms)
if not sym_list:
sym_list = await client.get_all_usdt_pairs()
if not sym_list:
raise HTTPException(status_code=500, detail="无法获取交易对列表,请手动指定交易对")
end_ms = int(datetime.now(BEIJING_TZ).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
sem = asyncio.Semaphore(5)
async def _fetch_one(sym: str):
async with sem:
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
return rows
except Exception as e:
return [{"_symbol": sym, "_error": str(e)}]
finally:
await asyncio.sleep(0.12)
tasks = [_fetch_one(sym) for sym in sym_list]
chunks = await asyncio.gather(*tasks)
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)
for ch in chunks:
all_data.extend(ch)
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}
return {
"total": len(all_data),
"data_type": data_type,
"symbols_queried": len(sym_list),
"data": all_data,
}
finally:
if client.client:
await client.client.close_connection()

View File

@ -34,12 +34,16 @@ export default function DataManagement() {
const [bnResult, setBnResult] = useState(null)
const [bnError, setBnError] = useState('')
const activeAccounts = accounts.filter((a) => (a.status || 'active').toLowerCase() === 'active')
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))
const active = list.filter((a) => (a.status || 'active').toLowerCase() === 'active')
const first = active[0] || list[0]
if (!dbAccountId && first) setDbAccountId(String(first.id))
if (!bnAccountId && first) setBnAccountId(String(first.id))
}).catch(() => {})
}, [])
@ -68,21 +72,17 @@ export default function DataManagement() {
const fetchBinance = async () => {
const aid = parseInt(bnAccountId, 10)
const syms = bnSymbols.trim()
if (!aid || !syms) {
setBnError('请选择账号并输入交易对')
if (!aid) {
setBnError('请选择账号')
return
}
setBnLoading(true)
setBnError('')
setBnResult(null)
try {
const res = await api.postDataManagementFetchBinance({
account_id: aid,
symbols: syms,
data_type: bnDataType,
days: bnDays,
})
const params = { account_id: aid, data_type: bnDataType, days: bnDays }
if (bnSymbols.trim()) params.symbols = bnSymbols.trim()
const res = await api.postDataManagementFetchBinance(params)
setBnResult(res)
} catch (e) {
setBnError(e?.message || '拉取失败')
@ -119,7 +119,7 @@ export default function DataManagement() {
账号
<select value={dbAccountId} onChange={(e) => setDbAccountId(e.target.value)}>
<option value="">请选择</option>
{accounts.map((a) => (
{activeAccounts.map((a) => (
<option key={a.id} value={a.id}>{a.name || `账号${a.id}`}</option>
))}
</select>
@ -200,14 +200,14 @@ export default function DataManagement() {
账号
<select value={bnAccountId} onChange={(e) => setBnAccountId(e.target.value)}>
<option value="">请选择</option>
{accounts.map((a) => (
{activeAccounts.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 }} />
<input type="text" value={bnSymbols} onChange={(e) => setBnSymbols(e.target.value)} placeholder="留空=全部交易对,或逗号分隔如 ASTERUSDT,FILUSDT" style={{ minWidth: 280 }} />
</label>
<label>
数据类型
@ -217,8 +217,9 @@ export default function DataManagement() {
</select>
</label>
<label>
最近天数
时间范围
<select value={bnDays} onChange={(e) => setBnDays(Number(e.target.value))}>
<option value={0}>当天</option>
{[1, 2, 3, 5, 7].map((d) => (
<option key={d} value={d}>{d} </option>
))}

View File

@ -931,8 +931,9 @@ export const api = {
},
// 数据管理(管理员专用)
getDataManagementAccounts: async () => {
const response = await fetch(buildUrl('/api/admin/data/accounts'), { headers: withAuthHeaders() });
getDataManagementAccounts: async (activeOnly = true) => {
const params = activeOnly ? '?active_only=true' : '';
const response = await fetch(buildUrl(`/api/admin/data/accounts${params}`), { headers: withAuthHeaders() });
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取账号列表失败' }));
throw new Error(error.detail || '获取账号列表失败');