From cbba86001a6cdfcd33bdb8330e8544fd023e8ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Wed, 25 Feb 2026 08:53:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(spot=5Forder):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=8E=B0=E8=B4=A7=E4=B8=8B=E5=8D=95API=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在后端API中新增现货下单功能,支持市价单和限价单的创建,并提供相应的错误处理机制。前端组件更新以支持现货下单的快速操作,允许用户选择现货市场并设置默认下单金额。此改动提升了用户体验,增强了交易系统的功能性与灵活性。 --- backend/api/routes/account.py | 67 +++++++ frontend/src/components/Recommendations.jsx | 196 +++++++++++++------- frontend/src/services/api.js | 27 ++- 3 files changed, 227 insertions(+), 63 deletions(-) diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 67d1400..5fb0207 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -1574,6 +1574,73 @@ async def open_position_from_recommendation( raise HTTPException(status_code=500, detail=error_msg) +@router.post("/spot/order") +async def place_spot_order( + symbol: str = Query(..., description="交易对,如 BTCUSDT"), + side: str = Query("BUY", description="BUY 或 SELL"), + quote_order_qty: float = Query(..., description="下单金额(USDT),市价单时使用"), + order_type: str = Query("MARKET", description="MARKET 或 LIMIT"), + price: float = Query(None, description="限价单价格(LIMIT 时必填)"), + account_id: int = Depends(get_account_id), +): + """现货一键下单(市价单或限价单)。仅支持当前账号 API 密钥对应的现货账户。""" + try: + if side not in ("BUY", "SELL"): + raise HTTPException(status_code=400, detail="side 必须是 BUY 或 SELL") + if quote_order_qty <= 0: + raise HTTPException(status_code=400, detail="下单金额必须大于 0") + if order_type.upper() == "LIMIT" and (price is None or price <= 0): + raise HTTPException(status_code=400, detail="限价单必须填写 price") + + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) + if (not api_key or not api_secret) and status == "active": + raise HTTPException(status_code=400, detail=f"API密钥未配置(account_id={account_id})") + + 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 + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + await client.connect() + try: + # 现货使用底层 AsyncClient 的 create_order(spot API) + if order_type.upper() == "MARKET": + order = await client.client.create_order( + symbol=symbol, + side=side, + type="MARKET", + quoteOrderQty=quote_order_qty, + ) + else: + # LIMIT: 需要 quantity,用 quote_order_qty / price 估算 + qty = quote_order_qty / price + order = await client.client.create_order( + symbol=symbol, + side=side, + type="LIMIT", + timeInForce="GTC", + quantity=round(qty, 8), + price=price, + ) + return { + "message": f"现货订单已提交: {symbol} {side}", + "symbol": symbol, + "order_id": order.get("orderId"), + "client_order_id": order.get("clientOrderId"), + "status": order.get("status"), + } + finally: + await client.disconnect() + except HTTPException: + raise + except Exception as e: + logger.exception("现货下单失败: %s", e) + raise HTTPException(status_code=500, detail=f"现货下单失败: {str(e)}") + + def _order_is_sltp(o: dict, type_key: str = "type") -> bool: """判断是否为止损/止盈类订单(含普通单与 Algo 条件单)""" t = str(o.get(type_key) or o.get("orderType") or "").upper() diff --git a/frontend/src/components/Recommendations.jsx b/frontend/src/components/Recommendations.jsx index 0e59fd6..e6b1420 100644 --- a/frontend/src/components/Recommendations.jsx +++ b/frontend/src/components/Recommendations.jsx @@ -21,8 +21,9 @@ function Recommendations() { const [showDetails, setShowDetails] = useState({}) const [bookmarking, setBookmarking] = useState({}) // 记录正在标记的推荐ID const [ordering, setOrdering] = useState({}) // 记录正在下单的推荐ID + const [marketType, setMarketType] = useState('futures') // 'futures' | 'spot' - // 获取默认下单保证金(按账号存储,单位:USDT) + // 获取默认下单保证金(按账号存储,单位:USDT);现货时表示下单金额(USDT) const getDefaultOrderSize = () => { try { const key = `ats_default_order_size_${accountId}`; @@ -64,49 +65,25 @@ function Recommendations() { useEffect(() => { loadRecommendations() - // 如果是查看实时推荐,每10秒静默更新价格(不触发loading状态) + // 合约 + 实时推荐:每10秒静默更新价格 let interval = null - if (typeFilter === 'realtime') { + if (marketType === 'futures' && typeFilter === 'realtime') { interval = setInterval(async () => { - // 静默更新:只更新价格,不显示loading try { const result = await api.getRecommendations({ type: 'realtime' }) const newData = result.data || [] - - // 使用setState直接更新,不触发loading状态 - setRecommendations(prevRecommendations => { - // 如果新数据为空,保持原数据不变 - if (newData.length === 0) { - return prevRecommendations - } - - // 实时推荐没有id,使用symbol作为key - const newDataMap = new Map(newData.map(rec => [rec.symbol, rec])) - const prevMap = new Map(prevRecommendations.map(rec => [rec.symbol || rec.id, rec])) - - // 合并数据:优先使用新数据(包含实时价格更新) - const updated = prevRecommendations.map(prevRec => { + if (newData.length === 0) return + const newDataMap = new Map(newData.map(rec => [rec.symbol, rec])) + setRecommendations(prev => { + const prevKeys = new Set(prev.map(r => r.symbol || r.id)) + const updated = prev.map(prevRec => { const key = prevRec.symbol || prevRec.id - const newRec = newDataMap.get(key) - if (newRec) { - return newRec - } - return prevRec + return newDataMap.get(key) || prevRec }) - - // 添加新出现的推荐 - const newItems = newData.filter(newRec => !prevMap.has(newRec.symbol)) - - // 合并并去重(按symbol) + const newItems = newData.filter(newRec => !prevKeys.has(newRec.symbol)) const merged = [...updated, ...newItems] const uniqueMap = new Map() - merged.forEach(rec => { - const key = rec.symbol || rec.id - if (!uniqueMap.has(key)) { - uniqueMap.set(key, rec) - } - }) - + merged.forEach(rec => { uniqueMap.set(rec.symbol || rec.id, rec) }) return Array.from(uniqueMap.values()) }) } catch (err) { @@ -121,30 +98,31 @@ function Recommendations() { clearInterval(interval) } } - }, [typeFilter, statusFilter, directionFilter]) + }, [marketType, typeFilter, statusFilter, directionFilter]) const loadRecommendations = async () => { try { setLoading(true) setError(null) - - const params = { type: typeFilter } - if (typeFilter === 'bookmarked') { - // 已标记的推荐:支持状态和方向过滤 - if (statusFilter) params.status = statusFilter - if (directionFilter) params.direction = directionFilter - params.limit = 50 + if (marketType === 'spot') { + const result = await api.getSpotRecommendations({ limit: 50 }) + const data = result.data || [] + setRecommendations(data) } else { - // 实时推荐:只支持方向过滤 - if (directionFilter) params.direction = directionFilter - params.limit = 50 - params.min_signal_strength = 5 + const params = { type: typeFilter } + if (typeFilter === 'bookmarked') { + if (statusFilter) params.status = statusFilter + if (directionFilter) params.direction = directionFilter + params.limit = 50 + } else { + if (directionFilter) params.direction = directionFilter + params.limit = 50 + params.min_signal_strength = 5 + } + const result = await api.getRecommendations(params) + const data = result.data || [] + setRecommendations(data) } - - const result = await api.getRecommendations(params) - const data = result.data || [] - - setRecommendations(data) } catch (err) { setError(err.message) console.error('加载推荐失败:', err) @@ -350,10 +328,9 @@ function Recommendations() { const direction = rec.direction; const leverage = rec.suggested_leverage || 10; - // defaultOrderSize 是保证金(USDT),需要转换为名义价值 - const marginUsdt = defaultOrderSize; // 保证金 - const notionalUsdt = marginUsdt * leverage; // 名义价值 = 保证金 × 杠杆 - const quantity = notionalUsdt / entryPrice; // 数量 = 名义价值 / 入场价 + const marginUsdt = defaultOrderSize; + const notionalUsdt = marginUsdt * leverage; + const quantity = notionalUsdt / entryPrice; if (!window.confirm( `确认下单?\n` + @@ -376,11 +353,10 @@ function Recommendations() { entryPrice, stopLossPrice, direction, - notionalUsdt, // 传递名义价值给后端 + notionalUsdt, leverage ); alert(`下单成功!\n订单ID: ${result.order_id}\n交易ID: ${result.trade_id || 'N/A'}`); - // 可以刷新推荐列表或更新状态 } catch (err) { alert(`下单失败: ${err.message}`); console.error('下单失败:', err); @@ -392,6 +368,35 @@ function Recommendations() { }); } }; + + const handleSpotQuickOrder = async (rec) => { + const recKey = rec.symbol || rec.id; + const usdtAmount = defaultOrderSize; + if (!usdtAmount || usdtAmount <= 0) { + alert('请先设置默认下单金额(USDT)'); + return; + } + if (!window.confirm( + `确认现货市价买入?\n` + + `交易对: ${rec.symbol}\n` + + `金额: ${usdtAmount} USDT\n` + + `(市价单,按当前价成交)` + )) return; + try { + setOrdering(prev => ({ ...prev, [recKey]: true })); + const result = await api.placeSpotOrder(rec.symbol, 'BUY', usdtAmount, 'MARKET'); + alert(`现货下单成功!\n订单ID: ${result.order_id}\n状态: ${result.status}`); + } 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\n\n注意:这是保证金,实际名义价值 = 保证金 × 杠杆`, String(defaultOrderSize)); @@ -443,12 +448,55 @@ function Recommendations() { +
+ + +
+ {marketType === 'spot' && ( +

+ 现货推荐(只做多)。默认下单金额用于市价买入的 USDT 数量,可在上方设置。 +

+ )} + + {marketType === 'futures' && (
+ )} {error && (
@@ -506,7 +555,8 @@ function Recommendations() { {rec.direction === 'BUY' ? '做多' : '做空'} - {getStatusBadge(rec.status)} + {marketType === 'spot' && 现货} + {marketType === 'futures' && getStatusBadge(rec.status)}
@@ -592,7 +642,7 @@ function Recommendations() { 止损 {fmtPrice(rec.suggested_stop_loss)} USDT - {(rec.suggested_limit_price || rec.planned_entry_price) && rec.suggested_stop_loss && ( + {marketType === 'futures' && (rec.suggested_limit_price || rec.planned_entry_price) && rec.suggested_stop_loss && ( )} + {marketType === 'spot' && ( + + )} 目标1 {fmtPrice(rec.suggested_take_profit_1)} USDT @@ -627,7 +699,7 @@ function Recommendations() { )}
- {typeFilter === 'realtime' && ( + {marketType === 'futures' && typeFilter === 'realtime' && (