This commit is contained in:
薇薇安 2026-01-22 19:52:46 +08:00
parent 14773e530a
commit 5d7166d404
3 changed files with 373 additions and 4 deletions

View File

@ -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)):
"""同步币安实际持仓状态与数据库状态"""

View File

@ -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 (
<div className="recommendations-container">
<div className="recommendations-header">
<h2>交易推荐</h2>
<div className="header-actions">
<div className="order-size-config" style={{ display: 'flex', alignItems: 'center', gap: '10px', marginRight: '10px' }}>
<span>默认下单量:</span>
<span style={{ fontWeight: 'bold', color: '#2ecc71' }}>{defaultOrderSize} USDT</span>
<button
className="btn-config"
onClick={handleSetDefaultOrderSize}
style={{ padding: '4px 8px', fontSize: '12px' }}
title="点击设置默认下单量"
>
设置
</button>
</div>
<button
className="btn-generate"
onClick={handleGenerate}
@ -462,6 +591,28 @@ 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 && (
<button
className="btn-quick-order"
onClick={() => handleQuickOrder(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>

View File

@ -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 = {}) => {
// 默认使用实时推荐