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>
300 lines
9.4 KiB
Python
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)
|