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:
+23
-11
@@ -339,13 +339,18 @@ async def set_manual_window(
|
|||||||
until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw)
|
until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw)
|
||||||
sub.manual_window_until = until
|
sub.manual_window_until = until
|
||||||
new_until_iso = iso_utc(until)
|
new_until_iso = iso_utc(until)
|
||||||
# Explicit re-arm clears any active circuit-breaker trip — human in
|
# Explicit re-arm clears any active circuit-breaker trip on BOTH
|
||||||
# the loop has acknowledged the risk and chosen to resume.
|
# 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
|
cb_cleared = False
|
||||||
if sub.circuit_breaker_tripped_at is not None:
|
for _col_at, _col_reason in (
|
||||||
sub.circuit_breaker_tripped_at = None
|
("circuit_breaker_tripped_at", "circuit_breaker_reason"),
|
||||||
sub.circuit_breaker_reason = None
|
("sys2_cb_tripped_at", "sys2_cb_reason"),
|
||||||
cb_cleared = True
|
):
|
||||||
|
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",
|
logger.info("Manual window armed for %s: until %s (%dh)%s",
|
||||||
wallet, until, hours_raw, " — CB cleared" if cb_cleared else "")
|
wallet, until, hours_raw, " — CB cleared" if cb_cleared else "")
|
||||||
|
|
||||||
@@ -402,11 +407,18 @@ async def set_auto_trade(
|
|||||||
|
|
||||||
sub.auto_trade = enabled
|
sub.auto_trade = enabled
|
||||||
cb_cleared = False
|
cb_cleared = False
|
||||||
if enabled and sub.circuit_breaker_tripped_at is not None:
|
if enabled:
|
||||||
# Turning Auto-Trade ON = explicit human ack → clear the breaker.
|
# Turning Auto-Trade ON = explicit human ack → clear BOTH breakers
|
||||||
sub.circuit_breaker_tripped_at = None
|
# (sys1 Trump + sys2 reversal). Previously only sys1 was cleared,
|
||||||
sub.circuit_breaker_reason = None
|
# leaving the reversal book locked even after the user re-armed.
|
||||||
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
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Auto-Trade %s for %s%s",
|
logger.info("Auto-Trade %s for %s%s",
|
||||||
"ON" if enabled else "OFF", wallet,
|
"ON" if enabled else "OFF", wallet,
|
||||||
|
|||||||
@@ -493,6 +493,33 @@ async def _execute_for_subscriber(
|
|||||||
result = await trader.open_position(asset, side, sized_position_usd)
|
result = await trader.open_position(asset, side, sized_position_usd)
|
||||||
entry_price = result.get('fill_price', 0.0)
|
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
|
# Resolve the FROZEN exit profile ONCE. Everything downstream
|
||||||
# (the watchdog AND recovery-after-restart) reads these — never
|
# (the watchdog AND recovery-after-restart) reads these — never
|
||||||
# the mutable Subscription/category config again.
|
# the mutable Subscription/category config again.
|
||||||
@@ -501,6 +528,9 @@ async def _execute_for_subscriber(
|
|||||||
_time.time() + sub["_min_hold_minutes"] * 60
|
_time.time() + sub["_min_hold_minutes"] * 60
|
||||||
if sub.get("_min_hold_minutes") else None
|
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(
|
eff = dict(
|
||||||
take_profit_pct = sub["take_profit_pct"],
|
take_profit_pct = sub["take_profit_pct"],
|
||||||
stop_loss_pct = sub["stop_loss_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,
|
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(
|
trade = BotTrade(
|
||||||
asset=asset,
|
asset=asset,
|
||||||
side=side,
|
side=side,
|
||||||
@@ -522,7 +559,7 @@ async def _execute_for_subscriber(
|
|||||||
hl_order_id=result.get('order_id'),
|
hl_order_id=result.get('order_id'),
|
||||||
size_usd=sized_position_usd,
|
size_usd=sized_position_usd,
|
||||||
base_size_usd=sized_position_usd, # immutable; pyramiding grows size_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"],
|
leverage=sub["leverage"],
|
||||||
# Freeze the exit profile on the row.
|
# Freeze the exit profile on the row.
|
||||||
eff_take_profit_pct = eff["take_profit_pct"],
|
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)
|
side, asset, wallet, entry_price, sized_position_usd, trade.id)
|
||||||
|
|
||||||
# Register with the watchdog using the FROZEN profile.
|
# 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
|
from app.services.tp_sl_monitor import register_trade
|
||||||
_derisk = None
|
_derisk = None
|
||||||
_addon = None
|
_addon = None
|
||||||
_peak_trail = None
|
_peak_trail = None
|
||||||
_sys2_mode = sub.get("_sys2_mode", "standard")
|
|
||||||
if sys2:
|
if sys2:
|
||||||
|
_mode_for_ladders = _sys2_mode or "standard"
|
||||||
from app.services.signal_categories import (
|
from app.services.signal_categories import (
|
||||||
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
|
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
|
||||||
)
|
)
|
||||||
_derisk = sys2_derisk_ladder(sub["leverage"], _sys2_mode)
|
_derisk = sys2_derisk_ladder(sub["leverage"], _mode_for_ladders)
|
||||||
_addon = sys2_addon_ladder(_sys2_mode)
|
_addon = sys2_addon_ladder(_mode_for_ladders)
|
||||||
_peak_trail = sys2_peak_trail(_sys2_mode)
|
_peak_trail = sys2_peak_trail(_mode_for_ladders)
|
||||||
register_trade(
|
register_trade(
|
||||||
trade_id=trade.id,
|
trade_id=trade.id,
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
|
|||||||
@@ -182,20 +182,37 @@ def is_tripped(sub_dict: dict, system: str = "sys1") -> tuple[bool, str]:
|
|||||||
# ─── Manual reset (called from /manual-window endpoint) ─────────────────────
|
# ─── Manual reset (called from /manual-window endpoint) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def clear_trip(wallet: str, db: AsyncSession) -> bool:
|
async def clear_trip(
|
||||||
"""Clear the trip state. Returns True if a trip was actually cleared.
|
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
|
`system` selects which breaker to clear:
|
||||||
human-in-the-loop unblock. Just letting LOCKOUT_HOURS expire works too,
|
None → clear BOTH sys1 and sys2 (default for human-ack endpoints)
|
||||||
but most users will hit the button.
|
"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(
|
sub = (await db.execute(
|
||||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if sub is None or sub.circuit_breaker_tripped_at is None:
|
if sub is None:
|
||||||
return False
|
return False
|
||||||
sub.circuit_breaker_tripped_at = None
|
|
||||||
sub.circuit_breaker_reason = None
|
targets = (system,) if system in ("sys1", "sys2") else ("sys1", "sys2")
|
||||||
await db.commit()
|
cleared_any = False
|
||||||
logger.info("CB cleared for %s", wallet)
|
for s in targets:
|
||||||
return True
|
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
|
||||||
|
|||||||
@@ -172,8 +172,13 @@ class HyperliquidTrader:
|
|||||||
coin = asset.upper()
|
coin = asset.upper()
|
||||||
is_buy = side.lower() == "long"
|
is_buy = side.lower() == "long"
|
||||||
|
|
||||||
# 1. Set leverage before opening
|
# 1. Set leverage before opening. Capture HL's clipped value — for
|
||||||
await self.set_leverage(coin)
|
# 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
|
# 2. Compute coin size from USD notional
|
||||||
mid = await self._mid_price(coin)
|
mid = await self._mid_price(coin)
|
||||||
@@ -232,9 +237,14 @@ class HyperliquidTrader:
|
|||||||
oid_src = filled_info or status.get("resting") or {}
|
oid_src = filled_info or status.get("resting") or {}
|
||||||
order_id = str(oid_src.get("oid", "")) if oid_src else ""
|
order_id = str(oid_src.get("oid", "")) if oid_src else ""
|
||||||
|
|
||||||
logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s)",
|
logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s, lev=%dx)",
|
||||||
side, coin, fill_price, total_sz, order_id)
|
side, coin, fill_price, total_sz, order_id, effective_leverage)
|
||||||
return {"order_id": order_id, "fill_price": fill_price, "size_coins": total_sz}
|
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:
|
async def close_position(self, asset: str) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ def format_post(post: Post) -> str:
|
|||||||
# Use the section that matches the source
|
# Use the section that matches the source
|
||||||
path = {
|
path = {
|
||||||
"truth": "/en/trump",
|
"truth": "/en/trump",
|
||||||
"btc_bottom_reversal": "/en/btc",
|
"btc_bottom_reversal": "/en/macro",
|
||||||
"funding_reversal": "/en/btc",
|
"funding_reversal": "/en/macro",
|
||||||
"kol_divergence": "/en/kol",
|
"kol_divergence": "/en/kol",
|
||||||
}.get(post.source, "/en")
|
}.get(post.source, "/en")
|
||||||
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
|
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
|
||||||
|
|||||||
Reference in New Issue
Block a user