a
This commit is contained in:
parent
14773e530a
commit
5d7166d404
|
|
@ -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)):
|
||||
"""同步币安实际持仓状态与数据库状态"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {}) => {
|
||||
// 默认使用实时推荐
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user