"""Adopt / release flow for System-2 manage-only positions. The user opens a position MANUALLY on Hyperliquid (any size / leverage). They then ask the bot to manage it — either via the dashboard adoption button or the Telegram /adopt command. From that moment on, tp_sl_monitor runs the full System-2 lifecycle against it: 5-rung stop ladder, downside de-risk, pyramid, peak-trail, full close. `release` is the escape hatch. It marks released_at on the BotTrade row, unregisters the watchdog, and leaves the HL position untouched — the user takes back manual control without closing. The flow is intentionally LIGHT: - We don't validate signal timing (any HL position can be adopted) - We don't enforce direction (both long and short work; ladder is signed) - We DO enforce the same concurrency cap as the old auto-open path (SYS2_MAX_CONCURRENT) — adopting unlimited correlated crypto-beta is the same disaster regardless of who pulled the trigger - We DO block paper-mode subscribers (paper has no HL state to read) Race notes: - Adopt creates the BotTrade row, then register_trade. If a price tick arrives between those two events, no harm — register_trade is idempotent on trade_id. - Two concurrent adopts on the same wallet+asset are guarded by the "already adopted?" check inside the wallet-keyed asyncio lock reused from bot_engine. """ from __future__ import annotations import asyncio import logging from collections import OrderedDict from dataclasses import dataclass from datetime import datetime, timezone from typing import Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database import AsyncSessionLocal from app.models import BotTrade, Subscription from app.config import settings from app.services.crypto import decrypt_api_key from app.services.hyperliquid import HyperliquidTrader from app.services.signal_categories import ( get_exit_profile, get_stop_ladder, sys2_normalize_mode, sys2_protective_stop_pct, sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail, SYS2_MAX_CONCURRENT, SYS2_MODES, ) logger = logging.getLogger(__name__) # Default category — the System-2 strategy is BTC bottom-reversal long. # An adopted short or non-BTC asset still gets the same generous ladder # (it's direction-agnostic), but we tag the category for telemetry. ADOPTED_CATEGORY = "btc_bottom_reversal_long" # Per-wallet asyncio lock to serialise adopt_position calls for the same # wallet. Without this, two concurrent /adopt requests on the same wallet # can both pass the duplicate-check and end up writing two BotTrade rows # pointing at the same HL position — corrupting the ladder / de-risk math # (each row would race to reduce the position). Process-local; with the # atomic duplicate check inside the lock, multi-process deploys are still # safe (losers would just see "already_adopted" on the second attempt). # # Capacity-capped at _ADOPT_LOCK_MAX (matches _WALLET_LOCK_MAX in bot_engine) # to prevent unbounded growth if many unique wallets pass through. LRU # eviction: evicting an in-flight lock is safe — the holder already holds a # reference; new adopt for that wallet just gets a fresh lock. _ADOPT_LOCK_MAX = 512 _adopt_locks: OrderedDict[str, asyncio.Lock] = OrderedDict() def _wallet_adopt_lock(wallet: str) -> asyncio.Lock: lock = _adopt_locks.get(wallet) if lock is None: if len(_adopt_locks) >= _ADOPT_LOCK_MAX: # Evict LRU entry. The evicted lock object stays alive as long as # any coroutine holds a reference to it — eviction only means it # won't be reused, not that it's freed mid-acquire. _adopt_locks.popitem(last=False) lock = asyncio.Lock() _adopt_locks[wallet] = lock else: # Promote to MRU position. _adopt_locks.move_to_end(wallet) return lock # ── Errors ────────────────────────────────────────────────────────────────── class AdoptionError(Exception): """User-facing adoption failure. .code is a stable short token suitable for switching on in the API / Telegram layers; .message is the human-readable text.""" def __init__(self, code: str, message: str): super().__init__(message) self.code = code self.message = message # ── Reads ─────────────────────────────────────────────────────────────────── @dataclass class HLPositionView: """Lightweight projection of a Hyperliquid open position, for adoption pickers. Kept thin so the UI / Telegram layers don't depend on the HL SDK's dict shape.""" asset: str side: str # "long" | "short" size_coins: float # absolute (always positive) entry_price: float leverage: int size_usd: float # size_coins * entry_price already_adopted: bool # there's a BotTrade open + not released for it async def list_hl_positions(wallet: str) -> list[HLPositionView]: """Read the wallet's current HL open positions, annotated with whether each is already under bot management. Raises AdoptionError on missing subscription / missing key / HL read failure.""" wallet_l = wallet.lower().strip() async with AsyncSessionLocal() as db: sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == wallet_l) )).scalar_one_or_none() if sub is None: raise AdoptionError("no_subscription", "Wallet not subscribed. Open Settings on the dashboard first.") if not sub.hl_api_key: raise AdoptionError("no_hl_key", "This wallet has no Hyperliquid API key on file. " "Add one in Settings before adopting positions.") # Adopted positions only — closed/released rows don't block re-adopt. existing = (await db.execute( select(BotTrade.asset).where( BotTrade.wallet_address == wallet_l, BotTrade.closed_at.is_(None), BotTrade.released_at.is_(None), ) )).scalars().all() adopted_assets = {a.upper() for a in existing} try: api_key = decrypt_api_key(sub.hl_api_key) except Exception as exc: raise AdoptionError("key_decrypt_failed", f"Could not decrypt the HL API key: {exc}") trader = HyperliquidTrader( api_private_key=api_key, account_address=wallet_l, leverage=int(sub.leverage or 2), mainnet=settings.hl_mainnet, ) try: raw = await trader.get_open_positions() except Exception as exc: raise AdoptionError("hl_read_failed", f"Could not read HL positions: {exc}") out: list[HLPositionView] = [] for p in raw: coin = (p.get("coin") or "").upper() szi = float(p.get("szi") or 0.0) entry = float(p.get("entry_px") or 0.0) lev_info = p.get("leverage") or {} # HL position.leverage shape is {"type": "isolated"|"cross", "value": N} lev = int(lev_info.get("value") or sub.leverage or 2) if szi == 0 or entry <= 0: continue out.append(HLPositionView( asset = coin, side = "long" if szi > 0 else "short", size_coins = abs(szi), entry_price = entry, leverage = lev, size_usd = round(abs(szi) * entry, 2), already_adopted = coin in adopted_assets, )) return out # ── Adopt ─────────────────────────────────────────────────────────────────── @dataclass class AdoptionResult: trade_id: int asset: str side: str entry_price: float size_usd: float leverage: int mode: str protective_stop: float # positive % distance derisk_ladder: list # the rungs we just registered stop_ladder: list # the upside ratchet rungs async def adopt_position(wallet: str, asset: str, mode: str) -> AdoptionResult: """Read the user's current HL position for `asset`, create a BotTrade row with the System-2 ladder for `mode`, and register it with the watchdog. Raises AdoptionError on any failure path. Wallet-level locking serialises concurrent adopts for the same wallet so the duplicate / concurrency-cap checks below are race-free. """ wallet_l = wallet.lower().strip() asset_u = asset.upper().strip() mode_n = sys2_normalize_mode(mode) if mode_n not in SYS2_MODES: raise AdoptionError("bad_mode", f"mode must be one of {SYS2_MODES}; got {mode!r}") async with _wallet_adopt_lock(wallet_l): return await _adopt_locked(wallet_l, asset_u, mode_n) async def _adopt_locked(wallet_l: str, asset_u: str, mode_n: str) -> AdoptionResult: async with AsyncSessionLocal() as db: sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == wallet_l) )).scalar_one_or_none() if sub is None: raise AdoptionError("no_subscription", "Wallet not subscribed. Open Settings on the dashboard first.") if not sub.hl_api_key: raise AdoptionError("no_hl_key", "This wallet has no Hyperliquid API key on file.") # Paper-mode wallets have no real HL position to manage. The HL read # below would return empty and surface a confusing "not_on_hl" error # — better to reject explicitly with a clear cause up front. if getattr(sub, "paper_mode", False): raise AdoptionError("paper_mode", "Adopt isn't available in paper mode — paper trades have " "no Hyperliquid position to manage. Turn off paper mode in " "Settings to use /adopt.") # System-2 circuit breaker. Same gate the auto-open path used to run: # if recent losses tripped the sys2 breaker, block new adoptions for # the lockout window. Otherwise the breaker would be useless under # the manage-only model. from app.services.circuit_breaker import is_tripped as _cb_tripped sub_snapshot = { "sys2_cb_tripped_at": sub.sys2_cb_tripped_at, "sys2_cb_reason": sub.sys2_cb_reason, } tripped, cb_msg = _cb_tripped(sub_snapshot, "sys2") if tripped: raise AdoptionError("circuit_breaker", f"Adopt blocked: {cb_msg}. Turning Auto-Trade ON or arming " "Manual Window in Settings clears the breaker.") # Already managing this wallet+asset? dup = (await db.execute( select(BotTrade).where( BotTrade.wallet_address == wallet_l, BotTrade.asset == asset_u, BotTrade.closed_at.is_(None), BotTrade.released_at.is_(None), ) )).scalar_one_or_none() if dup is not None: raise AdoptionError("already_adopted", f"{asset_u} is already managed by the bot (trade #{dup.id}). " "Send /release first if you want to re-adopt.") # Wallet-level concurrency cap. Same reasoning as the auto-open # path: every System-2 asset is correlated crypto-beta and 3 # simultaneous positions ≈ 1 leveraged macro bet, not 3 bets. open_count = (await db.execute( select(BotTrade).where( BotTrade.wallet_address == wallet_l, BotTrade.closed_at.is_(None), BotTrade.released_at.is_(None), ) )).scalars().all() if len(open_count) >= SYS2_MAX_CONCURRENT: raise AdoptionError("concurrency_cap", f"You already have {len(open_count)} managed positions " f"(cap is {SYS2_MAX_CONCURRENT}). /release one first.") try: api_key = decrypt_api_key(sub.hl_api_key) except Exception as exc: raise AdoptionError("key_decrypt_failed", f"Could not decrypt the HL API key: {exc}") trader = HyperliquidTrader( api_private_key=api_key, account_address=wallet_l, leverage=int(sub.leverage or 2), mainnet=settings.hl_mainnet, ) try: raw = await trader.get_open_positions() except Exception as exc: raise AdoptionError("hl_read_failed", f"Could not read HL positions: {exc}") target = next((p for p in raw if (p.get("coin") or "").upper() == asset_u), None) if target is None: raise AdoptionError("not_on_hl", f"No open {asset_u} position found on Hyperliquid. " "Make sure you've filled the order, then try again.") szi = float(target.get("szi") or 0.0) entry = float(target.get("entry_px") or 0.0) if szi == 0 or entry <= 0: raise AdoptionError("bad_hl_state", f"HL returned a zero-size or zero-price {asset_u} position. " "Refresh and try again in a moment.") side = "long" if szi > 0 else "short" size_coins = abs(szi) size_usd = round(size_coins * entry, 2) lev_info = target.get("leverage") or {} leverage = int(lev_info.get("value") or sub.leverage or 2) # BUG-09 guard: sys2_protective_stop_pct clamps leverage to SYS2_MAX_LEVERAGE # when computing the stop distance. If the actual position is above that cap # the computed stop is WIDER than the real liquidation band — the user could # get HL-liquidated before our stop fires. Reject up front with a clear # error so the user knows to reduce leverage on HL before adopting. if leverage > SYS2_MAX_LEVERAGE: raise AdoptionError( "leverage_too_high", f"{asset_u} position is at {leverage}× leverage. " f"The bot's System-2 strategy supports up to {SYS2_MAX_LEVERAGE}×. " f"Reduce leverage on Hyperliquid to ≤{SYS2_MAX_LEVERAGE}× first, " "then try /adopt again.", ) # Build the System-2 exit profile against the ACTUAL HL leverage so the # protective stop is always inside the real liquidation line. Identical # math to bot_engine's old sys2 open path; reused so behaviour stays # consistent across the two entry points. exit_profile = get_exit_profile(ADOPTED_CATEGORY) prot_stop = sys2_protective_stop_pct(leverage) derisk = sys2_derisk_ladder(leverage, mode_n) addon = sys2_addon_ladder(mode_n) peak_trail = sys2_peak_trail(mode_n) stop_ladder = get_stop_ladder(ADOPTED_CATEGORY) now_naive = datetime.now(timezone.utc).replace(tzinfo=None) async with AsyncSessionLocal() as db: trade = BotTrade( asset=asset_u, side=side, entry_price=entry, wallet_address=wallet_l, trigger_post_id=None, # adopted, not signal-triggered opened_at=now_naive, hl_order_id=f"adopted:{int(now_naive.timestamp())}", size_usd=size_usd, base_size_usd=size_usd, sys2_mode=mode_n, leverage=leverage, # Freeze the System-2 exit profile on the row. No TP / no # trailing-from-peak — pure staged stop + de-risk + peak-trail. eff_take_profit_pct = None, eff_stop_loss_pct = prot_stop, eff_trailing_stop_pct = None, eff_trailing_activate_pct = None, eff_max_hold_hours = exit_profile.max_hold_hours, eff_invalidation = None, eff_invalidation_price = None, eff_min_hold_until_ts = None, ) db.add(trade) await db.commit() await db.refresh(trade) trade_id = trade.id # Register with the watchdog AFTER the row is committed so a tick fired # in the next millisecond can find the trade on disk if it needs to. from app.services.tp_sl_monitor import register_trade register_trade( trade_id=trade_id, wallet=wallet_l, api_key=api_key, leverage=leverage, asset=asset_u, side=side, entry_price=entry, take_profit_pct=None, stop_loss_pct=prot_stop, trailing_stop_pct=None, trailing_activate_at_pct=None, invalidation=None, invalidation_price=None, min_hold_until_ts=None, stop_ladder=stop_ladder, derisk_ladder=derisk, derisk_done=0, addon_ladder=addon, addon_done=0, peak_trail=peak_trail, grow_mode=False, # user opts a position into Grow explicitly ) logger.warning( "ADOPTED trade %d: wallet=%s asset=%s side=%s entry=%.4f size=%.2f " "lev=%dx mode=%s prot_stop=%.2f%% derisk=%s", trade_id, wallet_l, asset_u, side, entry, size_usd, leverage, mode_n, prot_stop, derisk, ) return AdoptionResult( trade_id=trade_id, asset=asset_u, side=side, entry_price=entry, size_usd=size_usd, leverage=leverage, mode=mode_n, protective_stop=prot_stop, derisk_ladder=derisk, stop_ladder=stop_ladder or [], ) # ── Release ───────────────────────────────────────────────────────────────── async def release_management(wallet: str, trade_id: int) -> dict: """Stop managing trade_id without closing the HL position. Marks released_at, unregisters the watchdog. Idempotent — re-releasing a released trade is a no-op.""" wallet_l = wallet.lower().strip() async with AsyncSessionLocal() as db: trade = (await db.execute( select(BotTrade).where(BotTrade.id == trade_id) )).scalar_one_or_none() if trade is None: raise AdoptionError("not_found", f"trade {trade_id} not found") if trade.wallet_address.lower() != wallet_l: raise AdoptionError("not_owner", f"trade {trade_id} belongs to a different wallet") if trade.closed_at is not None: raise AdoptionError("already_closed", f"trade {trade_id} is already closed — nothing to release") if trade.released_at is not None: # Idempotent — return success. return {"status": "ok", "trade_id": trade_id, "asset": trade.asset, "already_released": True} now_naive = datetime.now(timezone.utc).replace(tzinfo=None) await db.execute( update(BotTrade) .where(BotTrade.id == trade_id) .values(released_at=now_naive) ) await db.commit() from app.services.tp_sl_monitor import unregister unregister(trade_id) logger.warning( "RELEASED trade %d: wallet=%s asset=%s — bot stops managing, " "HL position untouched.", trade_id, wallet_l, trade.asset, ) return {"status": "ok", "trade_id": trade_id, "asset": trade.asset, "already_released": False}