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>
142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
from datetime import datetime, timedelta
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.db_models import Device, StockPosition, Recommendation
|
|
from app.models.schemas import RecommendationResponse, RecommendationWithSignals
|
|
from app.services.signal_engine import compute_signals
|
|
from app.services.recommendation_engine import build_recommendation
|
|
from app.config import settings
|
|
|
|
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
|
|
|
|
|
def _get_device(x_device_token: str = Header(...), db: Session = Depends(get_db)) -> Device:
|
|
device = db.query(Device).filter(Device.apns_token == x_device_token).first()
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail="Device not registered.")
|
|
return device
|
|
|
|
|
|
@router.get("", response_model=list[RecommendationResponse])
|
|
def get_recommendations(
|
|
time_horizon: str | None = Query(None),
|
|
device: Device = Depends(_get_device),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Return latest cached recommendations for all portfolio tickers."""
|
|
query = db.query(Recommendation).filter(Recommendation.device_id == device.id)
|
|
if time_horizon:
|
|
query = query.filter(Recommendation.time_horizon == time_horizon)
|
|
return query.order_by(Recommendation.created_at.desc()).all()
|
|
|
|
|
|
@router.get("/{ticker}", response_model=RecommendationWithSignals)
|
|
def get_recommendation_for_ticker(
|
|
ticker: str,
|
|
time_horizon: str = Query("weekly"),
|
|
strategy: str = Query("covered_call"),
|
|
device: Device = Depends(_get_device),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Return fresh recommendation + signals for a specific ticker."""
|
|
ticker = ticker.upper()
|
|
|
|
snap = compute_signals(ticker)
|
|
if snap is None:
|
|
raise HTTPException(status_code=503, detail=f"Could not fetch market data for {ticker}")
|
|
|
|
rec = build_recommendation(
|
|
device_id=device.id,
|
|
ticker=ticker,
|
|
strategy=strategy,
|
|
time_horizon=time_horizon,
|
|
snapshot=snap,
|
|
)
|
|
if rec is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No qualifying options found for {ticker} {strategy} {time_horizon}",
|
|
)
|
|
|
|
# Persist recommendation
|
|
existing = (
|
|
db.query(Recommendation)
|
|
.filter(
|
|
Recommendation.device_id == device.id,
|
|
Recommendation.ticker == ticker,
|
|
Recommendation.strategy == strategy,
|
|
Recommendation.time_horizon == time_horizon,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
db.delete(existing)
|
|
db.add(rec)
|
|
db.commit()
|
|
db.refresh(rec)
|
|
|
|
return RecommendationWithSignals(recommendation=RecommendationResponse.model_validate(rec), signals=snap)
|
|
|
|
|
|
@router.post("/refresh", response_model=list[RecommendationResponse])
|
|
def refresh_recommendations(
|
|
device: Device = Depends(_get_device),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
On-demand recalculation for all portfolio tickers.
|
|
Throttled: no-ops if last refresh was less than THROTTLE_MINUTES ago.
|
|
"""
|
|
throttle = timedelta(minutes=settings.recommendation_throttle_minutes)
|
|
most_recent = (
|
|
db.query(Recommendation)
|
|
.filter(Recommendation.device_id == device.id)
|
|
.order_by(Recommendation.created_at.desc())
|
|
.first()
|
|
)
|
|
if most_recent and (datetime.utcnow() - most_recent.created_at) < throttle:
|
|
return db.query(Recommendation).filter(Recommendation.device_id == device.id).all()
|
|
|
|
stock_positions = db.query(StockPosition).filter(StockPosition.device_id == device.id).all()
|
|
if not stock_positions:
|
|
return []
|
|
|
|
results = []
|
|
for sp in stock_positions:
|
|
snap = compute_signals(sp.ticker)
|
|
if snap is None:
|
|
continue
|
|
for strategy in ("covered_call", "cash_secured_put"):
|
|
for horizon in ("weekly", "monthly"):
|
|
rec = build_recommendation(
|
|
device_id=device.id,
|
|
ticker=sp.ticker,
|
|
strategy=strategy,
|
|
time_horizon=horizon,
|
|
snapshot=snap,
|
|
)
|
|
if rec is None:
|
|
continue
|
|
|
|
existing = (
|
|
db.query(Recommendation)
|
|
.filter(
|
|
Recommendation.device_id == device.id,
|
|
Recommendation.ticker == sp.ticker,
|
|
Recommendation.strategy == strategy,
|
|
Recommendation.time_horizon == horizon,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
db.delete(existing)
|
|
db.add(rec)
|
|
results.append(rec)
|
|
|
|
db.commit()
|
|
for r in results:
|
|
db.refresh(r)
|
|
return results
|