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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
46
backend/app/routers/alerts.py
Normal file
46
backend/app/routers/alerts.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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, Alert
|
||||
from app.models.schemas import AlertResponse
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
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[AlertResponse])
|
||||
def get_alerts(
|
||||
unread_only: bool = Query(False),
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Alert).filter(Alert.device_id == device.id)
|
||||
if unread_only:
|
||||
query = query.filter(Alert.acknowledged == False) # noqa: E712
|
||||
return query.order_by(Alert.sent_at.desc()).limit(100).all()
|
||||
|
||||
|
||||
@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse)
|
||||
def acknowledge_alert(
|
||||
alert_id: int,
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
alert = (
|
||||
db.query(Alert)
|
||||
.filter(Alert.id == alert_id, Alert.device_id == device.id)
|
||||
.first()
|
||||
)
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found.")
|
||||
alert.acknowledged = True
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
29
backend/app/routers/devices.py
Normal file
29
backend/app/routers/devices.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.db_models import Device
|
||||
from app.models.schemas import DeviceRegister, DeviceResponse
|
||||
|
||||
router = APIRouter(prefix="/devices", tags=["devices"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=DeviceResponse)
|
||||
def register_device(body: DeviceRegister, db: Session = Depends(get_db)):
|
||||
"""Register or refresh an APNs device token."""
|
||||
device = db.query(Device).filter(Device.apns_token == body.apns_token).first()
|
||||
|
||||
if device:
|
||||
device.device_name = body.device_name or device.device_name
|
||||
device.last_seen = datetime.utcnow()
|
||||
else:
|
||||
device = Device(
|
||||
apns_token=body.apns_token,
|
||||
device_name=body.device_name,
|
||||
)
|
||||
db.add(device)
|
||||
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device
|
||||
64
backend/app/routers/portfolio.py
Normal file
64
backend/app/routers/portfolio.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.db_models import Device, StockPosition
|
||||
from app.models.schemas import StockPositionCreate, StockPositionResponse
|
||||
|
||||
router = APIRouter(prefix="/portfolio", tags=["portfolio"])
|
||||
|
||||
|
||||
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. Call /devices/register first.")
|
||||
return device
|
||||
|
||||
|
||||
@router.get("", response_model=list[StockPositionResponse])
|
||||
def get_portfolio(device: Device = Depends(_get_device), db: Session = Depends(get_db)):
|
||||
return db.query(StockPosition).filter(StockPosition.device_id == device.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=list[StockPositionResponse])
|
||||
def set_portfolio(
|
||||
positions: list[StockPositionCreate],
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Full replace — client sends complete list of stock holdings."""
|
||||
# Delete all existing for this device
|
||||
db.query(StockPosition).filter(StockPosition.device_id == device.id).delete()
|
||||
|
||||
new_positions = []
|
||||
for p in positions:
|
||||
sp = StockPosition(
|
||||
device_id=device.id,
|
||||
ticker=p.ticker,
|
||||
shares=p.shares,
|
||||
cost_basis=p.cost_basis,
|
||||
)
|
||||
db.add(sp)
|
||||
new_positions.append(sp)
|
||||
|
||||
db.commit()
|
||||
for sp in new_positions:
|
||||
db.refresh(sp)
|
||||
return new_positions
|
||||
|
||||
|
||||
@router.delete("/{ticker}", status_code=204)
|
||||
def delete_ticker(
|
||||
ticker: str,
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ticker = ticker.upper()
|
||||
deleted = (
|
||||
db.query(StockPosition)
|
||||
.filter(StockPosition.device_id == device.id, StockPosition.ticker == ticker)
|
||||
.delete()
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Ticker {ticker} not in portfolio")
|
||||
db.commit()
|
||||
78
backend/app/routers/positions.py
Normal file
78
backend/app/routers/positions.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime
|
||||
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, OptionPosition
|
||||
from app.models.schemas import OptionPositionCreate, OptionPositionClose, OptionPositionResponse
|
||||
|
||||
router = APIRouter(prefix="/positions", tags=["positions"])
|
||||
|
||||
|
||||
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[OptionPositionResponse])
|
||||
def get_positions(
|
||||
status: str | None = Query(None),
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(OptionPosition).filter(OptionPosition.device_id == device.id)
|
||||
if status:
|
||||
query = query.filter(OptionPosition.status == status)
|
||||
return query.order_by(OptionPosition.opened_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("", response_model=OptionPositionResponse, status_code=201)
|
||||
def log_position(
|
||||
body: OptionPositionCreate,
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
position = OptionPosition(
|
||||
device_id=device.id,
|
||||
ticker=body.ticker,
|
||||
strategy=body.strategy,
|
||||
strike=body.strike,
|
||||
expiration=body.expiration,
|
||||
premium_received=body.premium_received,
|
||||
contracts=body.contracts,
|
||||
status="open",
|
||||
)
|
||||
db.add(position)
|
||||
db.commit()
|
||||
db.refresh(position)
|
||||
return position
|
||||
|
||||
|
||||
@router.patch("/{position_id}", response_model=OptionPositionResponse)
|
||||
def close_position(
|
||||
position_id: int,
|
||||
body: OptionPositionClose,
|
||||
device: Device = Depends(_get_device),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
position = (
|
||||
db.query(OptionPosition)
|
||||
.filter(OptionPosition.id == position_id, OptionPosition.device_id == device.id)
|
||||
.first()
|
||||
)
|
||||
if not position:
|
||||
raise HTTPException(status_code=404, detail="Position not found.")
|
||||
|
||||
valid_statuses = ("closed", "rolled")
|
||||
if body.status not in valid_statuses:
|
||||
raise HTTPException(status_code=422, detail=f"status must be one of {valid_statuses}")
|
||||
|
||||
position.status = body.status
|
||||
position.close_reason = body.close_reason
|
||||
position.closed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(position)
|
||||
return position
|
||||
141
backend/app/routers/recommendations.py
Normal file
141
backend/app/routers/recommendations.py
Normal file
@@ -0,0 +1,141 @@
|
||||
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
|
||||
14
backend/app/routers/signals.py
Normal file
14
backend/app/routers/signals.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models.schemas import SignalSnapshot
|
||||
from app.services.signal_engine import compute_signals
|
||||
|
||||
router = APIRouter(prefix="/signals", tags=["signals"])
|
||||
|
||||
|
||||
@router.get("/{ticker}", response_model=SignalSnapshot)
|
||||
def get_signals(ticker: str):
|
||||
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}")
|
||||
return snap
|
||||
Reference in New Issue
Block a user