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:
2026-04-09 14:38:25 -04:00
commit b7d4e900cc
61 changed files with 4953 additions and 0 deletions

View 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