""" recommendation_engine.py — Select optimal strike and expiry for a given strategy/horizon. For each (ticker, strategy, time_horizon) combination: 1. Determine the target expiration date 2. Fetch the options chain for that expiry 3. Filter by delta range (0.20-0.30 for CC/CSP) 4. Among qualifying strikes, pick highest mid-price premium 5. Build a Recommendation DB row """ import logging import math from datetime import date, datetime from typing import Optional import pandas as pd from app.models.db_models import Recommendation from app.models.schemas import SignalSnapshot 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.utils.date_helpers import ( next_trading_day, next_friday, nearest_monthly_expiry, within_dte_window_for_0dte, ) logger = logging.getLogger(__name__) # Delta target ranges for short premium selling DELTA_MIN = 0.18 DELTA_MAX = 0.35 def _target_expiry(ticker: str, time_horizon: str) -> Optional[str]: """ Return the best available expiry string for the given time horizon. Returns None if no suitable expiry exists. """ today = date.today() if time_horizon == "0dte": if not within_dte_window_for_0dte(): return None expiry = md.get_same_day_expiry(ticker) return expiry # None if today has no options if time_horizon == "1dte": target = next_trading_day(today) return md.get_nearest_expiry(ticker, target) if time_horizon == "weekly": target = next_friday(today) return md.get_nearest_expiry(ticker, target) if time_horizon == "monthly": target = nearest_monthly_expiry(today, target_dte=30) return md.get_nearest_expiry(ticker, target) return None def _best_strike( chain_df: pd.DataFrame, strategy: str, current_price: float, nearest_support: Optional[float], nearest_resistance: Optional[float], ) -> Optional[pd.Series]: """ Filter options chain for the best strike. Rules: covered_call — calls, OTM (strike > current_price), delta 0.20-0.35 cash_secured_put — puts, OTM (strike < current_price), |delta| 0.20-0.35, strike preferably > nearest_support Returns the best row or None. """ df = chain_df.copy() # Ensure we have a usable delta column has_delta = "delta" in df.columns and df["delta"].notna().any() if strategy == "covered_call": df = df[df["strike"] > current_price] if has_delta: df = df[(df["delta"] >= DELTA_MIN) & (df["delta"] <= DELTA_MAX)] else: # Approximate OTM calls: within 5% above current price df = df[df["strike"] <= current_price * 1.07] elif strategy == "cash_secured_put": df = df[df["strike"] < current_price] if has_delta: df = df[(df["delta"].abs() >= DELTA_MIN) & (df["delta"].abs() <= DELTA_MAX)] else: df = df[df["strike"] >= current_price * 0.93] # Prefer strikes above nearest support to avoid selling below key level if nearest_support: above_support = df[df["strike"] >= nearest_support * 0.99] if not above_support.empty: df = above_support if df.empty: return None # Compute mid-price and pick highest premium df = df.copy() df["mid"] = (df["bid"] + df["ask"]) / 2 df = df[df["mid"] > 0] if df.empty: return None return df.loc[df["mid"].idxmax()] def build_recommendation( device_id: int, ticker: str, strategy: str, time_horizon: str, snapshot: Optional[SignalSnapshot] = None, ) -> Optional[Recommendation]: """ Build a Recommendation ORM object for the given parameters. Returns None if not enough data exists. """ # 1. Get signal snapshot (reuse if provided to avoid duplicate yfinance calls) if snapshot is None: 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) nearest_support = swing["nearest_support"] nearest_resistance = swing["nearest_resistance"] sma_50 = smas["sma_50"] sma_200 = smas["sma_200"] else: current_price = snapshot.current_price iv_rank = snapshot.iv_rank sma_50 = snapshot.sma_50 sma_200 = snapshot.sma_200 nearest_support = snapshot.nearest_support nearest_resistance = snapshot.nearest_resistance trend = snapshot.trend earnings_date = snapshot.earnings_date # 2. Determine target expiry expiry_str = _target_expiry(ticker, time_horizon) if expiry_str is None: logger.debug(f"No expiry available for {ticker} {time_horizon}") return None expiry_date = date.fromisoformat(expiry_str) # 3. Earnings warning earnings_warning = bool(earnings_date and earnings_date <= expiry_date) # 4. Fetch options chain chain = md.get_options_chain(ticker, expiry_str) if chain is None: return None chain_df = chain["calls"] if strategy == "covered_call" else chain["puts"] # 5. Pick best strike best = _best_strike(chain_df, strategy, current_price, nearest_support, nearest_resistance) if best is None: logger.debug(f"No qualifying strike for {ticker} {strategy} {time_horizon}") return None strike = float(best["strike"]) mid_price = float((best["bid"] + best["ask"]) / 2) delta = float(best["delta"]) if not math.isnan(best.get("delta", float("nan"))) else _estimate_delta(strike, current_price, strategy) theta = float(best["theta"]) if not math.isnan(best.get("theta", float("nan"))) else 0.0 # 6. Signal strength signal_strength = compute_signal_strength( iv_rank=iv_rank, trend=trend, strategy=strategy, nearest_support=nearest_support, nearest_resistance=nearest_resistance, recommended_strike=strike, earnings_warning=earnings_warning, ) # 7. Signal hash sig_hash = compute_signal_hash( iv_rank=iv_rank, sma_50=sma_50, sma_200=sma_200, nearest_support=nearest_support, nearest_resistance=nearest_resistance, recommended_strike=strike, recommended_expiration=expiry_date, earnings_warning=earnings_warning, ) # 8. Build human-readable rationale rationale = _build_rationale( strategy=strategy, time_horizon=time_horizon, current_price=current_price, strike=strike, iv_rank=iv_rank, trend=trend, earnings_warning=earnings_warning, earnings_date=earnings_date, signal_strength=signal_strength, ) return Recommendation( device_id=device_id, ticker=ticker, strategy=strategy, time_horizon=time_horizon, current_price=current_price, recommended_strike=strike, recommended_expiration=expiry_date, estimated_premium=mid_price, delta=delta, theta=theta, iv_rank=iv_rank, signal_strength=signal_strength, earnings_warning=earnings_warning, earnings_date=earnings_date, rationale=rationale, signal_hash=sig_hash, created_at=datetime.utcnow(), ) def _estimate_delta(strike: float, current_price: float, strategy: str) -> float: """Rough delta estimate when yfinance doesn't provide it.""" moneyness = (strike - current_price) / current_price if strategy == "covered_call": return max(0.05, 0.50 - moneyness * 3) else: return max(0.05, 0.50 - abs(moneyness) * 3) def _build_rationale( strategy: str, time_horizon: str, current_price: float, strike: float, iv_rank: float, trend: str, earnings_warning: bool, earnings_date: Optional[date], signal_strength: str, ) -> str: parts = [] strategy_label = "Covered Call" if strategy == "covered_call" else "Cash-Secured Put" direction = "above" if strategy == "covered_call" else "below" parts.append( f"{signal_strength.capitalize()} {strategy_label} setup. " f"Strike ${strike:.2f} is {direction} current price ${current_price:.2f}." ) if iv_rank >= 50: parts.append(f"IV rank is elevated at {iv_rank:.0f}% — favorable premium-selling environment.") elif iv_rank >= 30: parts.append(f"IV rank is moderate at {iv_rank:.0f}%.") else: parts.append(f"IV rank is low at {iv_rank:.0f}% — premiums may be thin.") trend_map = {"uptrend": "bullish uptrend", "downtrend": "bearish downtrend", "sideways": "sideways range"} parts.append(f"Price is in a {trend_map.get(trend, trend)}.") if earnings_warning and earnings_date: parts.append( f"⚠️ Earnings on {earnings_date} fall within this expiry — elevated risk. Consider a shorter expiry." ) horizon_map = {"0dte": "0DTE", "1dte": "1DTE", "weekly": "weekly", "monthly": "monthly"} parts.append(f"Horizon: {horizon_map.get(time_horizon, time_horizon)}.") return " ".join(parts)