1
This commit is contained in:
parent
ceaceb29c6
commit
97fc08671f
@ -91,6 +91,8 @@ def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dic
|
|||||||
size = abs(_safe_float(position.get("size") or position.get("quantity")))
|
size = abs(_safe_float(position.get("size") or position.get("quantity")))
|
||||||
leverage = _safe_float(position.get("leverage"))
|
leverage = _safe_float(position.get("leverage"))
|
||||||
margin = _safe_float(position.get("margin") or position.get("initialMargin") or position.get("initial_margin"))
|
margin = _safe_float(position.get("margin") or position.get("initialMargin") or position.get("initial_margin"))
|
||||||
|
if margin <= 0 and entry_price > 0 and size > 0 and leverage > 0:
|
||||||
|
margin = (entry_price * size) / leverage
|
||||||
unrealized_pnl = _safe_float(position.get("unrealized_pnl") or position.get("pnl_amount"))
|
unrealized_pnl = _safe_float(position.get("unrealized_pnl") or position.get("pnl_amount"))
|
||||||
pnl_percent = _safe_float(position.get("unrealized_pnl_pct") or position.get("pnl_percent") or position.get("percentage"))
|
pnl_percent = _safe_float(position.get("unrealized_pnl_pct") or position.get("pnl_percent") or position.get("percentage"))
|
||||||
|
|
||||||
@ -112,9 +114,48 @@ def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dic
|
|||||||
"setup_basis": position.get("setup_basis"),
|
"setup_basis": position.get("setup_basis"),
|
||||||
"entry_basis": position.get("entry_basis"),
|
"entry_basis": position.get("entry_basis"),
|
||||||
"opened_at": position.get("opened_at") or position.get("created_at"),
|
"opened_at": position.get("opened_at") or position.get("created_at"),
|
||||||
|
"protection": position.get("protection"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_live_position_metrics(position: Dict[str, Any], latest_price: float | None) -> Dict[str, Any]:
|
||||||
|
price = _safe_float(latest_price)
|
||||||
|
if price <= 0:
|
||||||
|
return position
|
||||||
|
|
||||||
|
entry_price = _safe_float(position.get("entry_price"))
|
||||||
|
size = abs(_safe_float(position.get("size")))
|
||||||
|
side = str(position.get("side") or "").lower()
|
||||||
|
margin = _safe_float(position.get("margin"))
|
||||||
|
leverage = _safe_float(position.get("leverage"))
|
||||||
|
|
||||||
|
if entry_price <= 0 or size <= 0:
|
||||||
|
position["mark_price"] = price
|
||||||
|
return position
|
||||||
|
|
||||||
|
direction = 1 if side == "long" else -1
|
||||||
|
pnl_percent_unlevered = ((price - entry_price) / entry_price) * 100 * direction
|
||||||
|
|
||||||
|
if str(position.get("platform")) == "paper":
|
||||||
|
notional = _safe_float(position.get("size"))
|
||||||
|
unrealized_pnl = notional * pnl_percent_unlevered / 100
|
||||||
|
position["unrealized_pnl"] = round(unrealized_pnl, 2)
|
||||||
|
position["pnl_percent"] = round(pnl_percent_unlevered, 4)
|
||||||
|
else:
|
||||||
|
unrealized_pnl = (price - entry_price) * size * direction
|
||||||
|
if margin <= 0 and leverage > 0:
|
||||||
|
margin = (entry_price * size) / leverage
|
||||||
|
position["margin"] = round(margin, 8)
|
||||||
|
leveraged_pnl_pct = (unrealized_pnl / margin * 100) if margin > 0 else (
|
||||||
|
pnl_percent_unlevered * leverage if leverage > 0 else pnl_percent_unlevered
|
||||||
|
)
|
||||||
|
position["unrealized_pnl"] = round(unrealized_pnl, 8)
|
||||||
|
position["pnl_percent"] = round(leveraged_pnl_pct, 4)
|
||||||
|
|
||||||
|
position["mark_price"] = price
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
def _normalize_platform_order(platform: str, order: Dict[str, Any]) -> Dict[str, Any]:
|
def _normalize_platform_order(platform: str, order: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
side_raw = str(order.get("side") or "").lower()
|
side_raw = str(order.get("side") or "").lower()
|
||||||
side = "long" if side_raw in {"buy", "long", "b"} else "short"
|
side = "long" if side_raw in {"buy", "long", "b"} else "short"
|
||||||
@ -449,10 +490,17 @@ async def get_console_snapshot(_: dict = Depends(require_console_access)):
|
|||||||
for symbol in configured_symbols:
|
for symbol in configured_symbols:
|
||||||
price_monitor.subscribe_symbol(symbol)
|
price_monitor.subscribe_symbol(symbol)
|
||||||
|
|
||||||
paper_position_items = [
|
raw_paper_positions = paper_service.get_open_positions()[:12]
|
||||||
_normalize_platform_position("paper", pos)
|
for pos in raw_paper_positions:
|
||||||
for pos in paper_service.get_open_positions()[:12]
|
symbol = _normalize_contract_symbol(pos.get("symbol") or pos.get("coin") or "-")
|
||||||
]
|
if symbol and symbol != "-":
|
||||||
|
price_monitor.subscribe_symbol(symbol)
|
||||||
|
|
||||||
|
paper_position_items = []
|
||||||
|
for pos in raw_paper_positions:
|
||||||
|
normalized = _normalize_platform_position("paper", pos)
|
||||||
|
live_price = price_monitor.get_latest_price(normalized.get("symbol", ""))
|
||||||
|
paper_position_items.append(_apply_live_position_metrics(normalized, live_price))
|
||||||
paper_order_items = [
|
paper_order_items = [
|
||||||
_normalize_platform_order("paper", order)
|
_normalize_platform_order("paper", order)
|
||||||
for order in paper_pending[:12]
|
for order in paper_pending[:12]
|
||||||
@ -461,9 +509,20 @@ async def get_console_snapshot(_: dict = Depends(require_console_access)):
|
|||||||
bitget_position_items = []
|
bitget_position_items = []
|
||||||
bitget_order_items = []
|
bitget_order_items = []
|
||||||
for account in bitget_accounts:
|
for account in bitget_accounts:
|
||||||
|
protection_state_map = {}
|
||||||
|
bitget_executor = getattr(crypto_agent, "bitget_executors", {}).get(account["account_id"])
|
||||||
|
if bitget_executor and hasattr(bitget_executor, "export_position_protection_state"):
|
||||||
|
protection_state_map = bitget_executor.export_position_protection_state()
|
||||||
for pos in account["positions"]["items"][:12]:
|
for pos in account["positions"]["items"][:12]:
|
||||||
normalized = _normalize_platform_position("bitget", pos)
|
normalized = _normalize_platform_position("bitget", pos)
|
||||||
|
if normalized.get("symbol") and normalized.get("symbol") != "-":
|
||||||
|
price_monitor.subscribe_symbol(normalized["symbol"])
|
||||||
|
live_price = price_monitor.get_latest_price(normalized.get("symbol", ""))
|
||||||
|
normalized = _apply_live_position_metrics(normalized, live_price)
|
||||||
normalized["account_id"] = account["account_id"]
|
normalized["account_id"] = account["account_id"]
|
||||||
|
state_key = f"{account['account_id']}:{normalized.get('symbol', '').upper()}:{normalized.get('side', '')}:{float(normalized.get('entry_price', 0) or 0):.8f}"
|
||||||
|
if state_key in protection_state_map:
|
||||||
|
normalized["protection"] = protection_state_map[state_key]
|
||||||
bitget_position_items.append(normalized)
|
bitget_position_items.append(normalized)
|
||||||
for order in account["orders"]["items"][:12]:
|
for order in account["orders"]["items"][:12]:
|
||||||
enriched_order = dict(order)
|
enriched_order = dict(order)
|
||||||
|
|||||||
@ -173,6 +173,13 @@ class Settings(BaseSettings):
|
|||||||
bitget_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT)
|
bitget_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT)
|
||||||
bitget_max_total_leverage: float = 10 # 总杠杆上限(倍数)
|
bitget_max_total_leverage: float = 10 # 总杠杆上限(倍数)
|
||||||
bitget_default_leverage: int = 10 # 默认执行杠杆(启动时同步到交易对)
|
bitget_default_leverage: int = 10 # 默认执行杠杆(启动时同步到交易对)
|
||||||
|
bitget_breakeven_threshold: float = 1.0 # Bitget 保本止损触发阈值(盈利百分比)
|
||||||
|
bitget_trailing_stop_enabled: bool = True # Bitget 是否启用移动止损
|
||||||
|
bitget_trailing_stop_threshold_multiplier: float = 2.0 # Bitget 移动止损触发倍数(相对于保本阈值)
|
||||||
|
bitget_trailing_stop_ratio: float = 0.5 # Bitget 移动止损锁定利润比例
|
||||||
|
bitget_trailing_min_move_step: float = 0.4 # Bitget 每次上移止损的最小利润阶梯(百分比)
|
||||||
|
bitget_dynamic_tp_enabled: bool = True # Bitget 止损抬升时是否联动止盈
|
||||||
|
bitget_dynamic_tp_distance_ratio: float = 0.8 # Bitget 联动止盈时保留原始止盈距离的比例
|
||||||
|
|
||||||
# 账户级止损(所有平台通用)
|
# 账户级止损(所有平台通用)
|
||||||
account_max_drawdown: float = 0.25 # 账户最大回撤(25%),超过则停止交易并平仓
|
account_max_drawdown: float = 0.25 # 账户最大回撤(25%),超过则停止交易并平仓
|
||||||
|
|||||||
@ -185,14 +185,30 @@ class ExecutionGuardian:
|
|||||||
|
|
||||||
elif action == 'MOVE_SL':
|
elif action == 'MOVE_SL':
|
||||||
new_sl = action_info.get('new_sl')
|
new_sl = action_info.get('new_sl')
|
||||||
|
new_tp = action_info.get('new_tp')
|
||||||
pnl_pct = action_info.get('pnl_pct', 0)
|
pnl_pct = action_info.get('pnl_pct', 0)
|
||||||
if new_sl:
|
if new_sl:
|
||||||
|
if hasattr(target.executor, 'move_protection_levels') and target.platform == 'Bitget':
|
||||||
|
move_result = await target.executor.move_protection_levels(
|
||||||
|
symbol=symbol,
|
||||||
|
new_stop_loss=new_sl,
|
||||||
|
new_take_profit=new_tp,
|
||||||
|
)
|
||||||
|
else:
|
||||||
move_result = await target.executor.move_stop_loss(symbol=symbol, new_stop_loss=new_sl)
|
move_result = await target.executor.move_stop_loss(symbol=symbol, new_stop_loss=new_sl)
|
||||||
if move_result.get('success'):
|
if move_result.get('success'):
|
||||||
self._record_action("move_sl", target.target_key, symbol, f"new_sl={new_sl}")
|
detail_parts = [f"new_sl={new_sl:.6f}"]
|
||||||
|
if isinstance(new_tp, (int, float)):
|
||||||
|
detail_parts.append(f"new_tp={new_tp:.6f}")
|
||||||
|
if isinstance(pnl_pct, (int, float)):
|
||||||
|
detail_parts.append(f"pnl={pnl_pct:.2f}%")
|
||||||
|
if reason:
|
||||||
|
detail_parts.append(f"reason={reason}")
|
||||||
|
self._record_action("move_sl", target.target_key, symbol, " | ".join(detail_parts))
|
||||||
|
tp_line = f"\n新止盈: ${new_tp:.2f}" if isinstance(new_tp, (int, float)) else ""
|
||||||
await self.agent._send_alert_notification(
|
await self.agent._send_alert_notification(
|
||||||
f"🔒 [{target.target_key}] 移动止损",
|
f"🔒 [{target.target_key}] 移动止损",
|
||||||
f"交易对: {symbol}\n新止损: ${new_sl:.2f}\n原因: {reason}"
|
f"交易对: {symbol}\n新止损: ${new_sl:.2f}{tp_line}\n原因: {reason}"
|
||||||
)
|
)
|
||||||
await target.executor.send_execution_notification(
|
await target.executor.send_execution_notification(
|
||||||
operation='POSITION_MANAGEMENT',
|
operation='POSITION_MANAGEMENT',
|
||||||
@ -200,6 +216,7 @@ class ExecutionGuardian:
|
|||||||
result={'success': True, 'action': 'MOVE_SL', 'reason': reason},
|
result={'success': True, 'action': 'MOVE_SL', 'reason': reason},
|
||||||
details={
|
details={
|
||||||
'new_sl': new_sl,
|
'new_sl': new_sl,
|
||||||
|
'new_tp': new_tp,
|
||||||
'pnl_percent': pnl_pct,
|
'pnl_percent': pnl_pct,
|
||||||
'account_id': target.account_id,
|
'account_id': target.account_id,
|
||||||
'target_key': target.target_key,
|
'target_key': target.target_key,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class BitgetExecutor(BaseExecutor):
|
|||||||
super().__init__("Bitget")
|
super().__init__("Bitget")
|
||||||
self.account_id = (account_id or "default").strip() or "default"
|
self.account_id = (account_id or "default").strip() or "default"
|
||||||
self.bitget = service or get_bitget_live_service(self.account_id)
|
self.bitget = service or get_bitget_live_service(self.account_id)
|
||||||
|
self._position_protection_state: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def _notification_context(self) -> Dict[str, str]:
|
def _notification_context(self) -> Dict[str, str]:
|
||||||
account_id = getattr(self, 'account_id', 'default') or 'default'
|
account_id = getattr(self, 'account_id', 'default') or 'default'
|
||||||
@ -293,8 +294,8 @@ class BitgetExecutor(BaseExecutor):
|
|||||||
return 24.0
|
return 24.0
|
||||||
|
|
||||||
def get_position_exit_rules(self) -> tuple:
|
def get_position_exit_rules(self) -> tuple:
|
||||||
"""持仓退出规则:(目标盈利 3%, 无最大持仓时间限制)"""
|
"""Bitget 依赖保护单和移动止损管理利润,不做固定盈利自动平仓。"""
|
||||||
return (3.0, float('inf'))
|
return (float('inf'), float('inf'))
|
||||||
|
|
||||||
def get_fee_rate(self) -> float:
|
def get_fee_rate(self) -> float:
|
||||||
"""手续费率: 0.06% (taker)"""
|
"""手续费率: 0.06% (taker)"""
|
||||||
@ -380,6 +381,181 @@ class BitgetExecutor(BaseExecutor):
|
|||||||
logger.error(f"Bitget 移动止损失败: {e}")
|
logger.error(f"Bitget 移动止损失败: {e}")
|
||||||
return {'success': False, 'message': str(e)}
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
async def move_protection_levels(self,
|
||||||
|
symbol: str,
|
||||||
|
new_stop_loss: float,
|
||||||
|
new_take_profit: Optional[float] = None,
|
||||||
|
current_stop_loss: Optional[float] = None) -> Dict[str, Any]:
|
||||||
|
"""同时更新 Bitget 的止损/止盈保护单。"""
|
||||||
|
try:
|
||||||
|
position = self.bitget.get_position_for_symbol(symbol)
|
||||||
|
if not position:
|
||||||
|
return {'success': False, 'message': f'找不到 {symbol} 的持仓'}
|
||||||
|
|
||||||
|
current_tp_sl = self.bitget.get_tp_sl_prices(symbol.replace('USDT', ''))
|
||||||
|
target_tp = new_take_profit if new_take_profit is not None else current_tp_sl.get('take_profit')
|
||||||
|
result = self.bitget.set_tp_sl(
|
||||||
|
symbol=symbol.replace('USDT', ''),
|
||||||
|
is_long=position['size'] > 0,
|
||||||
|
size=abs(position['size']),
|
||||||
|
tp_price=target_tp,
|
||||||
|
sl_price=new_stop_loss
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.info(
|
||||||
|
f" ✅ Bitget 保护单更新成功: {symbol} "
|
||||||
|
f"SL→${new_stop_loss:.2f} TP→{f'${target_tp:.2f}' if isinstance(target_tp, (int, float)) else '保持'}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'保护单更新成功: SL={new_stop_loss:.2f}',
|
||||||
|
'take_profit': target_tp,
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = result.get('errors', [])
|
||||||
|
return {'success': False, 'message': '; '.join(errors) if errors else '保护单更新失败'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bitget 更新保护单失败: {e}")
|
||||||
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
def _protection_state_key(self, position: Dict[str, Any]) -> str:
|
||||||
|
symbol = str(position.get('symbol') or '').upper()
|
||||||
|
side = str(position.get('side') or '')
|
||||||
|
entry_price = float(position.get('entry_price', 0) or 0)
|
||||||
|
return f"{self.account_id}:{symbol}:{side}:{entry_price:.8f}"
|
||||||
|
|
||||||
|
def export_position_protection_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
return {key: dict(value) for key, value in self._position_protection_state.items()}
|
||||||
|
|
||||||
|
def _compute_trailing_take_profit(self,
|
||||||
|
position: Dict[str, Any],
|
||||||
|
current_price: float,
|
||||||
|
new_stop_loss: float,
|
||||||
|
current_take_profit: Optional[float]) -> Optional[float]:
|
||||||
|
if not self.bitget.settings.bitget_dynamic_tp_enabled:
|
||||||
|
return current_take_profit
|
||||||
|
if not isinstance(current_take_profit, (int, float)) or current_take_profit <= 0:
|
||||||
|
return current_take_profit
|
||||||
|
|
||||||
|
side = position.get('side')
|
||||||
|
entry_price = float(position.get('entry_price', 0) or 0)
|
||||||
|
distance_ratio = float(self.bitget.settings.bitget_dynamic_tp_distance_ratio or 0.8)
|
||||||
|
|
||||||
|
if side == 'buy':
|
||||||
|
original_tp_distance = float(current_take_profit) - entry_price
|
||||||
|
candidate = new_stop_loss + (original_tp_distance * distance_ratio)
|
||||||
|
if candidate <= current_price:
|
||||||
|
return current_take_profit
|
||||||
|
return max(float(current_take_profit), candidate)
|
||||||
|
|
||||||
|
original_tp_distance = entry_price - float(current_take_profit)
|
||||||
|
candidate = new_stop_loss - (original_tp_distance * distance_ratio)
|
||||||
|
if candidate >= current_price:
|
||||||
|
return current_take_profit
|
||||||
|
return min(float(current_take_profit), candidate)
|
||||||
|
|
||||||
|
def check_position_management(self,
|
||||||
|
positions: List[Dict],
|
||||||
|
current_prices: Dict[str, float],
|
||||||
|
volatility_data: Optional[Dict[str, float]] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Bitget 仓位保护:先保本,再基于最大浮盈分段上移止损。"""
|
||||||
|
actions: List[Dict[str, Any]] = []
|
||||||
|
active_keys = set()
|
||||||
|
|
||||||
|
breakeven_threshold = float(self.bitget.settings.bitget_breakeven_threshold or 1.0)
|
||||||
|
trailing_enabled = bool(self.bitget.settings.bitget_trailing_stop_enabled)
|
||||||
|
trailing_threshold = breakeven_threshold * float(self.bitget.settings.bitget_trailing_stop_threshold_multiplier or 2.0)
|
||||||
|
trailing_ratio = float(self.bitget.settings.bitget_trailing_stop_ratio or 0.5)
|
||||||
|
min_move_step = float(self.bitget.settings.bitget_trailing_min_move_step or 0.4)
|
||||||
|
|
||||||
|
for pos in positions:
|
||||||
|
symbol = pos.get('symbol')
|
||||||
|
current_price = float(current_prices.get(symbol, pos.get('entry_price', 0)) or 0)
|
||||||
|
entry_price = float(pos.get('entry_price', 0) or 0)
|
||||||
|
side = pos.get('side')
|
||||||
|
current_sl = pos.get('stop_loss')
|
||||||
|
current_tp = pos.get('take_profit')
|
||||||
|
|
||||||
|
if current_price <= 0 or entry_price <= 0 or side not in {'buy', 'sell'} or not isinstance(current_sl, (int, float)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pnl_pct = ((current_price - entry_price) / entry_price * 100) if side == 'buy' else ((entry_price - current_price) / entry_price * 100)
|
||||||
|
state_key = self._protection_state_key(pos)
|
||||||
|
active_keys.add(state_key)
|
||||||
|
state = self._position_protection_state.setdefault(state_key, {
|
||||||
|
'max_pnl_pct': pnl_pct,
|
||||||
|
'breakeven_done': False,
|
||||||
|
'trailing_active': False,
|
||||||
|
})
|
||||||
|
state['max_pnl_pct'] = max(float(state.get('max_pnl_pct', pnl_pct) or pnl_pct), pnl_pct)
|
||||||
|
max_pnl_pct = float(state['max_pnl_pct'])
|
||||||
|
|
||||||
|
if trailing_enabled and pnl_pct >= trailing_threshold:
|
||||||
|
locked_profit_pct = max_pnl_pct * trailing_ratio
|
||||||
|
if side == 'buy':
|
||||||
|
new_sl = entry_price * (1 + locked_profit_pct / 100)
|
||||||
|
current_locked_pct = (float(current_sl) - entry_price) / entry_price * 100
|
||||||
|
can_move = new_sl > float(current_sl) and (locked_profit_pct - current_locked_pct) >= min_move_step
|
||||||
|
else:
|
||||||
|
new_sl = entry_price * (1 - locked_profit_pct / 100)
|
||||||
|
current_locked_pct = (entry_price - float(current_sl)) / entry_price * 100
|
||||||
|
can_move = new_sl < float(current_sl) and (locked_profit_pct - current_locked_pct) >= min_move_step
|
||||||
|
|
||||||
|
if can_move:
|
||||||
|
new_tp = self._compute_trailing_take_profit(pos, current_price, new_sl, current_tp)
|
||||||
|
state['trailing_active'] = True
|
||||||
|
state['last_move_reason'] = 'trailing'
|
||||||
|
state['last_new_sl'] = new_sl
|
||||||
|
state['last_new_tp'] = new_tp
|
||||||
|
actions.append({
|
||||||
|
'symbol': symbol,
|
||||||
|
'action': 'MOVE_SL',
|
||||||
|
'new_sl': new_sl,
|
||||||
|
'new_tp': new_tp,
|
||||||
|
'pnl_pct': pnl_pct,
|
||||||
|
'reason': f"最高盈利 {max_pnl_pct:.1f}% ,锁定利润 {locked_profit_pct:.1f}% ,上移止损",
|
||||||
|
'priority': 3,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pnl_pct >= breakeven_threshold:
|
||||||
|
if side == 'buy' and float(current_sl) < entry_price:
|
||||||
|
state['breakeven_done'] = True
|
||||||
|
state['last_move_reason'] = 'breakeven'
|
||||||
|
state['last_new_sl'] = entry_price
|
||||||
|
state['last_new_tp'] = current_tp
|
||||||
|
actions.append({
|
||||||
|
'symbol': symbol,
|
||||||
|
'action': 'MOVE_SL',
|
||||||
|
'new_sl': entry_price,
|
||||||
|
'new_tp': current_tp,
|
||||||
|
'pnl_pct': pnl_pct,
|
||||||
|
'reason': f"盈利 {pnl_pct:.1f}% >= {breakeven_threshold:.1f}% ,止损移到保本",
|
||||||
|
'priority': 3,
|
||||||
|
})
|
||||||
|
elif side == 'sell' and float(current_sl) > entry_price:
|
||||||
|
state['breakeven_done'] = True
|
||||||
|
state['last_move_reason'] = 'breakeven'
|
||||||
|
state['last_new_sl'] = entry_price
|
||||||
|
state['last_new_tp'] = current_tp
|
||||||
|
actions.append({
|
||||||
|
'symbol': symbol,
|
||||||
|
'action': 'MOVE_SL',
|
||||||
|
'new_sl': entry_price,
|
||||||
|
'new_tp': current_tp,
|
||||||
|
'pnl_pct': pnl_pct,
|
||||||
|
'reason': f"盈利 {pnl_pct:.1f}% >= {breakeven_threshold:.1f}% ,止损移到保本",
|
||||||
|
'priority': 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
stale_keys = [key for key in self._position_protection_state.keys() if key not in active_keys]
|
||||||
|
for key in stale_keys:
|
||||||
|
self._position_protection_state.pop(key, None)
|
||||||
|
|
||||||
|
actions.sort(key=lambda x: x.get('priority', 99))
|
||||||
|
return actions
|
||||||
|
|
||||||
# ==================== 辅助方法 ====================
|
# ==================== 辅助方法 ====================
|
||||||
|
|
||||||
def _calculate_contracts(self, symbol: str, margin: float, price: float, leverage: int) -> int:
|
def _calculate_contracts(self, symbol: str, margin: float, price: float, leverage: int) -> int:
|
||||||
|
|||||||
@ -2271,6 +2271,97 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protection-action-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid rgba(126, 200, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-item.move {
|
||||||
|
border-color: rgba(255, 184, 77, 0.18);
|
||||||
|
background: rgba(255, 184, 77, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-item.repair {
|
||||||
|
border-color: rgba(126, 200, 255, 0.18);
|
||||||
|
background: rgba(126, 200, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-summary-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-summary-card .label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-summary-card .value {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-summary-card .sub {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-symbol {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-action-detail {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -2506,6 +2597,7 @@
|
|||||||
.signal-grid,
|
.signal-grid,
|
||||||
.ops-grid,
|
.ops-grid,
|
||||||
.coord-grid,
|
.coord-grid,
|
||||||
|
.protection-summary-grid,
|
||||||
.position-card-grid,
|
.position-card-grid,
|
||||||
.pane-grid.two-col,
|
.pane-grid.two-col,
|
||||||
.pane-grid.equal,
|
.pane-grid.equal,
|
||||||
@ -2772,6 +2864,19 @@
|
|||||||
<div class="loading">正在整理跨平台挂单...</div>
|
<div class="loading">正在整理跨平台挂单...</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel workspace-panel unified-section">
|
||||||
|
<div class="workspace-head">
|
||||||
|
<div>
|
||||||
|
<div class="section-label">Protection Layer</div>
|
||||||
|
<h2 class="panel-title" style="margin-top: 12px;">保护动作</h2>
|
||||||
|
<div class="panel-sub">最近 10 条保本、移动止损和 TP/SL 修复动作,方便确认 Bitget 执行保护是否正常</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="protection-action-list" id="protectionActionList">
|
||||||
|
<div class="loading">正在整理保护动作...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -2909,6 +3014,26 @@
|
|||||||
return String(value || '-').toUpperCase();
|
return String(value || '-').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProtectionSummary(protection) {
|
||||||
|
if (!protection || typeof protection !== 'object') return '-';
|
||||||
|
const lastSl = Number(protection.last_new_sl || 0);
|
||||||
|
const lastTp = Number(protection.last_new_tp || 0);
|
||||||
|
const latestLine = lastSl > 0
|
||||||
|
? `<br>最近 SL ${formatMoney(lastSl)}${lastTp > 0 ? ` / TP ${formatMoney(lastTp)}` : ''}`
|
||||||
|
: '';
|
||||||
|
if (protection.trailing_active) {
|
||||||
|
const locked = Number(protection.max_pnl_pct || 0) * 0.5;
|
||||||
|
return `移动止损中 / 最大浮盈 ${formatPercent(protection.max_pnl_pct || 0, 1)} / 参考锁盈 ${formatPercent(locked, 1)}${latestLine}`;
|
||||||
|
}
|
||||||
|
if (protection.breakeven_done) {
|
||||||
|
return `已保本 / 最大浮盈 ${formatPercent(protection.max_pnl_pct || 0, 1)}${latestLine}`;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(Number(protection.max_pnl_pct))) {
|
||||||
|
return `监控中 / 最大浮盈 ${formatPercent(protection.max_pnl_pct || 0, 1)}${latestLine}`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
function setFeedback(message, isError = false) {
|
function setFeedback(message, isError = false) {
|
||||||
const el = document.getElementById('feedback');
|
const el = document.getElementById('feedback');
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@ -4026,6 +4151,103 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function protectionActionLabel(actionType) {
|
||||||
|
const value = String(actionType || '').toLowerCase();
|
||||||
|
if (value === 'move_sl') return '移动保护';
|
||||||
|
if (value === 'repair_tpsl') return '修复保护单';
|
||||||
|
if (value === 'fallback_tpsl') return '回补保护单';
|
||||||
|
if (value === 'cleanup_pending_tpsl') return '清理保护残单';
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectionActionClass(actionType) {
|
||||||
|
const value = String(actionType || '').toLowerCase();
|
||||||
|
if (value === 'move_sl') return 'move';
|
||||||
|
return 'repair';
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProtectionAction(actionType) {
|
||||||
|
const value = String(actionType || '').toLowerCase();
|
||||||
|
if (value === 'move_sl') return 'move';
|
||||||
|
if (value === 'repair_tpsl' || value === 'fallback_tpsl') return 'repair';
|
||||||
|
if (value === 'cleanup_pending_tpsl') return 'cleanup';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProtectionActionDetail(item) {
|
||||||
|
const lines = [];
|
||||||
|
const platform = item.platform || '-';
|
||||||
|
lines.push(`目标: ${platform}`);
|
||||||
|
if (item.detail) {
|
||||||
|
const detail = String(item.detail)
|
||||||
|
.replaceAll(' | ', '\n')
|
||||||
|
.replace(/^reason=/gm, 'reason: ')
|
||||||
|
.replace(/^new_sl=/gm, 'new_sl: ')
|
||||||
|
.replace(/^new_tp=/gm, 'new_tp: ')
|
||||||
|
.replace(/^pnl=/gm, 'pnl: ');
|
||||||
|
lines.push(detail);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProtectionActionSummary(actions) {
|
||||||
|
const moveItems = actions.filter((item) => classifyProtectionAction(item.action_type) === 'move');
|
||||||
|
const repairItems = actions.filter((item) => classifyProtectionAction(item.action_type) === 'repair');
|
||||||
|
const cleanupItems = actions.filter((item) => classifyProtectionAction(item.action_type) === 'cleanup');
|
||||||
|
const latestMove = moveItems[0];
|
||||||
|
const latestRepair = repairItems[0];
|
||||||
|
const latestCleanup = cleanupItems[0];
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="protection-summary-grid">
|
||||||
|
<div class="protection-summary-card">
|
||||||
|
<span class="label">锁盈推进</span>
|
||||||
|
<span class="value">${moveItems.length}</span>
|
||||||
|
<span class="sub">${latestMove ? `${latestMove.symbol || '-'} · ${relativeTime(latestMove.timestamp)}` : '最近没有移动保护'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="protection-summary-card">
|
||||||
|
<span class="label">保护修复</span>
|
||||||
|
<span class="value">${repairItems.length}</span>
|
||||||
|
<span class="sub">${latestRepair ? `${latestRepair.symbol || '-'} · ${relativeTime(latestRepair.timestamp)}` : '最近没有补修保护单'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="protection-summary-card">
|
||||||
|
<span class="label">残单清理</span>
|
||||||
|
<span class="value">${cleanupItems.length}</span>
|
||||||
|
<span class="sub">${latestCleanup ? `${latestCleanup.symbol || '-'} · ${relativeTime(latestCleanup.timestamp)}` : '最近没有清理动作'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProtectionActions(guardianState) {
|
||||||
|
const container = document.getElementById('protectionActionList');
|
||||||
|
const actions = (guardianState?.last_actions || [])
|
||||||
|
.filter((item) => ['move_sl', 'repair_tpsl', 'fallback_tpsl', 'cleanup_pending_tpsl'].includes(String(item.action_type || '').toLowerCase()))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (!actions.length) {
|
||||||
|
container.innerHTML = compactEmpty('最近没有保护动作', '没有移动止损或补设保护单时,这里会保持紧凑显示。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
${renderProtectionActionSummary(actions)}
|
||||||
|
${actions.map((item) => `
|
||||||
|
<article class="protection-action-item ${protectionActionClass(item.action_type)}">
|
||||||
|
<div class="protection-action-top">
|
||||||
|
<div class="protection-action-main">
|
||||||
|
<span class="platform-pill bitget">${platformDisplayName(item.platform)}</span>
|
||||||
|
<span class="event-inline-badge">${protectionActionLabel(item.action_type)}</span>
|
||||||
|
<span class="protection-action-symbol">${item.symbol || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="unified-time">${item.timestamp ? `${relativeTime(item.timestamp)} / ${formatTime(item.timestamp)}` : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="protection-action-detail">${formatProtectionActionDetail(item).replaceAll('\n', '<br>')}</div>
|
||||||
|
</article>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderUnifiedPositions(positions) {
|
function renderUnifiedPositions(positions) {
|
||||||
const toolbar = document.getElementById('positionsToolbar');
|
const toolbar = document.getElementById('positionsToolbar');
|
||||||
const container = document.getElementById('positionsTable');
|
const container = document.getElementById('positionsTable');
|
||||||
@ -4092,8 +4314,10 @@
|
|||||||
<span class="value">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</span>
|
<span class="value">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="position-card-block">
|
<div class="position-card-block">
|
||||||
<span class="label">Setup / Entry</span>
|
<span class="label">${normalizePlatformKey(item.platform) === 'bitget' ? '保护状态' : 'Setup / Entry'}</span>
|
||||||
<span class="value">${item.setup_type || '-'}${item.entry_basis || item.setup_basis ? `<br>${item.entry_basis || item.setup_basis}` : ''}</span>
|
<span class="value">${normalizePlatformKey(item.platform) === 'bitget'
|
||||||
|
? formatProtectionSummary(item.protection)
|
||||||
|
: `${item.setup_type || '-'}${item.entry_basis || item.setup_basis ? `<br>${item.entry_basis || item.setup_basis}` : ''}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -4291,6 +4515,7 @@
|
|||||||
renderAttentionItems(data.management?.attention_items || []);
|
renderAttentionItems(data.management?.attention_items || []);
|
||||||
renderUnifiedPositions(data.management?.positions || []);
|
renderUnifiedPositions(data.management?.positions || []);
|
||||||
renderUnifiedOrders(data.management?.orders || []);
|
renderUnifiedOrders(data.management?.orders || []);
|
||||||
|
renderProtectionActions(data.crypto_agent?.execution_guardian || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setFeedback(`总控台加载失败: ${error.message}`, true);
|
setFeedback(`总控台加载失败: ${error.message}`, true);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user