fix(trading): close five risk gaps surfaced by pre-launch audit
1. hyperliquid.open_position now returns effective_leverage. HL clips the requested leverage to each asset's max (e.g. memes capped at 3×) and silently applied the lower value; we were discarding that. For System-2 this meant sys2_protective_stop_pct was computed against the REQUESTED leverage, so the "inside-liquidation" full-close rung was actually well inside an 8.5% stop while the real liquidation sat ~33% away — strategy intent broken. bot_engine now reads the effective value back, recomputes the protective stop + derisk ladder against it, and freezes the correct values into BotTrade.eff_* / .leverage. 2. circuit_breaker.clear_trip now clears BOTH systems by default. The sys2 column was never reachable from any reset path — a sys2 trip forced users to wait the full 24h lockout even after explicit human re-arm. 3. /api/user/.../manual-window and /auto-trade re-arm endpoints now clear sys1 AND sys2 CB on the same human ack. Symmetry with check_and_trip / is_tripped which already supported both systems. 4. telegram.py deep-link map: btc_bottom_reversal + funding_reversal alerts now point at /en/macro instead of the now-404 /en/btc. 5. (Already landed: _sys2_mode UnboundLocalError fix in bot_engine — restated here for the audit trail.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -493,6 +493,33 @@ async def _execute_for_subscriber(
|
||||
result = await trader.open_position(asset, side, sized_position_usd)
|
||||
entry_price = result.get('fill_price', 0.0)
|
||||
|
||||
# HL may clip leverage below the requested value (e.g. meme
|
||||
# asset capped at 3× when the user asked for 10×). Trust HL's
|
||||
# actual value — anything downstream that uses leverage for
|
||||
# risk math (sys2 protective stop, de-risk ladder, BotTrade
|
||||
# row) MUST use this, not sub["leverage"].
|
||||
# paper-mode branch has no HL leverage → fall back to req.
|
||||
effective_leverage = int(result.get('effective_leverage')
|
||||
or sub["leverage"])
|
||||
if effective_leverage != sub["leverage"]:
|
||||
logger.warning(
|
||||
"Sub %s: HL clipped leverage %d → %d for %s — "
|
||||
"recomputing sys2 protective stop / derisk ladder",
|
||||
wallet, sub["leverage"], effective_leverage, asset,
|
||||
)
|
||||
|
||||
# Sys2: rebuild protective stop + derisk ladder against the
|
||||
# ACTUAL leverage (so the full-close rung stays inside the
|
||||
# real HL liquidation line). Sys1 doesn't depend on leverage
|
||||
# for its stop math, but we still record the true value so
|
||||
# BotTrade.leverage reflects what HL actually applied.
|
||||
sub["leverage"] = effective_leverage
|
||||
if sys2:
|
||||
from app.services.signal_categories import (
|
||||
sys2_protective_stop_pct as _sps,
|
||||
)
|
||||
sub["stop_loss_pct"] = _sps(effective_leverage)
|
||||
|
||||
# Resolve the FROZEN exit profile ONCE. Everything downstream
|
||||
# (the watchdog AND recovery-after-restart) reads these — never
|
||||
# the mutable Subscription/category config again.
|
||||
@@ -501,6 +528,9 @@ async def _execute_for_subscriber(
|
||||
_time.time() + sub["_min_hold_minutes"] * 60
|
||||
if sub.get("_min_hold_minutes") else None
|
||||
)
|
||||
# sub["stop_loss_pct"] / sub["leverage"] were just rewritten
|
||||
# above to match the HL-clipped effective leverage when sys2,
|
||||
# so freezing them into eff here is correct.
|
||||
eff = dict(
|
||||
take_profit_pct = sub["take_profit_pct"],
|
||||
stop_loss_pct = sub["stop_loss_pct"],
|
||||
@@ -512,6 +542,13 @@ async def _execute_for_subscriber(
|
||||
min_hold_until_ts = eff_min_hold_ts,
|
||||
)
|
||||
|
||||
# Resolve sys2 mode BEFORE constructing the BotTrade row.
|
||||
# Python made this a local-name; reading it inside the
|
||||
# constructor before the later assignment used to throw
|
||||
# UnboundLocalError on every sys2 fire, leaving the HL
|
||||
# position open with NO DB record and NO watchdog. Critical.
|
||||
_sys2_mode = sub.get("_sys2_mode", "standard") if sys2 else None
|
||||
|
||||
trade = BotTrade(
|
||||
asset=asset,
|
||||
side=side,
|
||||
@@ -522,7 +559,7 @@ async def _execute_for_subscriber(
|
||||
hl_order_id=result.get('order_id'),
|
||||
size_usd=sized_position_usd,
|
||||
base_size_usd=sized_position_usd, # immutable; pyramiding grows size_usd
|
||||
sys2_mode=(_sys2_mode if sys2 else None),
|
||||
sys2_mode=_sys2_mode,
|
||||
leverage=sub["leverage"],
|
||||
# Freeze the exit profile on the row.
|
||||
eff_take_profit_pct = eff["take_profit_pct"],
|
||||
@@ -542,18 +579,21 @@ async def _execute_for_subscriber(
|
||||
side, asset, wallet, entry_price, sized_position_usd, trade.id)
|
||||
|
||||
# Register with the watchdog using the FROZEN profile.
|
||||
# _sys2_mode was resolved before the BotTrade constructor above;
|
||||
# reuse the same value here so de-risk/add-on ladders match
|
||||
# the row that was just written.
|
||||
from app.services.tp_sl_monitor import register_trade
|
||||
_derisk = None
|
||||
_addon = None
|
||||
_peak_trail = None
|
||||
_sys2_mode = sub.get("_sys2_mode", "standard")
|
||||
if sys2:
|
||||
_mode_for_ladders = _sys2_mode or "standard"
|
||||
from app.services.signal_categories import (
|
||||
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
|
||||
)
|
||||
_derisk = sys2_derisk_ladder(sub["leverage"], _sys2_mode)
|
||||
_addon = sys2_addon_ladder(_sys2_mode)
|
||||
_peak_trail = sys2_peak_trail(_sys2_mode)
|
||||
_derisk = sys2_derisk_ladder(sub["leverage"], _mode_for_ladders)
|
||||
_addon = sys2_addon_ladder(_mode_for_ladders)
|
||||
_peak_trail = sys2_peak_trail(_mode_for_ladders)
|
||||
register_trade(
|
||||
trade_id=trade.id,
|
||||
wallet=wallet,
|
||||
|
||||
Reference in New Issue
Block a user