From 5d7166d4048a81bf8d2938b0262bfdcc93b50367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Thu, 22 Jan 2026 19:52:46 +0800 Subject: [PATCH] a --- backend/api/routes/account.py | 198 ++++++++++++++++++++ frontend/src/components/Recommendations.jsx | 151 +++++++++++++++ frontend/src/services/api.js | 28 ++- 3 files changed, 373 insertions(+), 4 deletions(-) diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index a28153f..7fbfab1 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -1041,6 +1041,204 @@ async def close_position(symbol: str, account_id: int = Depends(get_account_id)) raise HTTPException(status_code=500, detail=error_msg) +@router.post("/positions/{symbol}/open") +async def open_position_from_recommendation( + symbol: str, + entry_price: float = Query(..., description="入场价格"), + stop_loss_price: float = Query(..., description="止损价格"), + direction: str = Query(..., description="交易方向: BUY 或 SELL"), + notional_usdt: float = Query(..., description="下单名义价值(USDT)"), + leverage: int = Query(10, description="杠杆倍数"), + account_id: int = Depends(get_account_id) +): + """根据推荐信息手动开仓""" + try: + logger.info("=" * 60) + logger.info(f"收到手动开仓请求: {symbol}") + logger.info(f" 入场价: {entry_price}, 止损价: {stop_loss_price}") + logger.info(f" 方向: {direction}, 名义价值: {notional_usdt} USDT, 杠杆: {leverage}x") + logger.info("=" * 60) + + if direction not in ('BUY', 'SELL'): + raise HTTPException(status_code=400, detail="交易方向必须是 BUY 或 SELL") + + if notional_usdt <= 0: + raise HTTPException(status_code=400, detail="下单名义价值必须大于0") + + 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) + + if not api_key or not api_secret: + 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 + except ImportError: + trading_system_path = project_root / 'trading_system' + sys.path.insert(0, str(trading_system_path)) + from binance_client import BinanceClient + + # 导入数据库模型 + 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: + # 设置杠杆 + await client.set_leverage(symbol, leverage) + logger.info(f"✓ 已设置杠杆: {leverage}x") + + # 获取交易对信息 + symbol_info = await client.get_symbol_info(symbol) + if not symbol_info: + raise HTTPException(status_code=400, detail=f"无法获取 {symbol} 的交易对信息") + + # 计算下单数量:数量 = 名义价值 / 入场价 + quantity = notional_usdt / entry_price + logger.info(f"计算下单数量: {quantity:.8f} (名义价值: {notional_usdt} USDT / 入场价: {entry_price})") + + # 调整数量精度 + adjusted_quantity = client._adjust_quantity_precision(quantity, symbol_info) + if adjusted_quantity <= 0: + raise HTTPException(status_code=400, detail=f"调整后的数量无效: {adjusted_quantity}") + + logger.info(f"调整后的数量: {adjusted_quantity:.8f}") + + # 检查最小名义价值 + min_notional = symbol_info.get('minNotional', 5.0) + actual_notional = adjusted_quantity * entry_price + if actual_notional < min_notional: + raise HTTPException( + status_code=400, + detail=f"订单名义价值不足: {actual_notional:.2f} USDT < 最小要求: {min_notional:.2f} USDT" + ) + + # 下 limit 订单 + logger.info(f"开始下 limit 订单: {symbol} {direction} {adjusted_quantity} @ {entry_price}") + order = await client.place_order( + symbol=symbol, + side=direction, + quantity=adjusted_quantity, + order_type='LIMIT', + price=entry_price, + reduce_only=False + ) + + if not order: + raise HTTPException(status_code=500, detail="下单失败:币安API返回None") + + order_id = order.get('orderId') + logger.info(f"✓ 订单已提交: orderId={order_id}") + + # 等待订单成交(最多等待30秒) + import asyncio + filled_order = None + for i in range(30): + await asyncio.sleep(1) + try: + order_status = await client.client.futures_get_order(symbol=symbol, orderId=order_id) + if order_status.get('status') == 'FILLED': + filled_order = order_status + logger.info(f"✓ 订单已成交: orderId={order_id}") + break + elif order_status.get('status') in ('CANCELED', 'EXPIRED', 'REJECTED'): + raise HTTPException(status_code=400, detail=f"订单未成交,状态: {order_status.get('status')}") + except Exception as e: + if i == 29: # 最后一次尝试 + logger.warning(f"订单状态查询失败或未成交: {e}") + continue + + if not filled_order: + logger.warning(f"订单 {order_id} 在30秒内未成交,但订单已提交") + return { + "message": f"{symbol} 订单已提交但未成交(请稍后检查)", + "symbol": symbol, + "order_id": order_id, + "status": "pending" + } + + # 订单已成交,保存到数据库 + avg_price = float(filled_order.get('avgPrice', entry_price)) + executed_qty = float(filled_order.get('executedQty', adjusted_quantity)) + + # 计算实际使用的名义价值和保证金 + actual_notional = executed_qty * avg_price + actual_margin = actual_notional / leverage + + # 保存交易记录 + trade_id = Trade.create( + account_id=account_id, + symbol=symbol, + side=direction, + quantity=executed_qty, + entry_price=avg_price, + leverage=leverage, + entry_order_id=order_id, + entry_reason='manual_from_recommendation', + notional_usdt=actual_notional, + margin_usdt=actual_margin, + stop_loss_price=stop_loss_price, + # 如果有推荐中的止盈价,也可以传入,这里先不传 + ) + + logger.info(f"✓ 交易记录已保存: trade_id={trade_id}") + + # 尝试挂止损/止盈保护单(如果系统支持) + try: + # 这里可以调用 _ensure_exchange_sltp_for_symbol 来挂保护单 + # 但需要先获取持仓信息来确定方向 + positions = await client.get_open_positions() + position = next((p for p in positions if p['symbol'] == symbol), None) + if position: + # 可以在这里挂止损单,但需要知道 take_profit_price + # 暂时只记录止损价到数据库,由系统自动监控 + logger.info(f"止损价已记录到数据库: {stop_loss_price}") + except Exception as e: + logger.warning(f"挂保护单失败(不影响开仓): {e}") + + return { + "message": f"{symbol} 开仓成功", + "symbol": symbol, + "order_id": order_id, + "trade_id": trade_id, + "quantity": executed_qty, + "entry_price": avg_price, + "notional_usdt": actual_notional, + "margin_usdt": actual_margin, + "status": "filled" + } + + 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/sync") async def sync_positions(account_id: int = Depends(get_account_id)): """同步币安实际持仓状态与数据库状态""" diff --git a/frontend/src/components/Recommendations.jsx b/frontend/src/components/Recommendations.jsx index 0b3d803..d10993e 100644 --- a/frontend/src/components/Recommendations.jsx +++ b/frontend/src/components/Recommendations.jsx @@ -17,6 +17,54 @@ function Recommendations() { const [generating, setGenerating] = useState(false) const [showDetails, setShowDetails] = useState({}) const [bookmarking, setBookmarking] = useState({}) // 记录正在标记的推荐ID + const [ordering, setOrdering] = useState({}) // 记录正在下单的推荐ID + + // 获取当前账号ID + const getCurrentAccountId = () => { + try { + const v = localStorage.getItem('ats_account_id'); + const n = parseInt(v || '1', 10); + return Number.isFinite(n) && n > 0 ? n : 1; + } catch (e) { + return 1; + } + }; + + // 获取默认下单量(按账号存储) + const getDefaultOrderSize = () => { + try { + const accountId = getCurrentAccountId(); + const key = `ats_default_order_size_${accountId}`; + const value = localStorage.getItem(key); + if (value) { + const size = parseFloat(value); + if (Number.isFinite(size) && size > 0) { + return size; + } + } + } catch (e) { + // ignore + } + return 1; // 默认1U + }; + + // 设置默认下单量(按账号存储) + const setDefaultOrderSize = (size) => { + try { + const accountId = getCurrentAccountId(); + const key = `ats_default_order_size_${accountId}`; + const sizeNum = parseFloat(String(size || '1')); + if (Number.isFinite(sizeNum) && sizeNum > 0) { + localStorage.setItem(key, String(sizeNum)); + return true; + } + } catch (e) { + // ignore + } + return false; + }; + + const [defaultOrderSize, setDefaultOrderSizeState] = useState(getDefaultOrderSize()); useEffect(() => { loadRecommendations() @@ -291,11 +339,92 @@ function Recommendations() { return 'signal-weak' } + const handleQuickOrder = async (rec) => { + const recKey = rec.symbol || rec.id; + if (!rec.suggested_limit_price && !rec.planned_entry_price) { + alert('该推荐没有入场价格,无法下单'); + return; + } + if (!rec.suggested_stop_loss) { + alert('该推荐没有止损价格,无法下单'); + return; + } + + const entryPrice = parseFloat(rec.suggested_limit_price || rec.planned_entry_price); + const stopLossPrice = parseFloat(rec.suggested_stop_loss); + const direction = rec.direction; + const notionalUsdt = defaultOrderSize; + const leverage = rec.suggested_leverage || 10; + + if (!window.confirm( + `确认下单?\n` + + `交易对: ${rec.symbol}\n` + + `方向: ${direction === 'BUY' ? '做多' : '做空'}\n` + + `入场价: ${entryPrice} USDT\n` + + `止损价: ${stopLossPrice} USDT\n` + + `下单量: ${notionalUsdt} USDT\n` + + `杠杆: ${leverage}x` + )) { + return; + } + + try { + setOrdering(prev => ({ ...prev, [recKey]: true })); + const result = await api.openPositionFromRecommendation( + rec.symbol, + entryPrice, + stopLossPrice, + direction, + notionalUsdt, + leverage + ); + alert(`下单成功!\n订单ID: ${result.order_id}\n交易ID: ${result.trade_id}`); + // 可以刷新推荐列表或更新状态 + } catch (err) { + alert(`下单失败: ${err.message}`); + console.error('下单失败:', err); + } finally { + setOrdering(prev => { + const newState = { ...prev }; + delete newState[recKey]; + return newState; + }); + } + }; + + const handleSetDefaultOrderSize = () => { + const newSize = window.prompt(`设置默认下单量(USDT)\n当前值: ${defaultOrderSize} USDT`, String(defaultOrderSize)); + if (newSize === null) return; + const size = parseFloat(newSize); + if (!Number.isFinite(size) || size <= 0) { + alert('请输入有效的正数'); + return; + } + if (setDefaultOrderSize(size)) { + setDefaultOrderSizeState(size); + alert(`默认下单量已设置为 ${size} USDT`); + } else { + alert('设置失败'); + } + }; + return (

