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

View File

@@ -0,0 +1,95 @@
from datetime import datetime, date
from sqlalchemy import Integer, String, Float, Boolean, DateTime, Date, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Device(Base):
__tablename__ = "devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
apns_token: Mapped[str] = mapped_column(String, unique=True, index=True)
device_name: Mapped[str | None] = mapped_column(String, nullable=True)
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_seen: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
stock_positions: Mapped[list["StockPosition"]] = relationship("StockPosition", back_populates="device", cascade="all, delete-orphan")
option_positions: Mapped[list["OptionPosition"]] = relationship("OptionPosition", back_populates="device", cascade="all, delete-orphan")
alerts: Mapped[list["Alert"]] = relationship("Alert", back_populates="device", cascade="all, delete-orphan")
class StockPosition(Base):
__tablename__ = "stock_positions"
__table_args__ = (UniqueConstraint("device_id", "ticker", name="uq_device_ticker"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
device_id: Mapped[int] = mapped_column(Integer, ForeignKey("devices.id"), nullable=False)
ticker: Mapped[str] = mapped_column(String, nullable=False)
shares: Mapped[int] = mapped_column(Integer, nullable=False)
cost_basis: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
device: Mapped["Device"] = relationship("Device", back_populates="stock_positions")
class OptionPosition(Base):
__tablename__ = "option_positions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
device_id: Mapped[int] = mapped_column(Integer, ForeignKey("devices.id"), nullable=False)
ticker: Mapped[str] = mapped_column(String, nullable=False)
strategy: Mapped[str] = mapped_column(String, nullable=False) # covered_call | cash_secured_put
strike: Mapped[float] = mapped_column(Float, nullable=False)
expiration: Mapped[date] = mapped_column(Date, nullable=False)
premium_received: Mapped[float] = mapped_column(Float, nullable=False)
contracts: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String, default="open") # open | closed | rolled
close_reason: Mapped[str | None] = mapped_column(String, nullable=True) # expired | bought_back | rolled
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_signal_hash: Mapped[str | None] = mapped_column(String(16), nullable=True)
device: Mapped["Device"] = relationship("Device", back_populates="option_positions")
alerts: Mapped[list["Alert"]] = relationship("Alert", back_populates="option_position")
class Recommendation(Base):
__tablename__ = "recommendations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
device_id: Mapped[int] = mapped_column(Integer, ForeignKey("devices.id"), nullable=False)
ticker: Mapped[str] = mapped_column(String, nullable=False)
strategy: Mapped[str] = mapped_column(String, nullable=False) # covered_call | cash_secured_put
time_horizon: Mapped[str] = mapped_column(String, nullable=False) # 0dte | 1dte | weekly | monthly
current_price: Mapped[float] = mapped_column(Float, nullable=False)
recommended_strike: Mapped[float] = mapped_column(Float, nullable=False)
recommended_expiration: Mapped[date] = mapped_column(Date, nullable=False)
estimated_premium: Mapped[float] = mapped_column(Float, nullable=False)
delta: Mapped[float] = mapped_column(Float, nullable=False)
theta: Mapped[float] = mapped_column(Float, nullable=False)
iv_rank: Mapped[float] = mapped_column(Float, nullable=False)
signal_strength: Mapped[str] = mapped_column(String, nullable=False) # strong | moderate | weak
earnings_warning: Mapped[bool] = mapped_column(Boolean, default=False)
earnings_date: Mapped[date | None] = mapped_column(Date, nullable=True)
rationale: Mapped[str] = mapped_column(String, nullable=False)
signal_hash: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Alert(Base):
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
device_id: Mapped[int] = mapped_column(Integer, ForeignKey("devices.id"), nullable=False)
option_position_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("option_positions.id"), nullable=True)
ticker: Mapped[str] = mapped_column(String, nullable=False)
alert_type: Mapped[str] = mapped_column(String, nullable=False) # close_early | roll_out | roll_up_down | earnings_warning | new_rec
message: Mapped[str] = mapped_column(String, nullable=False)
old_signal_hash: Mapped[str | None] = mapped_column(String(16), nullable=True)
new_signal_hash: Mapped[str | None] = mapped_column(String(16), nullable=True)
sent_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
device: Mapped["Device"] = relationship("Device", back_populates="alerts")
option_position: Mapped["OptionPosition | None"] = relationship("OptionPosition", back_populates="alerts")

