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获取实时账户和订单数据 账户实时数据API - 从币安API获取实时账户和订单数据
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi import Query
import sys import sys
from pathlib import Path from pathlib import Path
import logging import logging
import time
project_root = Path(__file__).parent.parent.parent.parent project_root = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
@ -17,6 +19,236 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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(): async def get_realtime_account_data():
"""从币安API实时获取账户数据""" """从币安API实时获取账户数据"""
logger.info("=" * 60) logger.info("=" * 60)

View File

@ -473,6 +473,34 @@
background: #95a5a6; 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 { .message {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -7,6 +7,7 @@ const StatsDashboard = () => {
const [dashboardData, setDashboardData] = useState(null) const [dashboardData, setDashboardData] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [closingSymbol, setClosingSymbol] = useState(null) const [closingSymbol, setClosingSymbol] = useState(null)
const [sltpSymbol, setSltpSymbol] = useState(null)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [tradingConfig, setTradingConfig] = useState(null) 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 () => { const handleSyncPositions = async () => {
if (!window.confirm('确定要同步持仓状态吗?这将检查币安实际持仓并更新数据库状态。')) { if (!window.confirm('确定要同步持仓状态吗?这将检查币安实际持仓并更新数据库状态。')) {
return return
@ -552,6 +573,14 @@ const StatsDashboard = () => {
<span> ({parseFloat(trade.pnl_percent).toFixed(2)}%)</span> <span> ({parseFloat(trade.pnl_percent).toFixed(2)}%)</span>
)} )}
</div> </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 <button
className="close-btn" className="close-btn"
onClick={() => handleClosePosition(trade.symbol)} onClick={() => handleClosePosition(trade.symbol)}

View File

@ -131,6 +131,31 @@ export const api = {
return response.json(); 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 () => { syncPositions: async () => {
const response = await fetch(buildUrl('/api/account/positions/sync'), { const response = await fetch(buildUrl('/api/account/positions/sync'), {