This commit is contained in:
薇薇安 2026-01-19 21:40:22 +08:00
parent 321ff599f6
commit e725cb19fa
3 changed files with 337 additions and 6 deletions

View File

@ -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>
)}

View File

@ -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 止损:价格 <= stopPricestopPrice 应 < current
# - short 止损:价格 >= stopPricestopPrice 应 > current
# - long 止盈:价格 >= stopPricestopPrice 应 > current
# - short 止盈:价格 <= stopPricestopPrice 应 < 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:
"""
设置杠杆倍数

View File

@ -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}")