diff --git a/app/api/user.py b/app/api/user.py index 78113a2..c50e741 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -339,13 +339,18 @@ async def set_manual_window( until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw) sub.manual_window_until = until new_until_iso = iso_utc(until) - # Explicit re-arm clears any active circuit-breaker trip — human in - # the loop has acknowledged the risk and chosen to resume. + # Explicit re-arm clears any active circuit-breaker trip on BOTH + # systems — the human ack covers the whole wallet. (Earlier only the + # sys1 breaker was cleared, leaving the sys2 book locked for 24h.) cb_cleared = False - if sub.circuit_breaker_tripped_at is not None: - sub.circuit_breaker_tripped_at = None - sub.circuit_breaker_reason = None - cb_cleared = True + for _col_at, _col_reason in ( + ("circuit_breaker_tripped_at", "circuit_breaker_reason"), + ("sys2_cb_tripped_at", "sys2_cb_reason"), + ): + if getattr(sub, _col_at) is not None: + setattr(sub, _col_at, None) + setattr(sub, _col_reason, None) + cb_cleared = True logger.info("Manual window armed for %s: until %s (%dh)%s", wallet, until, hours_raw, " — CB cleared" if cb_cleared else "") @@ -402,11 +407,18 @@ async def set_auto_trade( sub.auto_trade = enabled cb_cleared = False - if enabled and sub.circuit_breaker_tripped_at is not None: - # Turning Auto-Trade ON = explicit human ack → clear the breaker. - sub.circuit_breaker_tripped_at = None - sub.circuit_breaker_reason = None - cb_cleared = True + if enabled: + # Turning Auto-Trade ON = explicit human ack → clear BOTH breakers + # (sys1 Trump + sys2 reversal). Previously only sys1 was cleared, + # leaving the reversal book locked even after the user re-armed. + for _col_at, _col_reason in ( + ("circuit_breaker_tripped_at", "circuit_breaker_reason"), + ("sys2_cb_tripped_at", "sys2_cb_reason"), + ): + if getattr(sub, _col_at) is not None: + setattr(sub, _col_at, None) + setattr(sub, _col_reason, None) + cb_cleared = True await db.commit() logger.info("Auto-Trade %s for %s%s", "ON" if enabled else "OFF", wallet, diff --git a/app/services/bot_engine.py b/app/services/bot_engine.py index f0d60ee..40f3cff 100644 --- a/app/services/bot_engine.py +++ b/app/services/bot_engine.py @@ -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, diff --git a/app/services/circuit_breaker.py b/app/services/circuit_breaker.py index d1263a3..18bee22 100644 --- a/app/services/circuit_breaker.py +++ b/app/services/circuit_breaker.py @@ -182,20 +182,37 @@ def is_tripped(sub_dict: dict, system: str = "sys1") -> tuple[bool, str]: # ─── Manual reset (called from /manual-window endpoint) ───────────────────── -async def clear_trip(wallet: str, db: AsyncSession) -> bool: - """Clear the trip state. Returns True if a trip was actually cleared. +async def clear_trip( + wallet: str, db: AsyncSession, system: Optional[str] = None, +) -> bool: + """Clear circuit-breaker trip state. Returns True if anything cleared. - Called when the user explicitly re-arms manual_window — that's the - human-in-the-loop unblock. Just letting LOCKOUT_HOURS expire works too, - but most users will hit the button. + `system` selects which breaker to clear: + None → clear BOTH sys1 and sys2 (default for human-ack endpoints) + "sys1"/"sys2" → clear that one only + + Critical: there are TWO independent breakers (Trump book / reversal book). + The earlier implementation only cleared sys1; a sys2 trip therefore had + no manual reset path and forced users to wait 24h. When the human + re-arms via manual-window / auto-trade, they're acknowledging risk + across the wallet — clear both. """ sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == wallet) )).scalar_one_or_none() - if sub is None or sub.circuit_breaker_tripped_at is None: + if sub is None: return False - sub.circuit_breaker_tripped_at = None - sub.circuit_breaker_reason = None - await db.commit() - logger.info("CB cleared for %s", wallet) - return True + + targets = (system,) if system in ("sys1", "sys2") else ("sys1", "sys2") + cleared_any = False + for s in targets: + col_at, col_reason = _CB_COLS[s] + if getattr(sub, col_at) is not None: + setattr(sub, col_at, None) + setattr(sub, col_reason, None) + cleared_any = True + logger.info("CB[%s] cleared for %s", s, wallet) + + if cleared_any: + await db.commit() + return cleared_any diff --git a/app/services/hyperliquid.py b/app/services/hyperliquid.py index a307809..0c63055 100644 --- a/app/services/hyperliquid.py +++ b/app/services/hyperliquid.py @@ -172,8 +172,13 @@ class HyperliquidTrader: coin = asset.upper() is_buy = side.lower() == "long" - # 1. Set leverage before opening - await self.set_leverage(coin) + # 1. Set leverage before opening. Capture HL's clipped value — for + # illiquid / meme assets the requested leverage may exceed the + # asset's max and HL silently clips. Caller MUST use this + # `effective_leverage` for downstream risk math (protective stop, + # liquidation distance) or stops will fire far short of the real + # HL liquidation line. + effective_leverage = await self.set_leverage(coin) # 2. Compute coin size from USD notional mid = await self._mid_price(coin) @@ -232,9 +237,14 @@ class HyperliquidTrader: oid_src = filled_info or status.get("resting") or {} order_id = str(oid_src.get("oid", "")) if oid_src else "" - logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s)", - side, coin, fill_price, total_sz, order_id) - return {"order_id": order_id, "fill_price": fill_price, "size_coins": total_sz} + logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s, lev=%dx)", + side, coin, fill_price, total_sz, order_id, effective_leverage) + return { + "order_id": order_id, + "fill_price": fill_price, + "size_coins": total_sz, + "effective_leverage": effective_leverage, + } async def close_position(self, asset: str) -> dict: """ diff --git a/app/services/telegram.py b/app/services/telegram.py index 18e0fb0..e0bfd31 100644 --- a/app/services/telegram.py +++ b/app/services/telegram.py @@ -122,8 +122,8 @@ def format_post(post: Post) -> str: # Use the section that matches the source path = { "truth": "/en/trump", - "btc_bottom_reversal": "/en/btc", - "funding_reversal": "/en/btc", + "btc_bottom_reversal": "/en/macro", + "funding_reversal": "/en/macro", "kol_divergence": "/en/kol", }.get(post.source, "/en") link = f'\n\n→ open in dashboard'