a
This commit is contained in:
parent
e725cb19fa
commit
e4d72057eb
|
|
@ -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 用 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():
|
async def get_realtime_account_data():
|
||||||
"""从币安API实时获取账户数据"""
|
"""从币安API实时获取账户数据"""
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user