auto_trade_sys/backend/api/routes/trades.py
薇薇安 42480ef886 feat(trades): 添加时间筛选功能以优化交易记录查询
在 `trades.py` 中新增 `time_filter` 参数,允许用户按平仓时间或开仓时间筛选交易记录。更新 `Trade.get_all` 方法以支持该功能,并调整查询逻辑以符合新的时间筛选需求。同时,前端组件 `TradeList.jsx` 也进行了相应更新,增加了时间筛选按钮,提升了用户体验和数据查询的灵活性。
2026-02-17 08:01:35 +08:00

839 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
交易记录API
"""
from fastapi import APIRouter, Query, HTTPException, Header, Depends
from typing import Optional
from datetime import datetime, timedelta
from collections import Counter
import json
import sys
from pathlib import Path
import logging
import asyncio
import time
from datetime import timezone, timedelta
project_root = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / 'backend'))
from database.models import Trade, Account
from api.auth_deps import get_account_id
router = APIRouter()
# 在模块级别创建logger与其他路由文件保持一致
logger = logging.getLogger(__name__)
def get_timestamp_range(period: Optional[str] = None):
"""
根据时间段参数返回开始和结束时间戳Unix时间戳秒数
Args:
period: 时间段 ('1d', '7d', '30d', 'today', 'week', 'month', 'custom')
Returns:
(start_timestamp, end_timestamp) 元组Unix时间戳秒数
"""
# 使用当前时间作为结束时间Unix时间戳秒数
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
end_timestamp = int(now.timestamp())
if period == '1d':
# 最近1天当前时间减去24小时
start_timestamp = end_timestamp - 24 * 3600
elif period == '7d':
# 最近7天当前时间减去7*24小时
start_timestamp = end_timestamp - 7 * 24 * 3600
elif period == '30d':
# 最近30天当前时间减去30*24小时
start_timestamp = end_timestamp - 30 * 24 * 3600
elif period == 'today':
# 今天从今天00:00:00到现在
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_timestamp = int(today_start.timestamp())
elif period == 'week':
# 本周从本周一00:00:00到现在
days_since_monday = now.weekday() # 0=Monday, 6=Sunday
week_start = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
start_timestamp = int(week_start.timestamp())
elif period == 'month':
# 本月从本月1日00:00:00到现在
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_timestamp = int(month_start.timestamp())
else:
return None, None
return start_timestamp, end_timestamp
@router.get("")
@router.get("/")
async def get_trades(
account_id: int = Depends(get_account_id),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d'(最近1天), '7d'(最近7天), '30d'(最近30天), 'today'(今天), 'week'(本周), 'month'(本月)"),
symbol: Optional[str] = Query(None, description="交易对筛选"),
trade_type: Optional[str] = Query(None, description="交易类型筛选: 'buy', 'sell'"),
exit_reason: Optional[str] = Query(None, description="平仓原因筛选: 'stop_loss', 'take_profit', 'trailing_stop', 'manual', 'sync'"),
status: Optional[str] = Query(None, description="状态筛选: 'open', 'closed', 'cancelled'"),
time_filter: str = Query("exit", description="时间范围按哪种时间筛选: 'exit'(按平仓时间,今日=今天平掉的单+今天开的未平仓), 'entry'(按开仓时间), 'both'(原逻辑)"),
include_sync: bool = Query(False, description="是否包含 entry_reason 为 sync_recovered 的历史同步单"),
reconciled_only: bool = Query(True, description="仅返回可对账记录(有 entry_order_id已平仓的还有 exit_order_id与币安一致统计真实"),
limit: int = Query(100, ge=1, le=1000, description="返回记录数限制"),
):
"""
获取交易记录
支持两种筛选方式:
1. 快速时间段筛选:使用 period 参数 ('1d', '7d', '30d', 'today', 'week', 'month')
2. 自定义时间段筛选:使用 start_date 和 end_date 参数会转换为Unix时间戳
默认按平仓时间(time_filter=exit):选「今天」= 今天平掉的单 + 今天开的未平仓,更符合直觉。
"""
try:
logger.info(f"获取交易记录请求: start_date={start_date}, end_date={end_date}, period={period}, symbol={symbol}, status={status}, limit={limit}, trade_type={trade_type}, exit_reason={exit_reason}")
start_timestamp = None
end_timestamp = None
# 如果提供了 period使用快速时间段筛选
if period:
period_start, period_end = get_timestamp_range(period)
if period_start is not None and period_end is not None:
start_timestamp = period_start
end_timestamp = period_end
logger.info(f"使用快速时间段筛选: {period} -> {start_timestamp} ({datetime.fromtimestamp(start_timestamp)}) 至 {end_timestamp} ({datetime.fromtimestamp(end_timestamp)})")
elif start_date or end_date:
# 自定义时间段将日期字符串转换为Unix时间戳
beijing_tz = timezone(timedelta(hours=8))
if start_date:
if len(start_date) == 10: # YYYY-MM-DD
start_date = f"{start_date} 00:00:00"
try:
dt = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=beijing_tz)
start_timestamp = int(dt.timestamp())
except ValueError:
logger.warning(f"无效的开始日期格式: {start_date}")
if end_date:
if len(end_date) == 10: # YYYY-MM-DD
end_date = f"{end_date} 23:59:59"
try:
dt = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=beijing_tz)
end_timestamp = int(dt.timestamp())
except ValueError:
logger.warning(f"无效的结束日期格式: {end_date}")
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, status, trade_type, exit_reason, account_id=account_id, time_filter=time_filter or "exit")
if not include_sync:
trades = [
t for t in trades
if (t.get("entry_reason") or "") != "sync_recovered"
and (t.get("exit_reason") or "") != "sync"
]
# 仅可对账:有开仓订单号,已平仓的还须有平仓订单号,保证与币安可一一对应、统计真实
def _has_entry_order_id(t):
eid = t.get("entry_order_id")
return eid is not None and eid != "" and (eid != 0 if isinstance(eid, (int, float)) else True)
def _has_exit_order_id(t):
xid = t.get("exit_order_id")
return xid is not None and xid != "" and (xid != 0 if isinstance(xid, (int, float)) else True)
if reconciled_only:
before = len(trades)
trades = [
t for t in trades
if _has_entry_order_id(t)
and (t.get("status") != "closed" or _has_exit_order_id(t))
]
logger.info(f"可对账过滤: {before} -> {len(trades)}reconciled_only=True")
logger.info(f"查询到 {len(trades)} 条交易记录include_sync={include_sync}, reconciled_only={reconciled_only}")
# 格式化交易记录,添加平仓类型的中文显示
formatted_trades = []
for trade in trades[:limit]:
formatted_trade = dict(trade)
# 将 exit_reason 转换为中文显示
exit_reason = trade.get('exit_reason', '')
if exit_reason:
exit_reason_map = {
'manual': '手动平仓',
'stop_loss': '自动平仓(止损)',
'take_profit': '自动平仓(止盈)',
'trailing_stop': '自动平仓(移动止损)',
'sync': '同步平仓'
}
formatted_trade['exit_reason_display'] = exit_reason_map.get(exit_reason, exit_reason)
else:
formatted_trade['exit_reason_display'] = ''
# 入场思路 entry_context 可能从 DB 以 JSON 字符串返回,解析为对象便于前端/分析使用
if formatted_trade.get('entry_context') is not None and isinstance(formatted_trade['entry_context'], str):
try:
formatted_trade['entry_context'] = json.loads(formatted_trade['entry_context'])
except Exception:
pass
formatted_trades.append(formatted_trade)
result = {
"total": len(trades),
"trades": formatted_trades,
"filters": {
"start_timestamp": start_timestamp,
"end_timestamp": end_timestamp,
"start_date": datetime.fromtimestamp(start_timestamp).strftime('%Y-%m-%d %H:%M:%S') if start_timestamp else None,
"end_date": datetime.fromtimestamp(end_timestamp).strftime('%Y-%m-%d %H:%M:%S') if end_timestamp else None,
"period": period,
"symbol": symbol,
"status": status,
"reconciled_only": reconciled_only,
}
}
logger.debug(f"返回交易记录: {len(result['trades'])} 条 (限制: {limit})")
return result
except Exception as e:
logger.error(f"获取交易记录失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats")
async def get_trade_stats(
account_id: int = Depends(get_account_id),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d', '7d', '30d', 'today', 'week', 'month'"),
symbol: Optional[str] = Query(None, description="交易对筛选"),
time_filter: str = Query("exit", description="时间范围按哪种时间: 'exit'(按平仓时间,今日统计=今日平仓的盈亏), 'entry'(按开仓时间), 'both'"),
include_sync: bool = Query(False, description="是否包含 entry_reason 为 sync_recovered 的历史同步单"),
reconciled_only: bool = Query(True, description="仅统计可对账记录,与币安一致,避免系统盈利/币安亏损偏差"),
):
"""获取交易统计(默认按平仓时间统计:今日=今日平仓的盈亏,与订单记录筛选一致)"""
try:
logger.info(f"获取交易统计请求: start_date={start_date}, end_date={end_date}, period={period}, symbol={symbol}")
start_timestamp = None
end_timestamp = None
# 如果提供了 period使用快速时间段筛选
if period:
period_start, period_end = get_timestamp_range(period)
if period_start is not None and period_end is not None:
start_timestamp = period_start
end_timestamp = period_end
elif start_date or end_date:
# 自定义时间段将日期字符串转换为Unix时间戳
beijing_tz = timezone(timedelta(hours=8))
if start_date:
if len(start_date) == 10: # YYYY-MM-DD
start_date = f"{start_date} 00:00:00"
try:
dt = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=beijing_tz)
start_timestamp = int(dt.timestamp())
except ValueError:
logger.warning(f"无效的开始日期格式: {start_date}")
if end_date:
if len(end_date) == 10: # YYYY-MM-DD
end_date = f"{end_date} 23:59:59"
try:
dt = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=beijing_tz)
end_timestamp = int(dt.timestamp())
except ValueError:
logger.warning(f"无效的结束日期格式: {end_date}")
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, None, account_id=account_id, time_filter=time_filter or "exit")
if not include_sync:
trades = [
t for t in trades
if (t.get("entry_reason") or "") != "sync_recovered"
and (t.get("exit_reason") or "") != "sync"
]
if reconciled_only:
before = len(trades)
def _has_eid(t):
eid = t.get("entry_order_id")
return eid is not None and eid != "" and (eid != 0 if isinstance(eid, (int, float)) else True)
def _has_xid(t):
xid = t.get("exit_order_id")
return xid is not None and xid != "" and (xid != 0 if isinstance(xid, (int, float)) else True)
trades = [
t for t in trades
if _has_eid(t) and (t.get("status") != "closed" or _has_xid(t))
]
logger.info(f"统计可对账过滤: {before} -> {len(trades)}reconciled_only=True")
closed_trades = [t for t in trades if t['status'] == 'closed']
# 辅助函数:计算净盈亏(优先使用 realized_pnl - commission
def get_net_pnl(t):
pnl = float(t.get('pnl') or 0)
realized = t.get('realized_pnl')
if realized is not None:
pnl = float(realized)
commission = float(t.get('commission') or 0)
commission_asset = t.get('commission_asset')
# 如果手续费是 USDT则扣除
if commission_asset == 'USDT':
pnl -= commission
return pnl
# 排除0盈亏的订单abs(pnl) < 0.01 USDT视为0盈亏这些订单不应该影响胜率统计
ZERO_PNL_THRESHOLD = 0.01 # 0.01 USDT的阈值小于此值视为0盈亏
meaningful_trades = [t for t in closed_trades if abs(get_net_pnl(t)) >= ZERO_PNL_THRESHOLD]
zero_pnl_trades = [t for t in closed_trades if abs(get_net_pnl(t)) < ZERO_PNL_THRESHOLD]
# 只统计有意义的交易排除0盈亏的胜率
win_trades = [t for t in meaningful_trades if get_net_pnl(t) > 0]
loss_trades = [t for t in meaningful_trades if get_net_pnl(t) < 0]
# 盈利/亏损均值(用于观察是否接近 3:1
avg_win_pnl = sum(get_net_pnl(t) for t in win_trades) / len(win_trades) if win_trades else 0.0
avg_loss_pnl_abs = (
sum(abs(get_net_pnl(t)) for t in loss_trades) / len(loss_trades) if loss_trades else 0.0
)
win_loss_ratio = (avg_win_pnl / avg_loss_pnl_abs) if avg_loss_pnl_abs > 0 else None
# 实际盈亏比(所有盈利单的总盈利 / 所有亏损单的总亏损,必须 > 1.5,目标 2.5-3.0
total_win_pnl = sum(get_net_pnl(t) for t in win_trades) if win_trades else 0.0
total_loss_pnl_abs = sum(abs(get_net_pnl(t)) for t in loss_trades) if loss_trades else 0.0
actual_profit_loss_ratio = (total_win_pnl / total_loss_pnl_abs) if total_loss_pnl_abs > 0 else None
# 盈利因子(总盈利金额 / 总亏损金额,必须 > 1.1,目标 1.5+
profit_factor = (total_win_pnl / total_loss_pnl_abs) if total_loss_pnl_abs > 0 else None
# 平仓原因分布(用来快速定位胜率低的主要来源:止损/止盈/同步等)
exit_reason_counts = Counter((t.get("exit_reason") or "unknown") for t in meaningful_trades)
# 平均持仓时长(分钟):
# - 优先使用 duration_minutes若历史没写入则用 exit_time-entry_time 实时计算)
durations = []
for t in meaningful_trades:
dm = t.get("duration_minutes")
if dm is not None:
try:
dm_f = float(dm)
if dm_f >= 0:
durations.append(dm_f)
continue
except Exception:
pass
et = t.get("entry_time")
xt = t.get("exit_time")
try:
if et is None or xt is None:
continue
et_i = int(et)
xt_i = int(xt)
if xt_i >= et_i:
durations.append((xt_i - et_i) / 60.0)
except Exception:
continue
avg_duration_minutes = (sum(durations) / len(durations)) if durations else None
stats = {
"total_trades": len(trades),
"closed_trades": len(closed_trades),
"open_trades": len(trades) - len(closed_trades),
"meaningful_trades": len(meaningful_trades), # 有意义的交易数排除0盈亏
"zero_pnl_trades": len(zero_pnl_trades), # 0盈亏交易数
"win_trades": len(win_trades),
"loss_trades": len(loss_trades),
"win_rate": len(win_trades) / len(meaningful_trades) * 100 if meaningful_trades else 0, # 基于有意义的交易计算胜率
"total_pnl": sum(get_net_pnl(t) for t in closed_trades),
"avg_pnl": sum(get_net_pnl(t) for t in closed_trades) / len(closed_trades) if closed_trades else 0,
# 额外统计:盈利单均值 vs 亏损单均值(绝对值)以及比值(目标 3:1
"avg_win_pnl": avg_win_pnl,
"avg_loss_pnl_abs": avg_loss_pnl_abs,
"avg_win_loss_ratio": win_loss_ratio,
"avg_win_loss_ratio_target": 3.0,
# 实际盈亏比(所有盈利单总盈利 / 所有亏损单总亏损,目标 > 2.0
"actual_profit_loss_ratio": actual_profit_loss_ratio,
"actual_profit_loss_ratio_target": 2.0,
"total_win_pnl": total_win_pnl,
"total_loss_pnl_abs": total_loss_pnl_abs,
# 盈利因子(总盈利 / 总亏损,目标 > 1.2
"profit_factor": profit_factor,
"profit_factor_target": 1.2,
"exit_reason_counts": dict(exit_reason_counts),
"avg_duration_minutes": avg_duration_minutes,
# 总交易量(名义下单量口径):优先使用 notional_usdt新字段否则回退 entry_price * quantity
"total_notional_usdt": sum(
float(t.get('notional_usdt') or (float(t.get('entry_price', 0)) * float(t.get('quantity', 0))))
for t in trades
),
"filters": {
"start_timestamp": start_timestamp,
"end_timestamp": end_timestamp,
"start_date": datetime.fromtimestamp(start_timestamp).strftime('%Y-%m-%d %H:%M:%S') if start_timestamp else None,
"end_date": datetime.fromtimestamp(end_timestamp).strftime('%Y-%m-%d %H:%M:%S') if end_timestamp else None,
"period": period,
"symbol": symbol,
"reconciled_only": reconciled_only,
}
}
logger.info(
f"交易统计: 总交易数={stats['total_trades']}, 已平仓={stats['closed_trades']}, "
f"有意义交易={stats['meaningful_trades']}, 0盈亏交易={stats['zero_pnl_trades']}, "
f"胜率={stats['win_rate']:.2f}%, 总盈亏={stats['total_pnl']:.2f} USDT"
)
return stats
except Exception as e:
logger.error(f"获取交易统计失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync-binance")
async def sync_trades_from_binance(
account_id: int = Depends(get_account_id),
days: int = Query(7, ge=1, le=30, description="同步最近N天的订单"),
):
"""
从币安同步历史订单,补全 DB 中缺失的 exit_order_id / 平仓信息。
**WS 已接入后**:开仓/平仓订单号主要由 User Data Stream 回写,此接口仅作**冷启动或补漏**,建议降低调用频率。
仅对「DB 中近期有记录的 symbol」拉取订单避免全市场逐 symbol 请求。
"""
try:
logger.info(f"开始从币安同步历史订单(最近{days}天)...")
# 导入必要的模块
trading_system_path = project_root / 'trading_system'
if not trading_system_path.exists():
alternative_path = project_root / 'backend' / 'trading_system'
if alternative_path.exists():
trading_system_path = alternative_path
else:
raise HTTPException(status_code=500, detail="交易系统模块不存在")
sys.path.insert(0, str(trading_system_path))
sys.path.insert(0, str(project_root))
from binance_client import BinanceClient
import config
from database.models import DEFAULT_ACCOUNT_ID
# 计算时间范围(秒,供 DB 查询)
end_time_ms = int(time.time() * 1000)
start_time_ms = int((datetime.now() - timedelta(days=days)).timestamp() * 1000)
start_ts_sec = start_time_ms // 1000
end_ts_sec = end_time_ms // 1000
# 仅对 DB 中在时间范围内有 open/closed 记录的 symbol 拉取订单WS 已回写订单号,大幅减少请求)
symbol_list = []
try:
trades_in_range = Trade.get_all(
start_timestamp=start_ts_sec,
end_timestamp=end_ts_sec,
account_id=account_id or DEFAULT_ACCOUNT_ID,
)
symbol_list = list({t.get("symbol") for t in (trades_in_range or []) if t.get("symbol")})
except Exception as e:
logger.warning(f"从 DB 获取 symbol 列表失败,将跳过同步: {e}")
return {
"success": True,
"message": "未获取到需同步的交易对跳过WS 正常时订单号已由推送回写)",
"total_orders": 0,
"updated_trades": 0,
"close_orders": 0,
"open_orders": 0,
}
if not symbol_list:
logger.info("DB 内在时间范围内无交易记录跳过全量拉取订单WS 为主时无需频繁同步)")
return {
"success": True,
"message": "近期无交易记录未请求币安订单WS 正常时订单号已由推送回写",
"total_orders": 0,
"updated_trades": 0,
"close_orders": 0,
"open_orders": 0,
}
# 初始化客户端
client = BinanceClient(
api_key=config.BINANCE_API_KEY,
api_secret=config.BINANCE_API_SECRET,
testnet=config.USE_TESTNET
)
await client.connect()
try:
# 仅对上述 symbol 拉取订单
all_orders = []
try:
logger.info(f"仅对 {len(symbol_list)} 个有 DB 记录的 symbol 拉取订单(已跳过全市场请求)")
for symbol in symbol_list:
try:
orders = await client.client.futures_get_all_orders(
symbol=symbol,
startTime=start_time_ms,
endTime=end_time_ms
)
filled_orders = [o for o in orders if o.get('status') == 'FILLED']
all_orders.extend(filled_orders)
await asyncio.sleep(0.1)
except Exception as e:
logger.debug(f"获取 {symbol} 订单失败: {e}")
continue
logger.info(f"从币安获取到 {len(all_orders)} 个已成交订单")
except Exception as e:
logger.error(f"获取币安订单失败: {e}")
raise HTTPException(status_code=500, detail=f"获取币安订单失败: {str(e)}")
# 同步订单到数据库(仅当前账号)
synced_count = 0
updated_count = 0
# 按时间排序,从旧到新
all_orders.sort(key=lambda x: x.get('time', 0))
for order in all_orders:
symbol = order.get('symbol')
order_id = order.get('orderId')
side = order.get('side')
quantity = float(order.get('executedQty', 0))
avg_price = float(order.get('avgPrice', 0))
order_time = datetime.fromtimestamp(order.get('time', 0) / 1000)
reduce_only = order.get('reduceOnly', False)
otype = str(order.get('type') or order.get('origType') or '').upper()
exit_time_ts = None
try:
ms = order.get('updateTime') or order.get('time')
if ms:
exit_time_ts = int(int(ms) / 1000)
except Exception:
exit_time_ts = None
if quantity <= 0 or avg_price <= 0:
continue
try:
if reduce_only:
# 这是平仓订单
# 首先检查是否已经通过订单号同步过(避免重复)
existing_trade = Trade.get_by_exit_order_id(order_id)
if existing_trade and (existing_trade.get("exit_reason") not in (None, "", "sync")):
logger.debug(f"订单 {order_id} 已同步过且 exit_reason={existing_trade.get('exit_reason')},跳过")
continue
# 查找数据库中该交易对的 open 状态记录(仅当前账号)
open_trades = Trade.get_by_symbol(symbol, status='open', account_id=account_id or DEFAULT_ACCOUNT_ID)
if existing_trade or open_trades:
# 找到匹配的交易记录通过symbol匹配如果有多个则取最近的
trade = existing_trade or open_trades[0] # 取第一个
trade_id = trade['id']
# 计算盈亏
entry_price = float(trade['entry_price'])
entry_quantity = float(trade['quantity'])
# 使用实际成交数量(可能部分平仓)
actual_quantity = min(quantity, entry_quantity)
if trade['side'] == 'BUY':
pnl = (avg_price - entry_price) * actual_quantity
pnl_percent = ((avg_price - entry_price) / entry_price) * 100
else: # SELL
pnl = (entry_price - avg_price) * actual_quantity
pnl_percent = ((entry_price - avg_price) / entry_price) * 100
# 细分 exit_reason优先使用币安订单类型其次用价格接近止损/止盈做兜底
exit_reason = "sync"
# 检查订单的 reduceOnly 字段:如果是 true说明是自动平仓不应该标记为 manual
is_reduce_only = order.get("reduceOnly", False) if isinstance(order, dict) else False
if "TRAILING" in otype:
exit_reason = "trailing_stop"
elif "TAKE_PROFIT" in otype:
exit_reason = "take_profit"
elif "STOP" in otype:
exit_reason = "stop_loss"
elif otype in ("MARKET", "LIMIT"):
# 如果是 reduceOnly 订单,说明是自动平仓(可能是保护单触发的),先标记为 sync后续用价格判断
if is_reduce_only:
exit_reason = "sync" # 临时标记,后续用价格判断
else:
exit_reason = "manual" # 非 reduceOnly 的 MARKET/LIMIT 订单才是真正的手动平仓
try:
def _close_to(a: float, b: float, max_pct: float = 0.02) -> bool: # 放宽到2%,因为滑点可能导致价格不完全一致
if a <= 0 or b <= 0:
return False
return abs((a - b) / b) <= max_pct
ep = float(avg_price or 0)
if ep > 0:
sl = trade.get("stop_loss_price")
tp = trade.get("take_profit_price")
tp1 = trade.get("take_profit_1")
tp2 = trade.get("take_profit_2")
# 优先检查止损
if sl is not None and _close_to(ep, float(sl), max_pct=0.02):
exit_reason = "stop_loss"
# 然后检查止盈
elif tp is not None and _close_to(ep, float(tp), max_pct=0.02):
exit_reason = "take_profit"
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.02):
exit_reason = "take_profit"
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.02):
exit_reason = "take_profit"
# 如果价格接近入场价,可能是移动止损触发的
elif is_reduce_only and exit_reason == "sync":
entry_price_val = float(trade.get("entry_price", 0) or 0)
if entry_price_val > 0 and _close_to(ep, entry_price_val, max_pct=0.01):
exit_reason = "trailing_stop"
except Exception:
pass
# 从币安成交获取手续费与实际盈亏,保证统计与币安一致
sync_commission = None
sync_commission_asset = None
sync_realized_pnl = None
try:
recent_trades = await client.get_recent_trades(symbol, limit=30)
related = [t for t in recent_trades if str(t.get('orderId')) == str(order_id)]
if related:
sync_commission = sum(float(t.get('commission', 0)) for t in related)
assets = {t.get('commissionAsset') for t in related if t.get('commissionAsset')}
sync_commission_asset = "/".join(assets) if assets else None
sync_realized_pnl = sum(float(t.get('realizedPnl', 0)) for t in related)
except Exception as fee_err:
logger.debug(f"同步订单 {order_id} 手续费失败: {fee_err}")
# 持仓持续时间(分钟)
duration_minutes = None
try:
et = trade.get("entry_time")
if et is not None and exit_time_ts is not None:
et_i = int(et)
if exit_time_ts >= et_i:
duration_minutes = int((exit_time_ts - et_i) / 60)
except Exception:
duration_minutes = None
# 更新数据库(包含订单号、手续费与实际盈亏)
Trade.update_exit(
trade_id=trade_id,
exit_price=avg_price,
exit_reason=exit_reason,
pnl=pnl,
pnl_percent=pnl_percent,
exit_order_id=order_id, # 保存订单号,确保唯一性
duration_minutes=duration_minutes,
exit_time_ts=exit_time_ts,
commission=sync_commission,
commission_asset=sync_commission_asset,
realized_pnl=sync_realized_pnl,
)
updated_count += 1
logger.debug(
f"✓ 更新平仓记录: {symbol} (ID: {trade_id}, 订单号: {order_id}, "
f"类型: {otype or '-'}, 原因: {exit_reason}, 成交价: {avg_price:.4f})"
)
else:
# 这是开仓订单,检查数据库中是否已存在(通过订单号)
existing_trade = Trade.get_by_entry_order_id(order_id)
if not existing_trade:
# 如果不存在,可以创建新记录(但需要更多信息,暂时跳过)
logger.debug(f"发现新的开仓订单 {order_id},但缺少必要信息,跳过创建")
else:
logger.debug(f"开仓订单 {order_id} 已存在,跳过")
except Exception as e:
logger.warning(f"同步订单失败 {symbol} (订单ID: {order_id}): {e}")
continue
result = {
"success": True,
"message": f"同步完成:更新了 {updated_count} 条平仓记录(基于订单号匹配,确保唯一性)",
"total_orders": len(all_orders),
"updated_trades": updated_count,
"close_orders": len([o for o in all_orders if o.get('reduceOnly', False)]),
"open_orders": len([o for o in all_orders if not o.get('reduceOnly', False)])
}
logger.info(f"✓ 同步完成:处理了 {len(all_orders)} 个订单,更新了 {updated_count} 条记录")
return result
finally:
await client.disconnect()
except HTTPException:
raise
except Exception as e:
logger.error(f"同步币安订单失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"同步币安订单失败: {str(e)}")
@router.get("/verify-binance")
async def verify_trades_against_binance(
account_id: int = Depends(get_account_id),
days: int = Query(30, ge=1, le=90, description="校验最近 N 天的记录,默认 30"),
limit: int = Query(100, ge=1, le=500, description="最多校验条数,默认 100"),
):
"""
用币安订单接口逐条校验本账号「可对账」交易记录的准确性,便于把握策略执行分析所依赖的订单数据是否与交易所一致。
- 只校验有 entry_order_id 的记录(已平仓的还会校验 exit_order_id
- 每条记录会请求币安 futures_get_order 核对订单是否存在、symbol/side/数量是否一致。
- 返回汇总(一致/缺失/不一致数量)与明细,便于排查对不上的记录。
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
end_ts = int(now.timestamp())
start_ts = end_ts - days * 24 * 3600
trades = Trade.get_all(
account_id=account_id,
start_timestamp=start_ts,
end_timestamp=end_ts,
)
# 只校验「可对账」记录:有 entry_order_id若已平仓则还须有 exit_order_id
def _has_entry(eid):
return eid is not None and str(eid).strip() not in ("", "0")
def _has_exit(xid):
return xid is not None and str(xid).strip() not in ("", "0")
to_verify = [
t for t in trades
if _has_entry(t.get("entry_order_id"))
and (t.get("status") != "closed" or _has_exit(t.get("exit_order_id")))
][:limit]
if not to_verify:
return {
"success": True,
"account_id": account_id,
"summary": {"total_verified": 0, "entry_ok": 0, "entry_missing": 0, "entry_mismatch": 0, "exit_ok": 0, "exit_missing": 0, "exit_mismatch": 0},
"details": [],
"message": "该时间范围内没有可对账记录(需有 entry_order_id已平仓需有 exit_order_id",
}
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
raise HTTPException(status_code=400, detail=f"账号 {account_id} 未配置 API 密钥,无法请求币安")
trading_system_path = project_root / "trading_system"
if not trading_system_path.exists():
trading_system_path = project_root / "backend" / "trading_system"
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(trading_system_path))
try:
from binance_client import BinanceClient
except ImportError:
raise HTTPException(status_code=500, detail="无法导入 BinanceClient")
client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet)
await client.connect()
try:
summary = {"total_verified": 0, "entry_ok": 0, "entry_missing": 0, "entry_mismatch": 0, "exit_ok": 0, "exit_missing": 0, "exit_mismatch": 0}
details = []
for t in to_verify:
tid = t.get("id")
symbol = t.get("symbol") or ""
eid = t.get("entry_order_id")
xid = t.get("exit_order_id")
status_t = t.get("status") or "open"
side_t = t.get("side") or ""
qty_t = float(t.get("quantity") or 0)
entry_price_t = float(t.get("entry_price") or 0)
row = {
"trade_id": tid,
"symbol": symbol,
"side": side_t,
"status": status_t,
"entry_order_id": eid,
"exit_order_id": xid,
"entry_verified": None,
"entry_message": None,
"exit_verified": None,
"exit_message": None,
}
# 校验开仓订单
if _has_entry(eid):
summary["total_verified"] += 1
try:
order = await client.client.futures_get_order(symbol=symbol, orderId=int(eid))
if not order:
summary["entry_missing"] += 1
row["entry_verified"] = False
row["entry_message"] = "币安未返回订单"
else:
ob_side = (order.get("side") or "").upper()
ob_qty = float(order.get("origQty") or order.get("executedQty") or 0)
ob_price = float(order.get("avgPrice") or 0)
if ob_side != side_t or abs(ob_qty - qty_t) > 1e-8:
summary["entry_mismatch"] += 1
row["entry_verified"] = False
row["entry_message"] = f"币安 side={ob_side} qty={ob_qty}DB side={side_t} qty={qty_t}"
else:
summary["entry_ok"] += 1
row["entry_verified"] = True
row["entry_message"] = "一致"
except Exception as ex:
err = str(ex)
if "Unknown order" in err or "-2011" in err or "404" in err.lower():
summary["entry_missing"] += 1
row["entry_verified"] = False
row["entry_message"] = "币安无此订单"
else:
summary["entry_mismatch"] += 1
row["entry_verified"] = False
row["entry_message"] = err[:200]
# 校验平仓订单(仅已平仓且存在 exit_order_id
if status_t == "closed" and _has_exit(xid):
try:
order = await client.client.futures_get_order(symbol=symbol, orderId=int(xid))
if not order:
summary["exit_missing"] += 1
row["exit_verified"] = False
row["exit_message"] = "币安未返回订单"
else:
ob_side = (order.get("side") or "").upper()
ob_qty = float(order.get("executedQty") or order.get("origQty") or 0)
if ob_side != side_t or abs(ob_qty - qty_t) > 1e-8:
summary["exit_mismatch"] += 1
row["exit_verified"] = False
row["exit_message"] = f"币安 side={ob_side} qty={ob_qty}DB side={side_t} qty={qty_t}"
elif not order.get("reduceOnly"):
summary["exit_mismatch"] += 1
row["exit_verified"] = False
row["exit_message"] = "币安订单非 reduceOnly非平仓单"
else:
summary["exit_ok"] += 1
row["exit_verified"] = True
row["exit_message"] = "一致"
except Exception as ex:
err = str(ex)
if "Unknown order" in err or "-2011" in err or "404" in err.lower():
summary["exit_missing"] += 1
row["exit_verified"] = False
row["exit_message"] = "币安无此订单"
else:
summary["exit_mismatch"] += 1
row["exit_verified"] = False
row["exit_message"] = err[:200]
details.append(row)
await asyncio.sleep(0.05)
return {
"success": True,
"account_id": account_id,
"summary": summary,
"details": details,
"message": f"已校验 {summary['total_verified']} 条开仓订单,开仓一致 {summary['entry_ok']}、缺失 {summary['entry_missing']}、不一致 {summary['entry_mismatch']};平仓一致 {summary['exit_ok']}、缺失 {summary['exit_missing']}、不一致 {summary['exit_mismatch']}",
}
finally:
await client.disconnect()