This commit is contained in:
薇薇安 2026-01-23 19:31:20 +08:00
parent cb7b091280
commit 1fcd692368
5 changed files with 264 additions and 24 deletions

View File

@ -286,8 +286,8 @@ async def ensure_all_positions_sltp(
批量补挂当前所有持仓的止盈止损保护单
"""
# 先拿当前持仓symbol列表
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
if not api_key or not api_secret:
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if (not api_key or not api_secret) and status == "active":
logger.error(f"[account_id={account_id}] API密钥未配置")
raise HTTPException(status_code=400, detail=f"API密钥未配置account_id={account_id}")
@ -351,7 +351,7 @@ async def get_realtime_account_data(account_id: int = 1):
try:
# 从 accounts 表读取账号私有API密钥
logger.info(f"步骤1: 从accounts读取API配置... (account_id={account_id})")
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
logger.info(f" - API密钥存在: {bool(api_key)}")
if api_key:
@ -565,7 +565,7 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)):
"""获取实时持仓数据"""
client = None
try:
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
logger.info(f"尝试获取实时持仓数据 (testnet={use_testnet}, account_id={account_id})")
@ -738,9 +738,9 @@ async def close_position(symbol: str, account_id: int = Depends(get_account_id))
logger.info(f"收到平仓请求: {symbol}")
logger.info(f"=" * 60)
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
if (not api_key or not api_secret) and status == "active":
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)
@ -1041,6 +1041,169 @@ async def close_position(symbol: str, account_id: int = Depends(get_account_id))
raise HTTPException(status_code=500, detail=error_msg)
@router.post("/positions/close-all")
async def close_all_positions(account_id: int = Depends(get_account_id)):
"""一键全平:平仓所有持仓"""
try:
logger.info("=" * 60)
logger.info("收到一键全平请求")
logger.info("=" * 60)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if (not api_key or not api_secret) and status == "active":
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
logger.info("✓ 成功导入交易系统模块")
except ImportError as import_error:
logger.warning(f"首次导入失败: {import_error}尝试从trading_system路径导入")
trading_system_path = project_root / 'trading_system'
sys.path.insert(0, str(trading_system_path))
from binance_client import BinanceClient
logger.info("✓ 从trading_system路径导入成功")
# 导入数据库模型
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:
# 获取所有持仓
positions = await client.get_open_positions()
if not positions:
logger.info("当前没有持仓")
return {
"message": "当前没有持仓",
"closed": 0,
"failed": 0,
"results": []
}
logger.info(f"发现 {len(positions)} 个持仓,开始逐一平仓...")
results = []
closed_count = 0
failed_count = 0
for position in positions:
symbol = position.get('symbol')
position_amt = float(position.get('positionAmt', 0))
if abs(position_amt) <= 0:
continue
try:
logger.info(f"开始平仓 {symbol} (数量: {position_amt})...")
# 确定平仓方向
side = 'SELL' if position_amt > 0 else 'BUY'
# 使用市价单平仓
order = await client.place_order(
symbol=symbol,
side=side,
order_type='MARKET',
quantity=abs(position_amt),
reduce_only=True
)
if order and order.get('orderId'):
logger.info(f"{symbol} 平仓订单已提交: {order.get('orderId')}")
# 获取成交价格
exit_price = float(order.get('avgPrice', 0)) or float(order.get('price', 0))
if not exit_price:
# 如果订单中没有价格,获取当前价格
ticker = await client.get_ticker_24h(symbol)
exit_price = float(ticker['price']) if ticker else 0
# 更新数据库记录
open_trades = Trade.get_by_symbol(symbol, status='open')
for trade in open_trades:
entry_price = float(trade['entry_price'])
quantity = float(trade['quantity'])
if trade['side'] == 'BUY':
pnl = (exit_price - entry_price) * quantity
pnl_percent = ((exit_price - entry_price) / entry_price) * 100
else:
pnl = (entry_price - exit_price) * quantity
pnl_percent = ((entry_price - exit_price) / entry_price) * 100
Trade.update_exit(
trade_id=trade['id'],
exit_price=exit_price,
exit_reason='manual',
pnl=pnl,
pnl_percent=pnl_percent,
exit_order_id=order.get('orderId')
)
logger.info(f"✓ 已更新数据库记录 trade_id={trade['id']} (盈亏: {pnl:.2f} USDT)")
closed_count += 1
results.append({
"symbol": symbol,
"status": "success",
"order_id": order.get('orderId'),
"message": f"{symbol} 平仓成功"
})
else:
logger.warning(f"{symbol} 平仓订单提交失败")
failed_count += 1
results.append({
"symbol": symbol,
"status": "failed",
"message": f"{symbol} 平仓失败: 订单未提交"
})
except Exception as e:
logger.error(f"{symbol} 平仓失败: {e}")
failed_count += 1
results.append({
"symbol": symbol,
"status": "failed",
"message": f"{symbol} 平仓失败: {str(e)}"
})
logger.info(f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}")
return {
"message": f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}",
"closed": closed_count,
"failed": failed_count,
"results": results
}
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/{symbol}/open")
async def open_position_from_recommendation(
symbol: str,
@ -1068,9 +1231,9 @@ async def open_position_from_recommendation(
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)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
if (not api_key or not api_secret) and status == "active":
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)
@ -1247,9 +1410,9 @@ async def sync_positions(account_id: int = Depends(get_account_id)):
logger.info("收到持仓状态同步请求")
logger.info("=" * 60)
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
if (not api_key or not api_secret) and status == "active":
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)

View File

@ -99,12 +99,12 @@ async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> Lis
if not r:
continue
# 普通用户:不返回密钥明文,但返回“是否已配置”的状态,方便前端提示
api_key, api_secret, use_testnet = Account.get_credentials(int(aid))
api_key, api_secret, use_testnet, status = Account.get_credentials(int(aid))
out.append(
{
"id": int(aid),
"name": r.get("name") or "",
"status": r.get("status") or "active",
"status": status or r.get("status") or "active",
"use_testnet": bool(use_testnet),
"role": membership_map.get(int(aid), "viewer"),
"has_api_key": bool(api_key),

View File

@ -210,9 +210,9 @@ async def get_all_configs(
# 合并账号级 API Key/Secret从 accounts 表读,避免把密钥当普通配置存)
try:
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
except Exception:
api_key, api_secret, use_testnet = "", "", False
api_key, api_secret, use_testnet, status = "", "", False, "active"
# 仅用于配置页展示/更新:不返回 secret 明文api_key 仅脱敏展示
result["BINANCE_API_KEY"] = {
"value": _mask(api_key or ""),
@ -733,7 +733,7 @@ async def get_config(
try:
# 虚拟字段:从 accounts 表读取
if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
api_key, api_secret, use_testnet = Account.get_credentials(account_id)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if key == "BINANCE_API_KEY":
return {"key": key, "value": _mask(api_key or ""), "type": "string", "category": "api", "description": "币安API密钥仅脱敏展示"}
if key == "BINANCE_API_SECRET":

View File

@ -114,6 +114,47 @@ const StatsDashboard = () => {
}
}
const handleCloseAllPositions = async () => {
if (!window.confirm(`确定要一键全平所有持仓吗?\n\n这将使用市价单平仓所有持仓请谨慎操作`)) {
return
}
setClosingSymbol('ALL') // 使
setMessage('')
try {
console.log('开始一键全平...')
const result = await api.closeAllPositions()
console.log('一键全平结果:', result)
let message = result.message || '一键全平完成'
if (result.closed > 0 || result.failed > 0) {
message = `一键全平完成: 成功 ${result.closed} / 失败 ${result.failed}`
}
setMessage(message)
//
await loadDashboard()
// 5
setTimeout(() => {
setMessage('')
}, 5000)
} catch (error) {
console.error('Close all positions error:', error)
const errorMessage = error.message || error.toString() || '一键全平失败,请检查网络连接或后端服务'
setMessage(`一键全平失败: ${errorMessage}`)
// 5
setTimeout(() => {
setMessage('')
}, 5000)
} finally {
setClosingSymbol(null)
}
}
const handleEnsureSLTP = async (symbol) => {
if (!window.confirm(`确定要为 ${symbol} 补挂“币安止损/止盈保护单”吗?\n\n说明将自动取消该交易对已有的 STOP/TP 保护单并重新挂单(避免重复)。`)) {
return
@ -387,14 +428,35 @@ const StatsDashboard = () => {
<div className="dashboard-card">
<div className="positions-header">
<h3>当前持仓</h3>
<button
className="sltp-all-btn"
onClick={handleEnsureAllSLTP}
disabled={sltpAllBusy || openTrades.length === 0}
title="为所有持仓在币安侧补挂 STOP_MARKET + TAKE_PROFIT_MARKET 保护单closePosition"
>
{sltpAllBusy ? '一键补挂中...' : '一键补挂止盈止损'}
</button>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="sltp-all-btn"
onClick={handleEnsureAllSLTP}
disabled={sltpAllBusy || openTrades.length === 0}
title="为所有持仓在币安侧补挂 STOP_MARKET + TAKE_PROFIT_MARKET 保护单"
>
{sltpAllBusy ? '一键补挂中...' : '一键补挂止盈止损'}
</button>
<button
className="close-all-btn"
onClick={handleCloseAllPositions}
disabled={closingSymbol !== null || openTrades.length === 0}
style={{
padding: '6px 12px',
backgroundColor: '#ff6b6b',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: (closingSymbol !== null || openTrades.length === 0) ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 'bold',
opacity: (closingSymbol !== null || openTrades.length === 0) ? 0.6 : 1
}}
title="一键平仓所有持仓(市价单)"
>
{closingSymbol !== null ? '全平中...' : '⚡ 一键全平'}
</button>
</div>
</div>
<div className="entry-type-summary">
<span className="entry-type-badge limit">限价入场: {entryTypeCounts.limit}</span>

View File

@ -353,7 +353,7 @@ export const api = {
// 平仓操作
closePosition: async (symbol) => {
const response = await fetch(buildUrl(`/api/accounts/positions/${symbol}/close`), {
const response = await fetch(buildUrl(`/api/account/positions/${symbol}/close`), {
method: 'POST',
headers: {
...withAccountHeaders({ 'Content-Type': 'application/json' }),
@ -366,6 +366,21 @@ export const api = {
return response.json();
},
// 一键全平(平仓所有持仓)
closeAllPositions: async () => {
const response = await fetch(buildUrl(`/api/account/positions/close-all`), {
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();
},
// 补挂止盈止损(交易所保护单)
ensurePositionSLTP: async (symbol) => {
const response = await fetch(buildUrl(`/api/accounts/positions/${symbol}/sltp/ensure`), {