Files
Options-SideKick/backend/app/services/recommendation_engine.py
olsch01 b7d4e900cc Initial implementation of Options Sidekick
Full-stack iOS options trading assistant:
- Python FastAPI backend with SQLite, APScheduler (15-min position monitor),
  APNs push notifications, and yfinance market data integration
- Signal engine: IV Rank (rolling HV proxy), SMA-50/200, swing-based
  support/resistance, earnings detection, signal strength scoring and
  noise-resistant SHA hash for change detection
- Recommendation engine: covered call and cash-secured put strike/expiry
  selection across 0DTE, 1DTE, weekly, and monthly horizons
- REST API: /devices, /portfolio, /recommendations, /positions, /signals, /alerts
- iOS SwiftUI app (iOS 17+): dashboard, recommendations, trades, portfolio,
  and alerts tabs with push notification deep-linking
- Unit + integration tests for signal engine and API layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:38:25 -04:00

300 lines
9.4 KiB
Python

"""
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)