""" Startup rehydration: re-register TP/SL + max-hold for every open trade in DB. Called once from lifespan() after tables are ensured. Without this, any deploy or crash silently drops TP/SL protection on live positions. """ import asyncio import logging from datetime import datetime, timezone from sqlalchemy import select from app.database import AsyncSessionLocal from app.models import BotTrade, Post, Subscription logger = logging.getLogger(__name__) async def rehydrate_open_trades() -> None: # Imported locally to avoid circular imports at module load from app.services.bot_engine import close_and_finalize, _time_stop_check, _background_tasks from app.services.crypto import decrypt_api_key from app.services.signal_categories import ( get_stop_ladder as _get_stop_ladder, sys2_derisk_ladder as _sys2_derisk_ladder, sys2_addon_ladder as _sys2_addon_ladder, sys2_peak_trail as _sys2_peak_trail, get_exit_profile as _get_exit_profile, ) from app.services.tp_sl_monitor import register_trade async with AsyncSessionLocal() as db: # Skip released trades: the user has taken back manual control of # the HL position; re-registering would re-attach the watchdog and # silently start managing again after a restart. result = await db.execute( select(BotTrade).where( BotTrade.closed_at.is_(None), BotTrade.released_at.is_(None), ) ) open_trades = result.scalars().all() if not open_trades: logger.info("Rehydration: no open trades.") return now = datetime.now(timezone.utc) for t in open_trades: sub_res = await db.execute( select(Subscription).where(Subscription.wallet_address == t.wallet_address) ) sub = sub_res.scalar_one_or_none() if sub is None or not sub.hl_api_key: logger.warning( "Open trade %d has no subscription/key — marking as closed(abandoned)", t.id ) t.closed_at = now.replace(tzinfo=None) continue try: api_key = decrypt_api_key(sub.hl_api_key) except Exception as exc: logger.error("Cannot decrypt key for trade %d: %s", t.id, exc) continue # Use the leverage snapshot from the trade row (stamped at open time). # Fall back to current Subscription only for legacy rows (pre-migration 005). trade_leverage = t.leverage if t.leverage is not None else sub.leverage # Re-register from the trade's FROZEN exit profile (eff_* columns), # NOT the live Subscription. The trade may be a 90-day System-2 # reversal; using sub.* would rehydrate it with the user's Trump # stop (1.5%) and 7-day max-hold — silently rewriting its risk on # every restart. eff_* is stamped at open and never changes. # # Legacy rows (opened before this migration) have NULL eff_* — # fall back to the Subscription so they still get *some* watcher. eff_sl = t.eff_stop_loss_pct if t.eff_stop_loss_pct is not None else sub.stop_loss_pct eff_tp = t.eff_take_profit_pct # may legitimately be None (sys2 pure-trail) eff_tr = t.eff_trailing_stop_pct if t.eff_trailing_stop_pct is not None else sub.trailing_stop_pct eff_tra = t.eff_trailing_activate_pct if t.eff_trailing_activate_pct is not None else sub.trailing_activate_at_pct eff_mh = t.eff_max_hold_hours if t.eff_max_hold_hours is not None else (sub.max_hold_hours or 168) # NOTE: peak_gain_pct is now RESTORED from the row (throttled # persistence) so a pyramided / in-profit System-2 trade keeps its # regime across restarts. min_hold still resets on rehydrate, but # it only matters in the first 30 min of a Trump trade; a restart # inside that window is rare and the downside (TP can fire early) # is bounded. post_res = await db.execute( select(Post).where(Post.id == t.trigger_post_id) ) trigger_post = post_res.scalar_one_or_none() # System-2 detection. Two paths reach here: # (a) Auto-opened (legacy): trigger_post.category is set # to e.g. "btc_bottom_reversal_long" → look up via # the post's category. # (b) Adopted (current): trigger_post_id is NULL because # the user opened on HL manually. The trade IS a # System-2 trade — sys2_mode is non-NULL on the row — # but it has no source post to read category from. # Fall back to the adopted-category constant so the # ladder still rebuilds correctly on restart. # # Bug this guards: before this fix, adopted trades restarted # with stop_ladder=None / derisk=None / addon=None / # peak_trail=None — the entire System-2 ladder logic was # silently lost across any backend restart, downgrading the # trade to a plain stop_loss + max_hold position. _cat_for_ladders = ( (trigger_post.category if trigger_post else None) or ( "btc_bottom_reversal_long" if t.sys2_mode is not None else None ) ) _stop_ladder = _get_stop_ladder(_cat_for_ladders) register_trade( trade_id=t.id, wallet=t.wallet_address, api_key=api_key, leverage=trade_leverage, asset=t.asset, side=t.side, entry_price=t.entry_price, take_profit_pct=eff_tp, stop_loss_pct=eff_sl, trailing_stop_pct=eff_tr, trailing_activate_at_pct=eff_tra, invalidation=t.eff_invalidation, invalidation_price=( t.eff_invalidation_price if t.eff_invalidation_price is not None else (trigger_post.invalidation_price if trigger_post else None) ), min_hold_until_ts=t.eff_min_hold_until_ts, stop_ladder=_stop_ladder, derisk_ladder=( _sys2_derisk_ladder(t.leverage or 1, t.sys2_mode) if _stop_ladder else None ), derisk_done=(t.derisk_steps_done or 0), addon_ladder=( _sys2_addon_ladder(t.sys2_mode) if _stop_ladder else None ), addon_done=(t.addon_steps_done or 0), initial_peak=(t.peak_gain_pct or 0.0), peak_trail=( _sys2_peak_trail(t.sys2_mode) if _stop_ladder else None ), grow_mode=bool(getattr(t, "grow_mode", False)), ) # Remaining hold from the trade's FROZEN max_hold (e.g. 2160h for # an sma_reclaim), not the user's Trump setting. opened_aware = t.opened_at.replace(tzinfo=timezone.utc) elapsed = (now - opened_aware).total_seconds() max_hold_seconds = int(eff_mh) * 3600 remaining = max_hold_seconds - elapsed from app.services.bot_engine import _background_tasks if remaining <= 0: logger.info("Trade %d past max-hold on startup — closing now", t.id) task = asyncio.create_task( close_and_finalize( trade_id=t.id, api_key=api_key, leverage=trade_leverage, asset=t.asset, wallet=t.wallet_address, reason="max_hold_recovery", ) ) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) else: async def _delayed_close(trade_id=t.id, key=api_key, lev=trade_leverage, asset=t.asset, wallet=t.wallet_address, delay=remaining): await asyncio.sleep(delay) await close_and_finalize( trade_id=trade_id, api_key=key, leverage=lev, asset=asset, wallet=wallet, reason="max_hold", ) task = asyncio.create_task(_delayed_close()) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) # ── System-2 time-stop rehydration ────────────────────────────── # _time_stop_check is a background task created at open time that # closes the trade if it's still flat (|unrealised| < 2%) after # `time_stop_hours`. It is NOT stored in DB — it must be rebuilt # here on every restart or open sys2 trades silently lose this guard. # btc_bottom_reversal_long has time_stop_hours=None (no time stop). if _stop_ladder and t.sys2_mode: _exit_profile = _get_exit_profile(_cat_for_ladders) ts_hours = _exit_profile.time_stop_hours if ts_hours: ts_elapsed_h = elapsed / 3600 ts_remaining_s = max(0.0, ts_hours - ts_elapsed_h) * 3600 if ts_remaining_s > 0: ts_task = asyncio.create_task(_time_stop_check( trade_id=t.id, api_key=api_key, leverage=trade_leverage, asset=t.asset, wallet=t.wallet_address, delay_seconds=int(ts_remaining_s), )) _background_tasks.add(ts_task) ts_task.add_done_callback(_background_tasks.discard) logger.info( "Rehydrated time-stop for trade %d: %.1fh remaining", t.id, ts_remaining_s / 3600, ) else: # Time-stop window already elapsed while backend was down. # Fire it now — close_and_finalize is idempotent (WHERE # closed_at IS NULL) so a trade that already closed via # another path is a safe no-op. logger.info( "Trade %d time-stop elapsed during downtime — checking now", t.id, ) ts_task = asyncio.create_task(_time_stop_check( trade_id=t.id, api_key=api_key, leverage=trade_leverage, asset=t.asset, wallet=t.wallet_address, delay_seconds=0, )) _background_tasks.add(ts_task) ts_task.add_done_callback(_background_tasks.discard) await db.commit() logger.info("Rehydrated %d open trades.", len(open_trades))