feat(spot_order): 添加现货下单API与前端支持

在后端API中新增现货下单功能,支持市价单和限价单的创建,并提供相应的错误处理机制。前端组件更新以支持现货下单的快速操作,允许用户选择现货市场并设置默认下单金额。此改动提升了用户体验,增强了交易系统的功能性与灵活性。
This commit is contained in:
薇薇安 2026-02-25 08:53:39 +08:00
parent 3389e0aafc
commit cbba86001a
3 changed files with 227 additions and 63 deletions

View File

@ -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_orderspot 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()

View File

@ -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
// USDTUSDT
const getDefaultOrderSize = () => {
try {
const key = `ats_default_order_size_${accountId}`;
@ -64,49 +65,25 @@ function Recommendations() {
useEffect(() => {
loadRecommendations()
// 10loading
// + 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 || []
// 使setStateloading
setRecommendations(prevRecommendations => {
//
if (newData.length === 0) {
return prevRecommendations
}
// id使symbolkey
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() {
</div>
</div>
<div className="market-type-tabs" style={{ display: 'flex', gap: 0, marginBottom: 12, borderBottom: '1px solid #e0e0e0' }}>
<button
type="button"
onClick={() => setMarketType('futures')}
style={{
padding: '10px 20px',
border: 'none',
background: marketType === 'futures' ? 'transparent' : 'transparent',
color: marketType === 'futures' ? '#2196F3' : '#666',
fontWeight: marketType === 'futures' ? 500 : 400,
borderBottom: marketType === 'futures' ? '2px solid #2196F3' : '2px solid transparent',
marginBottom: -1,
cursor: 'pointer',
fontSize: 15
}}
>
合约
</button>
<button
type="button"
onClick={() => setMarketType('spot')}
style={{
padding: '10px 20px',
border: 'none',
background: 'transparent',
color: marketType === 'spot' ? '#2196F3' : '#666',
fontWeight: marketType === 'spot' ? 500 : 400,
borderBottom: marketType === 'spot' ? '2px solid #2196F3' : '2px solid transparent',
marginBottom: -1,
cursor: 'pointer',
fontSize: 15
}}
>
现货
</button>
</div>
{marketType === 'spot' && (
<p style={{ margin: '0 0 12px 0', fontSize: 13, color: '#666' }}>
现货推荐只做多默认下单金额用于市价买入的 USDT 数量可在上方设置
</p>
)}
{marketType === 'futures' && (
<div className="filters">
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value)
setStatusFilter('') //
setStatusFilter('')
}}
className="filter-select"
>
@ -480,6 +528,7 @@ function Recommendations() {
<option value="SELL">做空</option>
</select>
</div>
)}
{error && (
<div className="error-message">
@ -506,7 +555,8 @@ function Recommendations() {
<span className={`direction ${rec.direction.toLowerCase()}`}>
{rec.direction === 'BUY' ? '做多' : '做空'}
</span>
{getStatusBadge(rec.status)}
{marketType === 'spot' && <span className="market-tag" style={{ fontSize: 12, color: '#888', marginLeft: 6 }}>现货</span>}
{marketType === 'futures' && getStatusBadge(rec.status)}
</div>
<div className="card-meta">
<span className={`signal-strength ${getSignalStrengthColor(rec.signal_strength)}`}>
@ -592,7 +642,7 @@ function Recommendations() {
<span className="ug-pill stop">
止损 {fmtPrice(rec.suggested_stop_loss)} USDT
</span>
{(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 && (
<button
className="btn-quick-order"
onClick={() => handleQuickOrder(rec)}
@ -614,6 +664,28 @@ function Recommendations() {
{ordering[rec.symbol || rec.id] ? '下单中...' : '⚡ 一键下单'}
</button>
)}
{marketType === 'spot' && (
<button
type="button"
onClick={() => handleSpotQuickOrder(rec)}
disabled={ordering[rec.symbol || rec.id]}
style={{
marginLeft: '8px',
padding: '6px 12px',
backgroundColor: '#2ecc71',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: ordering[rec.symbol || rec.id] ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
fontSize: '13px',
opacity: ordering[rec.symbol || rec.id] ? 0.6 : 1
}}
title={`现货市价买入 ${defaultOrderSize} USDT`}
>
{ordering[rec.symbol || rec.id] ? '下单中...' : '⚡ 一键下单'}
</button>
)}
<span className="ug-pill tp1">
目标1 {fmtPrice(rec.suggested_take_profit_1)} USDT
</span>
@ -627,7 +699,7 @@ function Recommendations() {
)}
<div className="card-actions">
{typeFilter === 'realtime' && (
{marketType === 'futures' && typeFilter === 'realtime' && (
<button
className="btn-bookmark"
onClick={() => handleBookmark(rec)}

View File

@ -572,7 +572,32 @@ export const api = {
}
return response.json();
},
getSpotRecommendations: async (params = {}) => {
const query = new URLSearchParams(params).toString();
const url = query ? `${buildUrl('/api/recommendations/spot')}?${query}` : buildUrl('/api/recommendations/spot');
const response = await fetch(url, { headers: withAccountHeaders() });
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取现货推荐失败' }));
throw new Error(error.detail || '获取现货推荐失败');
}
return response.json();
},
placeSpotOrder: async (symbol, side, quoteOrderQty, orderType = 'MARKET', price = null) => {
const params = new URLSearchParams({ symbol, side, quote_order_qty: String(quoteOrderQty), order_type: orderType });
if (price != null && orderType === 'LIMIT') params.append('price', String(price));
const response = await fetch(buildUrl(`/api/account/spot/order?${params}`), {
method: 'POST',
headers: withAccountHeaders(),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '现货下单失败' }));
throw new Error(error.detail || '现货下单失败');
}
return response.json();
},
bookmarkRecommendation: async (recommendationData) => {
const response = await fetch(buildUrl('/api/recommendations/bookmark'), {
method: 'POST',