View File

@@ -0,0 +1,166 @@
from datetime import datetime, date
from pydantic import BaseModel, field_validator
# ─── Device ───────────────────────────────────────────────────────────────────
class DeviceRegister(BaseModel):
apns_token: str
device_name: str | None = None
class DeviceResponse(BaseModel):
id: int
apns_token: str
device_name: str | None
registered_at: datetime
model_config = {"from_attributes": True}
# ─── Stock Portfolio ───────────────────────────────────────────────────────────
class StockPositionCreate(BaseModel):
ticker: str
shares: int
cost_basis: float | None = None
@field_validator("ticker")
@classmethod
def uppercase_ticker(cls, v: str) -> str:
return v.upper().strip()
@field_validator("shares")
@classmethod
def positive_shares(cls, v: int) -> int:
if v <= 0:
raise ValueError("shares must be positive")
return v
class StockPositionResponse(BaseModel):
id: int
ticker: str
shares: int
cost_basis: float | None
created_at: datetime
model_config = {"from_attributes": True}
# ─── Option Position ───────────────────────────────────────────────────────────
class OptionPositionCreate(BaseModel):
ticker: str
strategy: str # covered_call | cash_secured_put
strike: float
expiration: date
premium_received: float
contracts: int = 1
@field_validator("ticker")
@classmethod
def uppercase_ticker(cls, v: str) -> str:
return v.upper().strip()
@field_validator("strategy")
@classmethod
def valid_strategy(cls, v: str) -> str:
if v not in ("covered_call", "cash_secured_put"):
raise ValueError("strategy must be 'covered_call' or 'cash_secured_put'")
return v
@field_validator("contracts")
@classmethod
def positive_contracts(cls, v: int) -> int:
if v <= 0:
raise ValueError("contracts must be positive")
return v
class OptionPositionClose(BaseModel):
status: str # closed | rolled
close_reason: str | None = None
class OptionPositionResponse(BaseModel):
id: int
ticker: str
strategy: str
strike: float
expiration: date
premium_received: float
contracts: int
status: str
close_reason: str | None
opened_at: datetime
closed_at: datetime | None
last_signal_hash: str | None
model_config = {"from_attributes": True}
# ─── Signals ──────────────────────────────────────────────────────────────────
class SignalSnapshot(BaseModel):
ticker: str
current_price: float
iv_rank: float
sma_50: float
sma_200: float
nearest_support: float | None
nearest_resistance: float | None
trend: str # uptrend | downtrend | sideways
earnings_date: date | None
computed_at: datetime
# ─── Recommendations ──────────────────────────────────────────────────────────
class RecommendationResponse(BaseModel):
id: int
ticker: str
strategy: str
time_horizon: str
current_price: float
recommended_strike: float
recommended_expiration: date
estimated_premium: float
delta: float
theta: float
iv_rank: float
signal_strength: str
earnings_warning: bool
earnings_date: date | None
rationale: str
signal_hash: str
created_at: datetime
model_config = {"from_attributes": True}
class RecommendationWithSignals(BaseModel):
recommendation: RecommendationResponse
signals: SignalSnapshot
# ─── Alerts ───────────────────────────────────────────────────────────────────
class AlertResponse(BaseModel):
id: int
ticker: str
option_position_id: int | None
alert_type: str
message: str
sent_at: datetime
acknowledged: bool
model_config = {"from_attributes": True}
# ─── Health ───────────────────────────────────────────────────────────────────
class HealthResponse(BaseModel):
status: str
scheduler_running: bool
last_run: datetime | None