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:
331
backend/app/services/position_monitor.py
Normal file
331
backend/app/services/position_monitor.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user