diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 31d8663..b44aaa5 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -286,8 +286,8 @@ async def ensure_all_positions_sltp( 批量补挂当前所有持仓的止盈止损保护单。 """ # 先拿当前持仓symbol列表 - api_key, api_secret, use_testnet = Account.get_credentials(account_id) - if not api_key or not api_secret: + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) + if (not api_key or not api_secret) and status == "active": logger.error(f"[account_id={account_id}] API密钥未配置") raise HTTPException(status_code=400, detail=f"API密钥未配置(account_id={account_id})") @@ -351,7 +351,7 @@ async def get_realtime_account_data(account_id: int = 1): try: # 从 accounts 表读取账号私有API密钥 logger.info(f"步骤1: 从accounts读取API配置... (account_id={account_id})") - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) logger.info(f" - API密钥存在: {bool(api_key)}") if api_key: @@ -565,7 +565,7 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): """获取实时持仓数据""" client = None try: - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) logger.info(f"尝试获取实时持仓数据 (testnet={use_testnet}, account_id={account_id})") @@ -738,9 +738,9 @@ async def close_position(symbol: str, account_id: int = Depends(get_account_id)) logger.info(f"收到平仓请求: {symbol}") logger.info(f"=" * 60) - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) - if not api_key or not api_secret: + if (not api_key or not api_secret) and status == "active": error_msg = f"API密钥未配置(account_id={account_id})" logger.warning(f"[account_id={account_id}] {error_msg}") raise HTTPException(status_code=400, detail=error_msg) @@ -1041,6 +1041,169 @@ async def close_position(symbol: str, account_id: int = Depends(get_account_id)) raise HTTPException(status_code=500, detail=error_msg) +@router.post("/positions/close-all") +async def close_all_positions(account_id: int = Depends(get_account_id)): + """一键全平:平仓所有持仓""" + try: + logger.info("=" * 60) + logger.info("收到一键全平请求") + logger.info("=" * 60) + + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) + + if (not api_key or not api_secret) and status == "active": + error_msg = f"API密钥未配置(account_id={account_id})" + logger.warning(f"[account_id={account_id}] {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + + # 导入必要的模块 + try: + from binance_client import BinanceClient + logger.info("✓ 成功导入交易系统模块") + except ImportError as import_error: + logger.warning(f"首次导入失败: {import_error},尝试从trading_system路径导入") + trading_system_path = project_root / 'trading_system' + sys.path.insert(0, str(trading_system_path)) + from binance_client import BinanceClient + logger.info("✓ 从trading_system路径导入成功") + + # 导入数据库模型 + from database.models import Trade + + # 创建客户端 + logger.info(f"创建BinanceClient (testnet={use_testnet})...") + client = BinanceClient( + api_key=api_key, + api_secret=api_secret, + testnet=use_testnet + ) + + logger.info("连接币安API...") + await client.connect() + logger.info("✓ 币安API连接成功") + + try: + # 获取所有持仓 + positions = await client.get_open_positions() + if not positions: + logger.info("当前没有持仓") + return { + "message": "当前没有持仓", + "closed": 0, + "failed": 0, + "results": [] + } + + logger.info(f"发现 {len(positions)} 个持仓,开始逐一平仓...") + + results = [] + closed_count = 0 + failed_count = 0 + + for position in positions: + symbol = position.get('symbol') + position_amt = float(position.get('positionAmt', 0)) + + if abs(position_amt) <= 0: + continue + + try: + logger.info(f"开始平仓 {symbol} (数量: {position_amt})...") + + # 确定平仓方向 + side = 'SELL' if position_amt > 0 else 'BUY' + + # 使用市价单平仓 + order = await client.place_order( + symbol=symbol, + side=side, + order_type='MARKET', + quantity=abs(position_amt), + reduce_only=True + ) + + if order and order.get('orderId'): + logger.info(f"✓ {symbol} 平仓订单已提交: {order.get('orderId')}") + + # 获取成交价格 + exit_price = float(order.get('avgPrice', 0)) or float(order.get('price', 0)) + if not exit_price: + # 如果订单中没有价格,获取当前价格 + ticker = await client.get_ticker_24h(symbol) + exit_price = float(ticker['price']) if ticker else 0 + + # 更新数据库记录 + open_trades = Trade.get_by_symbol(symbol, status='open') + for trade in open_trades: + entry_price = float(trade['entry_price']) + quantity = float(trade['quantity']) + + if trade['side'] == 'BUY': + pnl = (exit_price - entry_price) * quantity + pnl_percent = ((exit_price - entry_price) / entry_price) * 100 + else: + pnl = (entry_price - exit_price) * quantity + pnl_percent = ((entry_price - exit_price) / entry_price) * 100 + + Trade.update_exit( + trade_id=trade['id'], + exit_price=exit_price, + exit_reason='manual', + pnl=pnl, + pnl_percent=pnl_percent, + exit_order_id=order.get('orderId') + ) + logger.info(f"✓ 已更新数据库记录 trade_id={trade['id']} (盈亏: {pnl:.2f} USDT)") + + closed_count += 1 + results.append({ + "symbol": symbol, + "status": "success", + "order_id": order.get('orderId'), + "message": f"{symbol} 平仓成功" + }) + else: + logger.warning(f"⚠ {symbol} 平仓订单提交失败") + failed_count += 1 + results.append({ + "symbol": symbol, + "status": "failed", + "message": f"{symbol} 平仓失败: 订单未提交" + }) + + except Exception as e: + logger.error(f"❌ {symbol} 平仓失败: {e}") + failed_count += 1 + results.append({ + "symbol": symbol, + "status": "failed", + "message": f"{symbol} 平仓失败: {str(e)}" + }) + + logger.info(f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}") + return { + "message": f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}", + "closed": closed_count, + "failed": failed_count, + "results": results + } + + finally: + logger.info("断开币安API连接...") + await client.disconnect() + logger.info("✓ 已断开连接") + + except HTTPException: + raise + except Exception as e: + error_msg = f"一键全平失败: {str(e)}" + logger.error("=" * 60) + logger.error(f"一键全平操作异常: {error_msg}") + logger.error(f"错误类型: {type(e).__name__}") + logger.error("=" * 60, exc_info=True) + raise HTTPException(status_code=500, detail=error_msg) + + @router.post("/positions/{symbol}/open") async def open_position_from_recommendation( symbol: str, @@ -1068,9 +1231,9 @@ async def open_position_from_recommendation( if entry_price <= 0 or stop_loss_price <= 0: raise HTTPException(status_code=400, detail="入场价和止损价必须大于0") - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) - if not api_key or not api_secret: + if (not api_key or not api_secret) and status == "active": error_msg = f"API密钥未配置(account_id={account_id})" logger.warning(f"[account_id={account_id}] {error_msg}") raise HTTPException(status_code=400, detail=error_msg) @@ -1247,9 +1410,9 @@ async def sync_positions(account_id: int = Depends(get_account_id)): logger.info("收到持仓状态同步请求") logger.info("=" * 60) - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) - if not api_key or not api_secret: + if (not api_key or not api_secret) and status == "active": error_msg = f"API密钥未配置(account_id={account_id})" logger.warning(f"[account_id={account_id}] {error_msg}") raise HTTPException(status_code=400, detail=error_msg) diff --git a/backend/api/routes/accounts.py b/backend/api/routes/accounts.py index 2279c33..5ee7633 100644 --- a/backend/api/routes/accounts.py +++ b/backend/api/routes/accounts.py @@ -99,12 +99,12 @@ async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> Lis if not r: continue # 普通用户:不返回密钥明文,但返回“是否已配置”的状态,方便前端提示 - api_key, api_secret, use_testnet = Account.get_credentials(int(aid)) + api_key, api_secret, use_testnet, status = Account.get_credentials(int(aid)) out.append( { "id": int(aid), "name": r.get("name") or "", - "status": r.get("status") or "active", + "status": status or r.get("status") or "active", "use_testnet": bool(use_testnet), "role": membership_map.get(int(aid), "viewer"), "has_api_key": bool(api_key), diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index fea26a3..5287fe3 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -210,9 +210,9 @@ async def get_all_configs( # 合并账号级 API Key/Secret(从 accounts 表读,避免把密钥当普通配置存) try: - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) except Exception: - api_key, api_secret, use_testnet = "", "", False + api_key, api_secret, use_testnet, status = "", "", False, "active" # 仅用于配置页展示/更新:不返回 secret 明文;api_key 仅脱敏展示 result["BINANCE_API_KEY"] = { "value": _mask(api_key or ""), @@ -733,7 +733,7 @@ async def get_config( try: # 虚拟字段:从 accounts 表读取 if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}: - api_key, api_secret, use_testnet = Account.get_credentials(account_id) + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) if key == "BINANCE_API_KEY": return {"key": key, "value": _mask(api_key or ""), "type": "string", "category": "api", "description": "币安API密钥(仅脱敏展示)"} if key == "BINANCE_API_SECRET": diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index dce2987..5dcb991 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -114,6 +114,47 @@ const StatsDashboard = () => { } } + const handleCloseAllPositions = async () => { + if (!window.confirm(`确定要一键全平所有持仓吗?\n\n这将使用市价单平仓所有持仓,请谨慎操作!`)) { + return + } + + setClosingSymbol('ALL') // 使用特殊标记表示全平操作 + setMessage('') + + try { + console.log('开始一键全平...') + const result = await api.closeAllPositions() + console.log('一键全平结果:', result) + + let message = result.message || '一键全平完成' + if (result.closed > 0 || result.failed > 0) { + message = `一键全平完成: 成功 ${result.closed} / 失败 ${result.failed}` + } + + setMessage(message) + + // 立即刷新数据 + await loadDashboard() + + // 5秒后清除消息 + setTimeout(() => { + setMessage('') + }, 5000) + } catch (error) { + console.error('Close all positions error:', error) + const errorMessage = error.message || error.toString() || '一键全平失败,请检查网络连接或后端服务' + setMessage(`一键全平失败: ${errorMessage}`) + + // 错误消息5秒后清除 + setTimeout(() => { + setMessage('') + }, 5000) + } finally { + setClosingSymbol(null) + } + } + const handleEnsureSLTP = async (symbol) => { if (!window.confirm(`确定要为 ${symbol} 补挂“币安止损/止盈保护单”吗?\n\n说明:将自动取消该交易对已有的 STOP/TP 保护单并重新挂单(避免重复)。`)) { return @@ -387,14 +428,35 @@ const StatsDashboard = () => {

当前持仓

- +
+ + +
限价入场: {entryTypeCounts.limit} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index bef05da..33be5ed 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -353,7 +353,7 @@ export const api = { // 平仓操作 closePosition: async (symbol) => { - const response = await fetch(buildUrl(`/api/accounts/positions/${symbol}/close`), { + const response = await fetch(buildUrl(`/api/account/positions/${symbol}/close`), { method: 'POST', headers: { ...withAccountHeaders({ 'Content-Type': 'application/json' }), @@ -365,6 +365,21 @@ export const api = { } return response.json(); }, + + // 一键全平(平仓所有持仓) + closeAllPositions: async () => { + const response = await fetch(buildUrl(`/api/account/positions/close-all`), { + method: 'POST', + headers: { + ...withAccountHeaders({ 'Content-Type': 'application/json' }), + }, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '一键全平失败' })); + throw new Error(error.detail || '一键全平失败'); + } + return response.json(); + }, // 补挂止盈止损(交易所保护单) ensurePositionSLTP: async (symbol) => {