交易推荐

+
+ 默认下单量: + {defaultOrderSize} USDT + +
+ )} 目标1 {fmtPrice(rec.suggested_take_profit_1)} USDT diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 34c3405..382e9cd 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -354,7 +354,7 @@ export const api = { // 平仓操作 closePosition: async (symbol) => { - const response = await fetch(buildUrl(`/api/account/positions/${symbol}/close`), { + const response = await fetch(buildUrl(`/api/accounts/positions/${symbol}/close`), { method: 'POST', headers: { ...withAccountHeaders({ 'Content-Type': 'application/json' }), @@ -369,7 +369,7 @@ export const api = { // 补挂止盈止损(交易所保护单) ensurePositionSLTP: async (symbol) => { - const response = await fetch(buildUrl(`/api/account/positions/${symbol}/sltp/ensure`), { + const response = await fetch(buildUrl(`/api/accounts/positions/${symbol}/sltp/ensure`), { method: 'POST', headers: withAccountHeaders({ 'Content-Type': 'application/json' }), }); @@ -381,7 +381,7 @@ export const api = { }, ensureAllPositionsSLTP: async (limit = 50) => { - const response = await fetch(buildUrl(`/api/account/positions/sltp/ensure-all?limit=${encodeURIComponent(limit)}`), { + const response = await fetch(buildUrl(`/api/accounts/positions/sltp/ensure-all?limit=${encodeURIComponent(limit)}`), { method: 'POST', headers: withAccountHeaders({ 'Content-Type': 'application/json' }), }); @@ -394,7 +394,7 @@ export const api = { // 同步持仓状态 syncPositions: async () => { - const response = await fetch(buildUrl('/api/account/positions/sync'), { + const response = await fetch(buildUrl('/api/accounts/positions/sync'), { method: 'POST', headers: { ...withAccountHeaders({ 'Content-Type': 'application/json' }), @@ -407,6 +407,26 @@ export const api = { return response.json(); }, + // 根据推荐信息手动开仓 + openPositionFromRecommendation: async (symbol, entryPrice, stopLossPrice, direction, notionalUsdt, leverage = 10) => { + const params = new URLSearchParams({ + entry_price: String(entryPrice), + stop_loss_price: String(stopLossPrice), + direction: direction, + notional_usdt: String(notionalUsdt), + leverage: String(leverage), + }); + const response = await fetch(buildUrl(`/api/account/positions/${symbol}/open?${params}`), { + 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(); + }, + // 交易推荐 getRecommendations: async (params = {}) => { // 默认使用实时推荐