a
This commit is contained in:
parent
e725cb19fa
commit
e4d72057eb
|
|
@ -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 用 UP,short 用 DOWN;止盈 long 用 DOWN,short 用 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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user