feat(spot_order): 添加现货下单API与前端支持
在后端API中新增现货下单功能,支持市价单和限价单的创建,并提供相应的错误处理机制。前端组件更新以支持现货下单的快速操作,允许用户选择现货市场并设置默认下单金额。此改动提升了用户体验,增强了交易系统的功能性与灵活性。
This commit is contained in:
parent
3389e0aafc
commit
cbba86001a
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</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)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user