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>
This commit is contained in:
299
backend/app/services/recommendation_engine.py
Normal file
299
backend/app/services/recommendation_engine.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user