""" Trading decision loop. Called by the Truth Social scraper after each new post is analyzed and saved. Iterates all active subscribers and executes trades on their behalf. """ import asyncio import logging from datetime import datetime, timezone from typing import Dict from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import AsyncSessionLocal from app.models import BotTrade, Post, Subscription from app.services.crypto import decrypt_api_key from app.services.hyperliquid import HyperliquidTrader from app.services.price_store import price_store # noqa: F401 (used elsewhere) logger = logging.getLogger(__name__) # Platform-wide thresholds (per-user values in Subscription override where applicable) # No global confidence floor — users pick their own 0–100 threshold via settings. MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users) # Hyperliquid perp fees (mainnet, base tier). # IOC orders always cross the book → taker fee both on open and close. # Ref: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees HL_TAKER_FEE_RATE = 0.00045 # 4.5 bps per side → 9 bps round-trip # Per-trade locks prevent TP/SL and max-hold tasks from both calling close_and_finalize # simultaneously on the same trade. Lock is process-local — with the atomic # conditional UPDATE below, multi-process is still safe (losers become no-ops). _close_locks: Dict[int, asyncio.Lock] = {} # Strong references to background close tasks. Python's GC can collect # unreferenced asyncio.Task objects before they finish — keeping them here # prevents silent task cancellation. Entries are removed when tasks complete. _background_tasks: set = set() # Per-wallet locks for the open-position critical section. # Prevents two concurrent signals from both passing the daily-budget check # before either trade is written to DB (TOCTOU race). _wallet_open_locks: Dict[str, asyncio.Lock] = {} def _wallet_lock(wallet: str) -> asyncio.Lock: lock = _wallet_open_locks.get(wallet) if lock is None: lock = asyncio.Lock() _wallet_open_locks[wallet] = lock return lock def _lock_for(trade_id: int) -> asyncio.Lock: lock = _close_locks.get(trade_id) if lock is None: lock = asyncio.Lock() _close_locks[trade_id] = lock return lock async def process_post(post: Post, db: AsyncSession) -> None: """ Entry point called by the scraper after a new post is saved. `db` is used only to fetch the subscriber list; each subscriber gets its own session. """ if not post.relevant: return if post.signal not in ('buy', 'short', 'sell'): logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal) return asset = post.price_impact_asset or 'BTC' # "sell" is treated as a short signal (bearish directional trade) side = 'long' if post.signal == 'buy' else 'short' logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence) result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712 subscribers = result.scalars().all() if not subscribers: logger.info("No active subscribers, skipping trade execution") return # Snapshot primitive fields so tasks don't share the ORM object / session. subs_snapshot = [ dict( wallet=s.wallet_address, hl_api_key=s.hl_api_key, leverage=s.leverage, position_size_usd=s.position_size_usd, take_profit_pct=s.take_profit_pct, stop_loss_pct=s.stop_loss_pct, min_confidence=s.min_confidence, daily_budget_usd=s.daily_budget_usd, active_from=s.active_from, active_until=s.active_until, ) for s in subscribers ] post_id = post.id post_confidence = post.ai_confidence or 0 tasks = [ _execute_for_subscriber(sub, post_id, post_confidence, asset, side) for sub in subs_snapshot ] await asyncio.gather(*tasks, return_exceptions=True) async def _execute_for_subscriber( sub: dict, post_id: int, post_confidence: int, asset: str, side: str, ) -> None: wallet = sub["wallet"] if not sub["hl_api_key"]: logger.warning("Subscriber %s has no HL API key, skipping", wallet) return # Required-setup guard: the bot is only allowed to trade when the user # has explicitly set take-profit, stop-loss, and a daily budget. Prevents # accidental trading on partially-configured accounts. missing = [k for k in ("take_profit_pct", "stop_loss_pct", "daily_budget_usd") if sub.get(k) is None] if missing: logger.info("Sub %s skipped post %d: setup incomplete (missing %s)", wallet, post_id, ", ".join(missing)) return if post_confidence < sub["min_confidence"]: logger.info("Sub %s filters out post %d: conf %d < user min %d", wallet, post_id, post_confidence, sub["min_confidence"]) return # Active-window check. Both values stored as naive-UTC. af = sub.get("active_from") au = sub.get("active_until") if af is not None and au is not None: now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None) if now_utc_naive < af or now_utc_naive >= au: logger.info("Sub %s skipped post %d: outside active window [%s, %s)", wallet, post_id, af, au) return try: api_key = decrypt_api_key(sub["hl_api_key"]) except Exception as exc: logger.error("Cannot decrypt key for %s: %s", wallet, exc) return # Per-wallet lock wraps BOTH the budget re-check and the open, so two # concurrent signals can't both pass the daily-budget gate before either # trade is written to DB (TOCTOU race under asyncio.gather). async with _wallet_lock(wallet): # Re-check daily budget INSIDE the lock so it's atomic with the open. daily_cap = sub.get("daily_budget_usd") if daily_cap is not None and daily_cap > 0: start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) async with AsyncSessionLocal() as budget_db: spent_result = await budget_db.execute( select(BotTrade).where( BotTrade.wallet_address == wallet, BotTrade.opened_at >= start_of_day, ) ) spent_trades = spent_result.scalars().all() spent = sum( (t.size_usd if t.size_usd is not None else sub["position_size_usd"]) for t in spent_trades ) if spent + sub["position_size_usd"] > daily_cap: logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f", wallet, spent, sub["position_size_usd"], daily_cap) return # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. async with AsyncSessionLocal() as db: try: trader = HyperliquidTrader( api_private_key=api_key, account_address=wallet, leverage=sub["leverage"], mainnet=settings.hl_mainnet, ) open_positions = await trader.get_open_positions() if any(p.get('coin') == asset for p in open_positions): logger.info("Subscriber %s already has open %s position, skipping", wallet, asset) return result = await trader.open_position(asset, side, sub["position_size_usd"]) entry_price = result.get('fill_price', 0.0) trade = BotTrade( asset=asset, side=side, entry_price=entry_price, wallet_address=wallet, trigger_post_id=post_id, opened_at=datetime.now(timezone.utc).replace(tzinfo=None), hl_order_id=result.get('order_id'), size_usd=sub["position_size_usd"], leverage=sub["leverage"], ) db.add(trade) await db.commit() await db.refresh(trade) logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", side, asset, wallet, entry_price, trade.id) from app.services.tp_sl_monitor import register_trade register_trade( trade_id=trade.id, wallet=wallet, api_key=api_key, leverage=sub["leverage"], asset=asset, side=side, entry_price=entry_price, take_profit_pct=sub["take_profit_pct"], stop_loss_pct=sub["stop_loss_pct"], ) task = asyncio.create_task(_close_after_hold( trade.id, api_key, sub["leverage"], asset, wallet )) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) except Exception as e: logger.error("Trade execution failed for %s: %s", wallet, e) async def close_and_finalize( trade_id: int, api_key: str, leverage: int, asset: str, wallet: str, reason: str = "max_hold", ) -> None: """ Close a live HL position and write exit_price/pnl to BotTrade. Race-safe: a conditional UPDATE `closed_at = now WHERE closed_at IS NULL` lets exactly one caller win; losers return silently. Also wraps in a per-trade asyncio.Lock to prevent us even *attempting* the HL API call twice. """ async with _lock_for(trade_id): async with AsyncSessionLocal() as db: try: now_naive = datetime.now(timezone.utc).replace(tzinfo=None) # Atomic claim: only one caller sets closed_at. claim = await db.execute( update(BotTrade) .where(BotTrade.id == trade_id) .where(BotTrade.closed_at.is_(None)) .values(closed_at=now_naive) ) await db.commit() if claim.rowcount == 0: return # someone else closed it # Reload the row we just claimed result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id)) trade = result.scalar_one() # Prefer the historical snapshot stamped on the trade row; # fall back to current Subscription or caller's leverage for # legacy rows. if trade.size_usd is not None: size_usd = trade.size_usd else: sub_res = await db.execute( select(Subscription).where(Subscription.wallet_address == wallet) ) sub = sub_res.scalar_one_or_none() size_usd = sub.position_size_usd if sub else 20.0 trade_leverage = trade.leverage if trade.leverage is not None else leverage trader = HyperliquidTrader( api_private_key=api_key, account_address=wallet, leverage=trade_leverage, mainnet=settings.hl_mainnet, ) close_result = await trader.close_position(asset) # Detect "position was already closed externally" before we even tried if close_result.get("already_closed"): logger.warning( "Trade %d: no open %s position found on HL — " "user likely closed it manually. Marking trade closed with no PnL.", trade_id, asset, ) trade.exit_price = None trade.pnl_usd = None trade.hold_seconds = int( (datetime.now(timezone.utc) - trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds() ) await db.commit() from app.services.tp_sl_monitor import unregister unregister(trade_id) _close_locks.pop(trade_id, None) return exit_price = close_result.get("fill_price") if not exit_price: raise ValueError(f"close_position returned no fill_price for trade {trade_id}") now_aware = datetime.now(timezone.utc) opened_aware = trade.opened_at.replace(tzinfo=timezone.utc) hold_secs = int((now_aware - opened_aware).total_seconds()) pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0 signed_pct = pct if trade.side == 'long' else -pct # size_usd is the NOTIONAL value — leverage affects margin, not PnL. gross_pnl = size_usd * signed_pct # Deduct round-trip taker fees (IOC crosses book on both open + close). # Without this, displayed PnL overstates real returns by ~9 bps per trade. fees_usd = size_usd * HL_TAKER_FEE_RATE * 2 pnl_usd = gross_pnl - fees_usd trade.exit_price = exit_price trade.pnl_usd = round(pnl_usd, 2) trade.hold_seconds = hold_secs # closed_at already set by the atomic UPDATE await db.commit() # Clean up TP/SL + lock to avoid leaking memory from app.services.tp_sl_monitor import unregister unregister(trade_id) _close_locks.pop(trade_id, None) logger.info( "Closed %s %s for %s @ %.2f gross=%.2f fees=%.2f net=%.2f (reason=%s)", trade.side, asset, wallet, exit_price, gross_pnl, fees_usd, pnl_usd, reason, ) except Exception as e: logger.error("Failed to close trade %d: %s", trade_id, e) # Rollback closed_at so recovery can retry this trade on next restart. # Without this, the trade is "closed" in DB but position still open on HL. try: async with AsyncSessionLocal() as rb_db: await rb_db.execute( update(BotTrade) .where(BotTrade.id == trade_id) .values(closed_at=None) ) await rb_db.commit() logger.info("Rolled back closed_at for trade %d — will retry on recovery", trade_id) except Exception as rb_exc: logger.error("Failed to rollback closed_at for trade %d: %s", trade_id, rb_exc) finally: _close_locks.pop(trade_id, None) async def _close_after_hold( trade_id: int, api_key: str, leverage: int, asset: str, wallet: str ) -> None: await asyncio.sleep(MAX_HOLD_SECONDS) await close_and_finalize(trade_id, api_key, leverage, asset, wallet, reason="max_hold")