This commit is contained in:
薇薇安 2026-01-19 21:49:58 +08:00
parent e725cb19fa
commit e4d72057eb
4 changed files with 314 additions and 0 deletions

View File

@ -2,9 +2,11 @@
账户实时数据API - 从币安API获取实时账户和订单数据
"""
from fastapi import APIRouter, HTTPException
from fastapi import Query
import sys
from pathlib import Path
import logging
import time
project_root = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(project_root))
@ -17,6 +19,236 @@ logger = logging.getLogger(__name__)
router = APIRouter()
async def _ensure_exchange_sltp_for_symbol(symbol: str):
"""
在币安侧补挂该 symbol 的止损/止盈保护单STOP_MARKET + TAKE_PROFIT_MARKET
该接口用于手动补挂不依赖 trading_system 的监控任务
"""
# 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
if not api_key or not api_secret:
raise HTTPException(status_code=400, detail="API密钥未配置")
# 导入交易系统的BinanceClient复用其精度/持仓模式处理)
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:
# 1) 获取当前持仓(需要知道方向)
raw_positions = await client.client.futures_position_information(symbol=symbol)
nonzero = []
for p in raw_positions or []:
try:
amt = float(p.get("positionAmt", 0) or 0)
if amt != 0:
nonzero.append((amt, p))
except Exception:
continue
if not nonzero:
raise HTTPException(status_code=400, detail=f"{symbol} 当前无持仓,无法补挂止盈止损")
# 2) 获取持仓模式
dual_side = None
try:
mode_res = await client.client.futures_get_position_mode()
if isinstance(mode_res, dict):
dual_side = bool(mode_res.get("dualSidePosition"))
except Exception:
dual_side = None
# 3) 取净持仓(单向)或第一条非零腿(对冲/兜底)
amt, p0 = nonzero[0]
net_amt = sum([a for a, _ in nonzero])
if dual_side is False:
amt = net_amt
side = "BUY" if amt > 0 else "SELL"
mark_price = None
try:
mark_price = float(p0.get("markPrice", 0) or 0) or None
except Exception:
mark_price = None
# 4) 从数据库 open trade 取止损/止盈价(优先取最近一条)
from database.models import Trade
open_trades = Trade.get_by_symbol(symbol, status='open') or []
if not open_trades:
raise HTTPException(status_code=400, detail=f"{symbol} 数据库无 open 交易记录,无法确定止损止盈价")
trade = open_trades[0]
sl = trade.get("stop_loss_price")
tp = trade.get("take_profit_2") or trade.get("take_profit_price") or trade.get("take_profit_1")
try:
sl = float(sl) if sl is not None else None
except Exception:
sl = None
try:
tp = float(tp) if tp is not None else None
except Exception:
tp = None
if not sl or not tp:
raise HTTPException(status_code=400, detail=f"{symbol} 数据库缺少止损/止盈价,无法补挂")
# 5) 取消旧的保护单,避免重复
try:
orders = await client.client.futures_get_open_orders(symbol=symbol)
for o in orders or []:
try:
if not isinstance(o, dict):
continue
otype = str(o.get("type") or "").upper()
if otype in {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"}:
oid = o.get("orderId")
if oid:
await client.client.futures_cancel_order(symbol=symbol, orderId=int(oid))
except Exception:
continue
except Exception:
pass
# 6) 下保护单closePosition=True
symbol_info = await client.get_symbol_info(symbol)
# 使用 trading_system/binance_client 的格式化方法(如果不存在则回退简单格式)
try:
fmt_price = BinanceClient._format_price_str_with_rounding # type: ignore[attr-defined]
except Exception:
fmt_price = None
def _fmt(price: float, rounding_mode: str) -> str:
if fmt_price:
return fmt_price(price, symbol_info, rounding_mode) # type: ignore[misc]
return str(round(float(price), int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8))
# 触发价避免“立即触发”
cp = float(mark_price) if mark_price else None
tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0
pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8
min_step = tick if tick and tick > 0 else (10 ** (-pp) if pp and pp > 0 else 1e-8)
sl_price = float(sl)
tp_price = float(tp)
if cp and cp > 0:
# stop
if side == "BUY" and sl_price >= cp:
sl_price = max(0.0, cp - min_step)
if side == "SELL" and sl_price <= cp:
sl_price = cp + min_step
# tp
if side == "BUY" and tp_price <= cp:
tp_price = cp + min_step
if side == "SELL" and tp_price >= cp:
tp_price = max(0.0, cp - min_step)
# rounding止损 long 用 UPshort 用 DOWN止盈 long 用 DOWNshort 用 UP
sl_round = "UP" if side == "BUY" else "DOWN"
tp_round = "DOWN" if side == "BUY" else "UP"
close_side = "SELL" if side == "BUY" else "BUY"
sl_params = {
"symbol": symbol,
"side": close_side,
"type": "STOP_MARKET",
"stopPrice": _fmt(sl_price, sl_round),
"closePosition": True,
"workingType": "MARK_PRICE",
}
tp_params = {
"symbol": symbol,
"side": close_side,
"type": "TAKE_PROFIT_MARKET",
"stopPrice": _fmt(tp_price, tp_round),
"closePosition": True,
"workingType": "MARK_PRICE",
}
if dual_side is True:
sl_params["positionSide"] = "LONG" if side == "BUY" else "SHORT"
tp_params["positionSide"] = "LONG" if side == "BUY" else "SHORT"
sl_order = await client.client.futures_create_order(**sl_params)
tp_order = await client.client.futures_create_order(**tp_params)
return {
"symbol": symbol,
"position_side": side,
"dual_side_position": dual_side,
"stop_loss_price": sl_price,
"take_profit_price": tp_price,
"orders": {
"stop_market": sl_order,
"take_profit_market": tp_order,
},
}
finally:
await client.disconnect()
@router.post("/positions/{symbol}/sltp/ensure")
async def ensure_position_sltp(symbol: str):
"""
手动补挂该 symbol 的止盈止损保护单币安侧可见
"""
try:
return await _ensure_exchange_sltp_for_symbol(symbol)
except HTTPException:
raise
except Exception as e:
logger.error(f"{symbol} 补挂止盈止损失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"补挂止盈止损失败: {str(e)}")
@router.post("/positions/sltp/ensure-all")
async def ensure_all_positions_sltp(limit: int = Query(50, ge=1, le=200, description="最多处理多少个持仓symbol")):
"""
批量补挂当前所有持仓的止盈止损保护单
"""
# 先拿当前持仓symbol列表
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
if not api_key or not api_secret:
raise HTTPException(status_code=400, detail="API密钥未配置")
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:
positions = await client.get_open_positions()
symbols = [p["symbol"] for p in (positions or []) if float(p.get("positionAmt", 0) or 0) != 0]
finally:
await client.disconnect()
symbols = symbols[: int(limit or 50)]
results = []
errors = []
for sym in symbols:
try:
res = await _ensure_exchange_sltp_for_symbol(sym)
results.append({"symbol": sym, "ok": True, "orders": res.get("orders")})
except Exception as e:
errors.append({"symbol": sym, "ok": False, "error": str(e)})
return {
"total": len(symbols),
"ok": len([r for r in results if r.get("ok")]),
"failed": len(errors),
"results": results,
"errors": errors,
}
async def get_realtime_account_data():
"""从币安API实时获取账户数据"""
logger.info("=" * 60)

View File

@ -473,6 +473,34 @@
background: #95a5a6;
}
.sltp-btn {
padding: 0.5rem 1rem;
background: #1f7aec;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s;
white-space: nowrap;
margin-right: 8px;
}
.sltp-btn:hover:not(:disabled) {
background: #175fb8;
}
.sltp-btn:active:not(:disabled) {
transform: scale(0.95);
}
.sltp-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
background: #95a5a6;
}
.message {
padding: 1rem;
margin-bottom: 1rem;

View File

@ -7,6 +7,7 @@ const StatsDashboard = () => {
const [dashboardData, setDashboardData] = useState(null)
const [loading, setLoading] = useState(true)
const [closingSymbol, setClosingSymbol] = useState(null)
const [sltpSymbol, setSltpSymbol] = useState(null)
const [message, setMessage] = useState('')
const [tradingConfig, setTradingConfig] = useState(null)
@ -105,6 +106,26 @@ const StatsDashboard = () => {
}
}
const handleEnsureSLTP = async (symbol) => {
if (!window.confirm(`确定要为 ${symbol} 补挂“币安止损/止盈保护单”吗?\n\n说明将自动取消该交易对已有的 STOP/TP 保护单并重新挂单(避免重复)。`)) {
return
}
setSltpSymbol(symbol)
setMessage('')
try {
const res = await api.ensurePositionSLTP(symbol)
const slId = res?.orders?.stop_market?.orderId
const tpId = res?.orders?.take_profit_market?.orderId
setMessage(`${symbol} 已补挂保护单SL=${slId || '-'} / TP=${tpId || '-'}`)
await loadDashboard()
} catch (error) {
setMessage(`补挂失败 ${symbol}: ${error.message || '未知错误'}`)
} finally {
setSltpSymbol(null)
setTimeout(() => setMessage(''), 4000)
}
}
const handleSyncPositions = async () => {
if (!window.confirm('确定要同步持仓状态吗?这将检查币安实际持仓并更新数据库状态。')) {
return
@ -552,6 +573,14 @@ const StatsDashboard = () => {
<span> ({parseFloat(trade.pnl_percent).toFixed(2)}%)</span>
)}
</div>
<button
className="sltp-btn"
onClick={() => handleEnsureSLTP(trade.symbol)}
disabled={sltpSymbol === trade.symbol}
title="在币安侧补挂 STOP_MARKET + TAKE_PROFIT_MARKET 保护单closePosition"
>
{sltpSymbol === trade.symbol ? '补挂中...' : '补挂止盈止损'}
</button>
<button
className="close-btn"
onClick={() => handleClosePosition(trade.symbol)}

View File

@ -131,6 +131,31 @@ export const api = {
return response.json();
},
// 补挂止盈止损(交易所保护单)
ensurePositionSLTP: async (symbol) => {
const response = await fetch(buildUrl(`/api/account/positions/${symbol}/sltp/ensure`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '补挂止盈止损失败' }));
throw new Error(error.detail || '补挂止盈止损失败');
}
return response.json();
},
ensureAllPositionsSLTP: async (limit = 50) => {
const response = await fetch(buildUrl(`/api/account/positions/sltp/ensure-all?limit=${encodeURIComponent(limit)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '批量补挂止盈止损失败' }));
throw new Error(error.detail || '批量补挂止盈止损失败');
}
return response.json();
},
// 同步持仓状态
syncPositions: async () => {
const response = await fetch(buildUrl('/api/account/positions/sync'), {