""" 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, 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 MAX_HOLD_SECONDS, close_and_finalize from app.services.crypto import decrypt_api_key from app.services.tp_sl_monitor import register_trade async with AsyncSessionLocal() as db: result = await db.execute(select(BotTrade).where(BotTrade.closed_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 TP/SL watcher 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=sub.take_profit_pct, stop_loss_pct=sub.stop_loss_pct, ) # Compute remaining hold; if already exceeded, close immediately opened_aware = t.opened_at.replace(tzinfo=timezone.utc) elapsed = (now - opened_aware).total_seconds() 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) await db.commit() logger.info("Rehydrated %d open trades.", len(open_trades))