""" position_monitor.py — 15-minute job that re-evaluates all open option positions and fires alerts when signals change materially. Also refreshes recommendations for all stock positions. """ import logging from datetime import datetime, date from typing import Optional from sqlalchemy.orm import Session from app.database import SessionLocal from app.models.db_models import OptionPosition, StockPosition, Recommendation, Alert, Device from app.services import market_data as md from app.services.signal_engine import ( compute_iv_rank, compute_smas, compute_swing_levels, compute_trend, compute_signal_strength, compute_signal_hash, ) from app.services.recommendation_engine import build_recommendation from app.services.apns_service import send_push from app.models.schemas import SignalSnapshot logger = logging.getLogger(__name__) # Tracks the last time this job ran last_run: Optional[datetime] = None def _determine_alert_type( position: OptionPosition, current_delta: float, new_signal_hash: str, new_rec: Optional[Recommendation], earnings_warning: bool, ) -> Optional[str]: """ Determine if and what type of alert to fire. Returns alert_type string or None. """ # Earnings warning newly triggered if earnings_warning and not _position_had_earnings_warning(position): return "earnings_warning" # Deep ITM — delta threshold abs_delta = abs(current_delta) if abs_delta >= 0.45: return "close_early" # Profit capture — if premium has decayed significantly # (We don't track current price here, but high delta ITM is a proxy) if abs_delta >= 0.40: return "close_early" # Expiry-based recommendation changed if new_rec: days_to_expiry = (position.expiration - date.today()).days if days_to_expiry <= 5 and abs_delta <= 0.10: return "close_early" # expiring nearly worthless — take it off # Roll suggestion: new recommendation is for a further-out expiry if new_rec.recommended_expiration > position.expiration: return "roll_out" # Strike meaningfully different (more than 2 strikes, roughly $2-5 depending on underlying) if abs(new_rec.recommended_strike - position.strike) / position.strike > 0.02: return "roll_up_down" return None def _position_had_earnings_warning(position: OptionPosition) -> bool: """Best-effort: check if earnings warning was already flagged on last hash.""" # We encode earnings_warning in the hash payload so if it was True before # the hash would already reflect it. This is a simple flag check. return False # Simplified — the hash change will trigger the alert naturally async def monitor_all_positions(): """Main scheduler job. Runs every 15 minutes.""" global last_run logger.info("Position monitor: starting run") db: Session = SessionLocal() try: # 1. Get all open positions grouped by ticker to batch data fetching open_positions: list[OptionPosition] = ( db.query(OptionPosition) .filter(OptionPosition.status == "open") .all() ) tickers_to_check = list({p.ticker for p in open_positions}) # Also collect all stock positions to refresh recommendations stock_positions: list[StockPosition] = db.query(StockPosition).all() stock_tickers = list({sp.ticker for sp in stock_positions}) all_tickers = list(set(tickers_to_check + stock_tickers)) if not all_tickers: logger.info("Position monitor: no tickers to check") last_run = datetime.utcnow() return # 2. Pre-fetch market data for all tickers (cached by market_data module) logger.info(f"Position monitor: checking {len(all_tickers)} tickers") signal_snapshots: dict[str, Optional[SignalSnapshot]] = {} for ticker in all_tickers: snap = _compute_snapshot(ticker) signal_snapshots[ticker] = snap # 3. Evaluate each open option position for position in open_positions: snap = signal_snapshots.get(position.ticker) if snap is None: continue await _evaluate_position(db, position, snap) # 4. Refresh recommendations for all stock positions for stock_pos in stock_positions: snap = signal_snapshots.get(stock_pos.ticker) if snap is None: continue _refresh_recommendations(db, stock_pos.device_id, stock_pos.ticker, snap) db.commit() last_run = datetime.utcnow() logger.info("Position monitor: run complete") except Exception as e: logger.error(f"Position monitor error: {e}", exc_info=True) db.rollback() finally: db.close() def _compute_snapshot(ticker: str) -> Optional[SignalSnapshot]: """Build signal snapshot from market data.""" import math df = md.get_price_history(ticker) if df is None: return None current_price = md.get_current_price(ticker) if current_price is None: return None iv_rank = compute_iv_rank(df) smas = compute_smas(df) swing = compute_swing_levels(df) trend = compute_trend(current_price, smas["sma_50"], smas["sma_200"]) earnings_date = md.get_earnings_date(ticker) return SignalSnapshot( ticker=ticker, current_price=current_price, iv_rank=iv_rank, sma_50=smas["sma_50"] if not math.isnan(smas["sma_50"]) else 0.0, sma_200=smas["sma_200"] if not math.isnan(smas["sma_200"]) else 0.0, nearest_support=swing["nearest_support"], nearest_resistance=swing["nearest_resistance"], trend=trend, earnings_date=earnings_date, computed_at=datetime.utcnow(), ) async def _evaluate_position(db: Session, position: OptionPosition, snap: SignalSnapshot): """Re-evaluate one open position and fire an alert if signal changed.""" # Get current option data for this specific strike/expiry expiry_str = str(position.expiration) chain = md.get_options_chain(position.ticker, expiry_str) current_delta = 0.25 # fallback if chain: chain_df = chain["calls"] if position.strategy == "covered_call" else chain["puts"] row = chain_df[chain_df["strike"] == position.strike] if not row.empty and "delta" in row.columns: delta_val = row["delta"].iloc[0] if delta_val == delta_val: # not NaN current_delta = abs(float(delta_val)) # Build a fresh new recommendation to compare expiry/strike new_rec = build_recommendation( device_id=position.device_id, ticker=position.ticker, strategy=position.strategy, time_horizon="weekly", # use weekly for monitoring comparisons snapshot=snap, ) earnings_warning = bool(snap.earnings_date and snap.earnings_date <= position.expiration) new_hash = compute_signal_hash( iv_rank=snap.iv_rank, sma_50=snap.sma_50, sma_200=snap.sma_200, nearest_support=snap.nearest_support, nearest_resistance=snap.nearest_resistance, recommended_strike=new_rec.recommended_strike if new_rec else position.strike, recommended_expiration=new_rec.recommended_expiration if new_rec else position.expiration, earnings_warning=earnings_warning, ) # No change — skip if new_hash == position.last_signal_hash: return # Determine alert type alert_type = _determine_alert_type(position, current_delta, new_hash, new_rec, earnings_warning) if alert_type is None: # Still update the hash even if no actionable alert position.last_signal_hash = new_hash return # Build alert message message = _build_alert_message(position, alert_type, current_delta, snap, new_rec) # Save alert to DB alert = Alert( device_id=position.device_id, option_position_id=position.id, ticker=position.ticker, alert_type=alert_type, message=message, old_signal_hash=position.last_signal_hash, new_signal_hash=new_hash, sent_at=datetime.utcnow(), acknowledged=False, ) db.add(alert) # Update position hash position.last_signal_hash = new_hash # Send push notification device: Optional[Device] = db.query(Device).filter(Device.id == position.device_id).first() if device: strategy_label = "Covered Call" if position.strategy == "covered_call" else "Cash-Secured Put" await send_push( apns_token=device.apns_token, title=f"{position.ticker} {strategy_label} — Action Needed", body=message, payload={ "alert_type": alert_type, "ticker": position.ticker, "position_id": position.id, }, ) logger.info(f"Alert fired: {position.ticker} {alert_type} — {message[:60]}") def _build_alert_message( position: OptionPosition, alert_type: str, current_delta: float, snap: SignalSnapshot, new_rec: Optional[Recommendation], ) -> str: strike = position.strike expiry = position.expiration if alert_type == "close_early": if current_delta >= 0.45: return ( f"Delta has risen to {current_delta:.2f} — position is deep ITM. " f"Consider closing early to limit risk on the ${strike:.0f} strike expiring {expiry}." ) return ( f"Signals suggest closing early on ${strike:.0f} strike expiring {expiry}. " f"Capturing premium now may be optimal." ) if alert_type == "roll_out" and new_rec: return ( f"Consider rolling your ${strike:.0f} strike out to {new_rec.recommended_expiration} " f"at ${new_rec.recommended_strike:.0f} for ${new_rec.estimated_premium:.2f} credit." ) if alert_type == "roll_up_down" and new_rec: direction = "up" if new_rec.recommended_strike > strike else "down" return ( f"Signals favor rolling {direction} from ${strike:.0f} to ${new_rec.recommended_strike:.0f} " f"at {new_rec.recommended_expiration} for ${new_rec.estimated_premium:.2f} credit." ) if alert_type == "earnings_warning": return ( f"⚠️ Earnings date {snap.earnings_date} now falls within your expiry on {expiry}. " f"Consider closing or rolling before earnings." ) return f"Signal change detected on {position.ticker} ${strike:.0f} strike. Review your position." def _refresh_recommendations(db: Session, device_id: int, ticker: str, snap: SignalSnapshot): """Rebuild and save latest recommendations for a ticker.""" for strategy in ("covered_call", "cash_secured_put"): for horizon in ("weekly", "monthly"): rec = build_recommendation( device_id=device_id, ticker=ticker, strategy=strategy, time_horizon=horizon, snapshot=snap, ) if rec is None: continue # Replace existing recommendation for same device/ticker/strategy/horizon existing = ( db.query(Recommendation) .filter( Recommendation.device_id == device_id, Recommendation.ticker == ticker, Recommendation.strategy == strategy, Recommendation.time_horizon == horizon, ) .first() ) if existing: db.delete(existing) db.add(rec)