a
This commit is contained in:
parent
321ff599f6
commit
e725cb19fa
|
|
@ -21,6 +21,21 @@ const StatsDashboard = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const fmtPrice = (v) => {
|
||||
const n = Number(v)
|
||||
if (!isFinite(n)) return '-'
|
||||
const a = Math.abs(n)
|
||||
// 低价币需要更高精度,否则“止损价=入场价”只是显示被四舍五入了
|
||||
const dp =
|
||||
a >= 1000 ? 2 :
|
||||
a >= 100 ? 3 :
|
||||
a >= 1 ? 4 :
|
||||
a >= 0.01 ? 6 :
|
||||
a >= 0.0001 ? 8 :
|
||||
10
|
||||
return n.toFixed(dp).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
loadTradingConfig()
|
||||
|
|
@ -483,9 +498,9 @@ const StatsDashboard = () => {
|
|||
<div>名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT</div>
|
||||
<div>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>
|
||||
|
||||
<div>入场价: {entryPrice.toFixed(4)}</div>
|
||||
<div>入场价: {fmtPrice(entryPrice)}</div>
|
||||
{trade.mark_price && (
|
||||
<div>标记价: {markPrice.toFixed(4)}</div>
|
||||
<div>标记价: {fmtPrice(markPrice)}</div>
|
||||
)}
|
||||
{trade.leverage && (
|
||||
<div>杠杆: {trade.leverage}x</div>
|
||||
|
|
@ -510,22 +525,22 @@ const StatsDashboard = () => {
|
|||
<div className="stop-loss-info">
|
||||
止损: <span className="negative">-{stopLossPercent.toFixed(2)}%</span>
|
||||
<span className="stop-note">(of margin)</span>
|
||||
<span className="stop-price">(价: {stopLossPrice.toFixed(4)})</span>
|
||||
<span className="stop-price">(价: {fmtPrice(stopLossPrice)})</span>
|
||||
<span className="stop-amount">(金额: -{stopLossAmount >= 0.01 ? stopLossAmount.toFixed(2) : stopLossAmount.toFixed(4)} USDT)</span>
|
||||
</div>
|
||||
<div className="take-profit-info">
|
||||
止盈: <span className="positive">+{takeProfitPercent.toFixed(2)}%</span>
|
||||
<span className="take-note">(of margin)</span>
|
||||
<span className="take-price">(价: {takeProfitPrice.toFixed(4)})</span>
|
||||
<span className="take-price">(价: {fmtPrice(takeProfitPrice)})</span>
|
||||
<span className="take-amount">(金额: +{takeProfitAmount >= 0.01 ? takeProfitAmount.toFixed(2) : takeProfitAmount.toFixed(4)} USDT)</span>
|
||||
</div>
|
||||
{(trade.take_profit_1 || trade.take_profit_2) && (
|
||||
<div style={{ marginTop: '6px', fontSize: '12px', color: '#666' }}>
|
||||
{trade.take_profit_1 && (
|
||||
<span style={{ marginRight: '10px' }}>TP1: {parseFloat(trade.take_profit_1).toFixed(4)}</span>
|
||||
<span style={{ marginRight: '10px' }}>TP1: {fmtPrice(parseFloat(trade.take_profit_1))}</span>
|
||||
)}
|
||||
{trade.take_profit_2 && (
|
||||
<span>TP2: {parseFloat(trade.take_profit_2).toFixed(4)}</span>
|
||||
<span>TP2: {fmtPrice(parseFloat(trade.take_profit_2))}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -858,6 +858,51 @@ class BinanceClient:
|
|||
except Exception:
|
||||
return BinanceClient._format_decimal_str(p)
|
||||
|
||||
@staticmethod
|
||||
def _format_price_str_with_rounding(price: float, symbol_info: Optional[Dict], rounding_mode: str) -> str:
|
||||
"""
|
||||
通用价格格式化(tickSize/pricePrecision 对齐),并允许显式指定 ROUND_UP / ROUND_DOWN。
|
||||
rounding_mode: "UP" 或 "DOWN"
|
||||
"""
|
||||
try:
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
except Exception:
|
||||
return str(round(float(price), 8))
|
||||
|
||||
tick = 0.0
|
||||
pp = 8
|
||||
try:
|
||||
tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0
|
||||
except Exception:
|
||||
tick = 0.0
|
||||
try:
|
||||
pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8
|
||||
except Exception:
|
||||
pp = 8
|
||||
|
||||
p = Decimal(str(price))
|
||||
rounding = ROUND_UP if str(rounding_mode).upper() == "UP" else ROUND_DOWN
|
||||
|
||||
# tickSize 优先
|
||||
try:
|
||||
t = Decimal(str(tick))
|
||||
if t > 0:
|
||||
q = p / t
|
||||
q2 = q.to_integral_value(rounding=rounding)
|
||||
p2 = q2 * t
|
||||
return BinanceClient._format_decimal_str(p2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# pricePrecision 兜底
|
||||
try:
|
||||
if pp <= 0:
|
||||
return BinanceClient._format_decimal_str(p.to_integral_value(rounding=rounding))
|
||||
p2 = p.quantize(Decimal(f"1e-{pp}"), rounding=rounding)
|
||||
return BinanceClient._format_decimal_str(p2)
|
||||
except Exception:
|
||||
return BinanceClient._format_decimal_str(p)
|
||||
|
||||
@staticmethod
|
||||
def _adjust_price_to_tick(price: float, tick_size: float, side: str) -> float:
|
||||
"""
|
||||
|
|
@ -1253,6 +1298,165 @@ class BinanceClient:
|
|||
logger.error(f"取消订单失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_open_orders(self, symbol: str) -> List[Dict]:
|
||||
"""
|
||||
获取某交易对的未成交委托(用于防止重复挂保护单)。
|
||||
"""
|
||||
try:
|
||||
orders = await self.client.futures_get_open_orders(symbol=symbol)
|
||||
return orders if isinstance(orders, list) else []
|
||||
except Exception as e:
|
||||
logger.debug(f"{symbol} 获取未成交委托失败: {e}")
|
||||
return []
|
||||
|
||||
async def cancel_open_orders_by_types(self, symbol: str, types: set[str]) -> int:
|
||||
"""
|
||||
取消指定类型的未成交委托(只取消保护单相关类型,避免重复下单)。
|
||||
返回取消数量。
|
||||
"""
|
||||
try:
|
||||
want = {str(t).upper() for t in (types or set())}
|
||||
if not want:
|
||||
return 0
|
||||
orders = await self.get_open_orders(symbol)
|
||||
cancelled = 0
|
||||
for o in orders:
|
||||
try:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
otype = str(o.get("type") or "").upper()
|
||||
oid = o.get("orderId")
|
||||
if otype in want and oid:
|
||||
ok = await self.cancel_order(symbol, int(oid))
|
||||
if ok:
|
||||
cancelled += 1
|
||||
except Exception:
|
||||
continue
|
||||
return cancelled
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def place_trigger_close_position_order(
|
||||
self,
|
||||
symbol: str,
|
||||
position_direction: str,
|
||||
trigger_type: str,
|
||||
stop_price: float,
|
||||
current_price: Optional[float] = None,
|
||||
working_type: str = "MARK_PRICE",
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
在币安侧挂“保护单”,用于止损/止盈:
|
||||
- STOP_MARKET / TAKE_PROFIT_MARKET
|
||||
- closePosition=True(自动平掉该 symbol 的当前仓位)
|
||||
|
||||
注意:这类单子不会在本地生成 exit_reason;触发后我们靠“持仓同步/订单同步”去回写数据库。
|
||||
"""
|
||||
try:
|
||||
symbol_info = await self.get_symbol_info(symbol)
|
||||
dual = None
|
||||
try:
|
||||
dual = await self._get_dual_side_position()
|
||||
except Exception:
|
||||
dual = None
|
||||
|
||||
pd = (position_direction or "").upper()
|
||||
if pd not in {"BUY", "SELL"}:
|
||||
return None
|
||||
|
||||
ttype = str(trigger_type or "").upper()
|
||||
if ttype not in {"STOP_MARKET", "TAKE_PROFIT_MARKET"}:
|
||||
return None
|
||||
|
||||
close_side = "SELL" if pd == "BUY" else "BUY"
|
||||
|
||||
# stopPrice 的“避免立即触发”修正(按 MARK_PRICE)
|
||||
cp = None
|
||||
try:
|
||||
cp = float(current_price) if current_price is not None else None
|
||||
except Exception:
|
||||
cp = None
|
||||
|
||||
tick = 0.0
|
||||
pp = 8
|
||||
try:
|
||||
tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0
|
||||
except Exception:
|
||||
tick = 0.0
|
||||
try:
|
||||
pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8
|
||||
except Exception:
|
||||
pp = 8
|
||||
|
||||
min_step = tick if tick and tick > 0 else (10 ** (-pp) if pp and pp > 0 else 1e-8)
|
||||
|
||||
sp = float(stop_price or 0)
|
||||
if sp <= 0:
|
||||
return None
|
||||
|
||||
# 触发方向约束:
|
||||
# - long 止损:价格 <= stopPrice(stopPrice 应 < current)
|
||||
# - short 止损:价格 >= stopPrice(stopPrice 应 > current)
|
||||
# - long 止盈:价格 >= stopPrice(stopPrice 应 > current)
|
||||
# - short 止盈:价格 <= stopPrice(stopPrice 应 < current)
|
||||
if cp and cp > 0:
|
||||
if ttype == "STOP_MARKET":
|
||||
if pd == "BUY" and sp >= cp:
|
||||
sp = max(0.0, cp - min_step)
|
||||
if pd == "SELL" and sp <= cp:
|
||||
sp = cp + min_step
|
||||
if ttype == "TAKE_PROFIT_MARKET":
|
||||
if pd == "BUY" and sp <= cp:
|
||||
sp = cp + min_step
|
||||
if pd == "SELL" and sp >= cp:
|
||||
sp = max(0.0, cp - min_step)
|
||||
|
||||
# rounding 规则(提高命中概率,避免“显示等于入场价”的误差带来立即触发/永不触发):
|
||||
# 止损:long 用 UP(更靠近当前价),short 用 DOWN
|
||||
# 止盈:long 用 DOWN(更靠近当前价),short 用 UP
|
||||
rounding_mode = "DOWN"
|
||||
if ttype == "STOP_MARKET":
|
||||
rounding_mode = "UP" if pd == "BUY" else "DOWN"
|
||||
else: # TAKE_PROFIT_MARKET
|
||||
rounding_mode = "DOWN" if pd == "BUY" else "UP"
|
||||
|
||||
stop_price_str = self._format_price_str_with_rounding(sp, symbol_info, rounding_mode)
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": symbol,
|
||||
"side": close_side,
|
||||
"type": ttype,
|
||||
"stopPrice": stop_price_str,
|
||||
"closePosition": True,
|
||||
"workingType": working_type,
|
||||
}
|
||||
|
||||
# 对冲模式:必须指定 positionSide
|
||||
if dual is True:
|
||||
params["positionSide"] = "LONG" if pd == "BUY" else "SHORT"
|
||||
|
||||
try:
|
||||
return await self.client.futures_create_order(**params)
|
||||
except BinanceAPIException as e:
|
||||
code = getattr(e, "code", None)
|
||||
# -4061: positionSide 不匹配 -> 兜底切换一次
|
||||
if code == -4061:
|
||||
retry = dict(params)
|
||||
if "positionSide" in retry:
|
||||
retry.pop("positionSide", None)
|
||||
else:
|
||||
retry["positionSide"] = "LONG" if pd == "BUY" else "SHORT"
|
||||
return await self.client.futures_create_order(**retry)
|
||||
raise
|
||||
|
||||
except BinanceAPIException as e:
|
||||
# 常见:Order would immediately trigger(止损/止盈价不在正确一侧)
|
||||
logger.warning(f"{symbol} 挂保护单失败({trigger_type}): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"{symbol} 挂保护单失败({trigger_type}): {e}")
|
||||
return None
|
||||
|
||||
async def set_leverage(self, symbol: str, leverage: int = 10) -> bool:
|
||||
"""
|
||||
设置杠杆倍数
|
||||
|
|
|
|||
|
|
@ -590,6 +590,16 @@ class PositionManager:
|
|||
f"数量={float(binance_position.get('positionAmt', 0)):.4f}, "
|
||||
f"入场价={float(binance_position.get('entryPrice', 0)):.4f}"
|
||||
)
|
||||
# 在币安侧挂“止损/止盈保护单”,避免仅依赖本地监控(服务重启/网络波动时更安全)
|
||||
try:
|
||||
current_mark = None
|
||||
try:
|
||||
current_mark = float(binance_position.get("markPrice", 0) or 0) or None
|
||||
except Exception:
|
||||
current_mark = None
|
||||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_mark)
|
||||
except Exception as e:
|
||||
logger.warning(f"{symbol} 挂币安止盈止损失败(不影响持仓监控): {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"{symbol} [开仓验证] ⚠️ 币安账户中没有持仓,可能订单未成交或被立即平仓"
|
||||
|
|
@ -638,6 +648,22 @@ class PositionManager:
|
|||
try:
|
||||
logger.info(f"{symbol} [平仓] 开始平仓操作 (原因: {reason})")
|
||||
|
||||
# 先取消币安侧的保护单(避免平仓后残留委托导致反向开仓/误触发)
|
||||
try:
|
||||
info0 = self.active_positions.get(symbol) if hasattr(self, "active_positions") else None
|
||||
if info0 and isinstance(info0, dict):
|
||||
for k in ("exchangeSlOrderId", "exchangeTpOrderId"):
|
||||
oid = info0.get(k)
|
||||
if oid:
|
||||
try:
|
||||
await self.client.cancel_order(symbol, int(oid))
|
||||
except Exception:
|
||||
pass
|
||||
info0.pop("exchangeSlOrderId", None)
|
||||
info0.pop("exchangeTpOrderId", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取当前持仓
|
||||
positions = await self.client.get_open_positions()
|
||||
position = next(
|
||||
|
|
@ -1027,6 +1053,82 @@ class PositionManager:
|
|||
q = round(q, qty_precision)
|
||||
return max(0.0, q)
|
||||
|
||||
async def _ensure_exchange_sltp_orders(self, symbol: str, position_info: Dict, current_price: Optional[float] = None) -> None:
|
||||
"""
|
||||
在币安侧挂止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。
|
||||
目的:
|
||||
- 服务重启/网络波动时仍有交易所级别保护
|
||||
- 用户在币安界面能看到止损/止盈委托
|
||||
"""
|
||||
try:
|
||||
enabled = bool(config.TRADING_CONFIG.get("EXCHANGE_SLTP_ENABLED", True))
|
||||
except Exception:
|
||||
enabled = True
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
if not position_info or not isinstance(position_info, dict):
|
||||
return
|
||||
|
||||
side = (position_info.get("side") or "").upper()
|
||||
if side not in {"BUY", "SELL"}:
|
||||
return
|
||||
|
||||
stop_loss = position_info.get("stopLoss")
|
||||
take_profit = position_info.get("takeProfit2") or position_info.get("takeProfit")
|
||||
try:
|
||||
stop_loss = float(stop_loss) if stop_loss is not None else None
|
||||
except Exception:
|
||||
stop_loss = None
|
||||
try:
|
||||
take_profit = float(take_profit) if take_profit is not None else None
|
||||
except Exception:
|
||||
take_profit = None
|
||||
|
||||
if not stop_loss or not take_profit:
|
||||
return
|
||||
|
||||
# 防重复:先取消旧的保护单(仅取消特定类型,避免误伤普通挂单)
|
||||
try:
|
||||
await self.client.cancel_open_orders_by_types(
|
||||
symbol, {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sl_order = await self.client.place_trigger_close_position_order(
|
||||
symbol=symbol,
|
||||
position_direction=side,
|
||||
trigger_type="STOP_MARKET",
|
||||
stop_price=stop_loss,
|
||||
current_price=current_price,
|
||||
working_type="MARK_PRICE",
|
||||
)
|
||||
tp_order = await self.client.place_trigger_close_position_order(
|
||||
symbol=symbol,
|
||||
position_direction=side,
|
||||
trigger_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=take_profit,
|
||||
current_price=current_price,
|
||||
working_type="MARK_PRICE",
|
||||
)
|
||||
|
||||
try:
|
||||
position_info["exchangeSlOrderId"] = sl_order.get("orderId") if isinstance(sl_order, dict) else None
|
||||
except Exception:
|
||||
position_info["exchangeSlOrderId"] = None
|
||||
try:
|
||||
position_info["exchangeTpOrderId"] = tp_order.get("orderId") if isinstance(tp_order, dict) else None
|
||||
except Exception:
|
||||
position_info["exchangeTpOrderId"] = None
|
||||
|
||||
if position_info.get("exchangeSlOrderId") or position_info.get("exchangeTpOrderId"):
|
||||
logger.info(
|
||||
f"{symbol} 已挂币安保护单: "
|
||||
f"SL={position_info.get('exchangeSlOrderId') or '-'} "
|
||||
f"TP={position_info.get('exchangeTpOrderId') or '-'}"
|
||||
)
|
||||
|
||||
async def check_stop_loss_take_profit(self) -> List[str]:
|
||||
"""
|
||||
检查止损止盈
|
||||
|
|
@ -2019,6 +2121,16 @@ class PositionManager:
|
|||
}
|
||||
self.active_positions[symbol] = position_info
|
||||
logger.info(f"{symbol} 已创建临时持仓记录用于监控")
|
||||
# 也为“现有持仓”补挂交易所保护单(重启/掉线更安全)
|
||||
try:
|
||||
mp = None
|
||||
try:
|
||||
mp = float(position.get("markPrice", 0) or 0) or None
|
||||
except Exception:
|
||||
mp = None
|
||||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=mp or current_price)
|
||||
except Exception as e:
|
||||
logger.warning(f"{symbol} 补挂币安止盈止损失败(不影响监控): {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"{symbol} 创建临时持仓记录失败: {e}")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user