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

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.env
*.db
*.db-shm
*.db-wal
venv/
.venv/
dist/
*.egg-info/
# APNs keys — NEVER commit
*.p8
# Xcode
*.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
.DS_Store
DerivedData/
*.xccheckout
*.xcscmblueprint
build/
# macOS
.DS_Store
.AppleDouble
.LSOverride

7
backend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL=sqlite:///./app.db
APNS_KEY_PATH=./AuthKey_XXXXXXXXXX.p8
APNS_KEY_ID=XXXXXXXXXX
APNS_TEAM_ID=XXXXXXXXXX
APNS_BUNDLE_ID=com.yourname.options-sidekick
APNS_USE_SANDBOX=true
RECOMMENDATION_THROTTLE_MINUTES=5

1
backend/Procfile Normal file
View File

@@ -0,0 +1 @@
web: uvicorn app.main:app --host 0.0.0.0 --port $PORT

0
backend/app/__init__.py Normal file
View File

19
backend/app/config.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./app.db"
apns_key_path: str = "./AuthKey_XXXXXXXXXX.p8"
apns_key_id: str = ""
apns_team_id: str = ""
apns_bundle_id: str = "com.yourname.options-sidekick"
apns_use_sandbox: bool = True
recommendation_throttle_minutes: int = 5
monitor_interval_seconds: int = 900 # 15 minutes
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

27
backend/app/database.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
from app.models.db_models import Device, StockPosition, OptionPosition, Recommendation, Alert # noqa: F401
Base.metadata.create_all(bind=engine)

54
backend/app/main.py Normal file
View File

@@ -0,0 +1,54 @@
from contextlib import asynccontextmanager
from datetime import datetime
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.scheduler import start_scheduler, stop_scheduler, scheduler
from app.routers import devices, portfolio, recommendations, positions, signals, alerts
from app.models.schemas import HealthResponse
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
# Startup
init_db()
start_scheduler()
yield
# Shutdown
stop_scheduler()
app = FastAPI(
title="Options Sidekick",
description="Covered call and cash-secured put recommendation engine",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(devices.router, prefix="/api/v1")
app.include_router(portfolio.router, prefix="/api/v1")
app.include_router(recommendations.router, prefix="/api/v1")
app.include_router(positions.router, prefix="/api/v1")
app.include_router(signals.router, prefix="/api/v1")
app.include_router(alerts.router, prefix="/api/v1")
@app.get("/api/v1/health", response_model=HealthResponse, tags=["health"])
def health():
from app.services.position_monitor import last_run
return HealthResponse(
status="ok",
scheduler_running=scheduler.running,
last_run=last_run,
)

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

View File

View 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

View 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

View 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()

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

View 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

View 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

38
backend/app/scheduler.py Normal file
View File

@@ -0,0 +1,38 @@
"""
scheduler.py — APScheduler configuration.
The scheduler is started in main.py's lifespan event.
"""
import asyncio
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.config import settings
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
def start_scheduler():
from app.services.position_monitor import monitor_all_positions
scheduler.add_job(
monitor_all_positions,
trigger=IntervalTrigger(seconds=settings.monitor_interval_seconds),
id="position_monitor",
name="Position Monitor",
replace_existing=True,
misfire_grace_time=60,
)
scheduler.start()
logger.info(f"Scheduler started — monitor interval: {settings.monitor_interval_seconds}s")
def stop_scheduler():
if scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")

View File

View File

@@ -0,0 +1,124 @@
"""
apns_service.py — Send push notifications via APNs HTTP/2.
Uses JWT authentication with a .p8 key. JWT is cached for 50 minutes
and auto-renewed before the 60-minute APNs expiry.
"""
import json
import logging
import time
from datetime import datetime, timedelta
from typing import Optional
import httpx
import jwt as pyjwt
from app.config import settings
logger = logging.getLogger(__name__)
_APNS_HOST_PROD = "https://api.push.apple.com"
_APNS_HOST_SANDBOX = "https://api.sandbox.push.apple.com"
# Cached JWT state
_jwt_token: Optional[str] = None
_jwt_issued_at: Optional[float] = None
_JWT_TTL_SECONDS = 50 * 60 # 50 minutes — renew before Apple's 60-min limit
def _get_apns_host() -> str:
return _APNS_HOST_SANDBOX if settings.apns_use_sandbox else _APNS_HOST_PROD
def _load_private_key() -> str:
try:
with open(settings.apns_key_path, "r") as f:
return f.read()
except FileNotFoundError:
logger.warning(f"APNs key not found at {settings.apns_key_path} — push disabled")
return ""
def _get_jwt() -> Optional[str]:
global _jwt_token, _jwt_issued_at
now = time.time()
if _jwt_token and _jwt_issued_at and (now - _jwt_issued_at) < _JWT_TTL_SECONDS:
return _jwt_token
private_key = _load_private_key()
if not private_key:
return None
if not settings.apns_key_id or not settings.apns_team_id:
logger.warning("APNs key ID or team ID not configured — push disabled")
return None
payload = {
"iss": settings.apns_team_id,
"iat": int(now),
}
headers = {
"alg": "ES256",
"kid": settings.apns_key_id,
}
try:
token = pyjwt.encode(payload, private_key, algorithm="ES256", headers=headers)
_jwt_token = token if isinstance(token, str) else token.decode("utf-8")
_jwt_issued_at = now
return _jwt_token
except Exception as e:
logger.error(f"Failed to generate APNs JWT: {e}")
return None
async def send_push(
apns_token: str,
title: str,
body: str,
payload: Optional[dict] = None,
) -> bool:
"""
Send a push notification to a device.
Returns True on success, False on failure.
"""
jwt_token = _get_jwt()
if not jwt_token:
logger.warning("APNs push skipped — no valid JWT (check .p8 key config)")
return False
apns_payload = {
"aps": {
"alert": {"title": title, "body": body},
"badge": 1,
"sound": "default",
"category": "POSITION_ALERT",
}
}
if payload:
apns_payload.update(payload)
url = f"{_get_apns_host()}/3/device/{apns_token}"
headers = {
"authorization": f"bearer {jwt_token}",
"apns-topic": settings.apns_bundle_id,
"apns-push-type": "alert",
"content-type": "application/json",
}
try:
async with httpx.AsyncClient(http2=True, timeout=10.0) as client:
response = await client.post(url, headers=headers, content=json.dumps(apns_payload))
if response.status_code == 200:
logger.info(f"Push sent to {apns_token[:8]}... — {title}")
return True
else:
logger.error(f"APNs rejected push: {response.status_code} {response.text}")
return False
except Exception as e:
logger.error(f"APNs push failed: {e}")
return False

View File

@@ -0,0 +1,198 @@
"""
market_data.py — yfinance wrapper with 15-minute in-memory cache.
All other services call through this module. Never import yfinance directly
outside of here — keeps caching and error handling centralized.
"""
import logging
from datetime import datetime, date, timedelta
from functools import lru_cache
from typing import Optional
import time
import pandas as pd
import yfinance as yf
logger = logging.getLogger(__name__)
# ─── Simple time-bucketed cache ───────────────────────────────────────────────
# Key: (ticker, cache_bucket) where cache_bucket = int(time() // 900)
_CACHE: dict = {}
_CACHE_TTL_SECONDS = 900 # 15 minutes
def _bucket() -> int:
return int(time.time() // _CACHE_TTL_SECONDS)
def _cache_get(key: tuple):
return _CACHE.get((key, _bucket()))
def _cache_set(key: tuple, value):
_CACHE[(key, _bucket())] = value
# Prune old entries to avoid unbounded growth
current = _bucket()
stale = [k for k in list(_CACHE) if k[1] < current - 1]
for k in stale:
del _CACHE[k]
# ─── Price history ─────────────────────────────────────────────────────────────
def get_price_history(ticker: str, period: str = "1y") -> Optional[pd.DataFrame]:
"""
Return OHLCV DataFrame for the past year.
Columns: Open, High, Low, Close, Volume
Index: DatetimeIndex
Returns None on failure.
"""
cache_key = ("price_history", ticker, period)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
if df.empty:
logger.warning(f"Empty price history for {ticker}")
return None
# Flatten multi-level columns if present (yfinance sometimes returns them)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
_cache_set(cache_key, df)
return df
except Exception as e:
logger.error(f"Failed to fetch price history for {ticker}: {e}")
return None
# ─── Current price ─────────────────────────────────────────────────────────────
def get_current_price(ticker: str) -> Optional[float]:
cache_key = ("current_price", ticker)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
t = yf.Ticker(ticker)
price = t.fast_info.get("last_price") or t.fast_info.get("previousClose")
if price is None:
hist = get_price_history(ticker, period="5d")
if hist is not None and not hist.empty:
price = float(hist["Close"].iloc[-1])
if price is not None:
price = float(price)
_cache_set(cache_key, price)
return price
except Exception as e:
logger.error(f"Failed to fetch current price for {ticker}: {e}")
return None
# ─── Options chain ─────────────────────────────────────────────────────────────
def get_option_expirations(ticker: str) -> list[str]:
"""Return list of available expiration date strings (YYYY-MM-DD)."""
cache_key = ("expirations", ticker)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
t = yf.Ticker(ticker)
expirations = list(t.options)
_cache_set(cache_key, expirations)
return expirations
except Exception as e:
logger.error(f"Failed to fetch expirations for {ticker}: {e}")
return []
def get_options_chain(ticker: str, expiration: str) -> Optional[dict]:
"""
Return {'calls': DataFrame, 'puts': DataFrame} for the given expiry.
Columns include: strike, lastPrice, bid, ask, volume, openInterest,
impliedVolatility, delta, theta, gamma (where available).
Returns None on failure.
"""
cache_key = ("options_chain", ticker, expiration)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
t = yf.Ticker(ticker)
chain = t.option_chain(expiration)
result = {"calls": chain.calls.copy(), "puts": chain.puts.copy()}
# yfinance does not always include Greeks — add NaN columns if missing
for df in result.values():
for col in ("delta", "theta", "gamma"):
if col not in df.columns:
df[col] = float("nan")
_cache_set(cache_key, result)
return result
except Exception as e:
logger.error(f"Failed to fetch options chain for {ticker} exp={expiration}: {e}")
return None
def get_nearest_expiry(ticker: str, target_date: date) -> Optional[str]:
"""Return the closest available expiry on or after target_date."""
expirations = get_option_expirations(ticker)
if not expirations:
return None
target_str = str(target_date)
future = [e for e in sorted(expirations) if e >= target_str]
return future[0] if future else None
def get_same_day_expiry(ticker: str) -> Optional[str]:
"""Return today's expiry string if it exists, else None."""
today_str = str(date.today())
expirations = get_option_expirations(ticker)
return today_str if today_str in expirations else None
# ─── Earnings / dividends ──────────────────────────────────────────────────────
def get_earnings_date(ticker: str) -> Optional[date]:
"""Return the next earnings date or None."""
cache_key = ("earnings", ticker)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
t = yf.Ticker(ticker)
cal = t.calendar
if cal is None:
return None
# calendar can be a dict or DataFrame depending on yfinance version
if isinstance(cal, dict):
earnings_info = cal.get("Earnings Date", [])
else:
# DataFrame — look for "Earnings Date" row or column
try:
earnings_info = cal.loc["Earnings Date"].tolist()
except Exception:
earnings_info = []
result = None
for item in (earnings_info if isinstance(earnings_info, list) else [earnings_info]):
try:
d = pd.Timestamp(item).date()
if d >= date.today():
result = d
break
except Exception:
continue
_cache_set(cache_key, result)
return result
except Exception as e:
logger.error(f"Failed to fetch earnings date for {ticker}: {e}")
return None

View 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)

View File

@@ -0,0 +1,299 @@
"""
recommendation_engine.py — Select optimal strike and expiry for a given strategy/horizon.
For each (ticker, strategy, time_horizon) combination:
1. Determine the target expiration date
2. Fetch the options chain for that expiry
3. Filter by delta range (0.20-0.30 for CC/CSP)
4. Among qualifying strikes, pick highest mid-price premium
5. Build a Recommendation DB row
"""
import logging
import math
from datetime import date, datetime
from typing import Optional
import pandas as pd
from app.models.db_models import Recommendation
from app.models.schemas import SignalSnapshot
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.utils.date_helpers import (
next_trading_day,
next_friday,
nearest_monthly_expiry,
within_dte_window_for_0dte,
)
logger = logging.getLogger(__name__)
# Delta target ranges for short premium selling
DELTA_MIN = 0.18
DELTA_MAX = 0.35
def _target_expiry(ticker: str, time_horizon: str) -> Optional[str]:
"""
Return the best available expiry string for the given time horizon.
Returns None if no suitable expiry exists.
"""
today = date.today()
if time_horizon == "0dte":
if not within_dte_window_for_0dte():
return None
expiry = md.get_same_day_expiry(ticker)
return expiry # None if today has no options
if time_horizon == "1dte":
target = next_trading_day(today)
return md.get_nearest_expiry(ticker, target)
if time_horizon == "weekly":
target = next_friday(today)
return md.get_nearest_expiry(ticker, target)
if time_horizon == "monthly":
target = nearest_monthly_expiry(today, target_dte=30)
return md.get_nearest_expiry(ticker, target)
return None
def _best_strike(
chain_df: pd.DataFrame,
strategy: str,
current_price: float,
nearest_support: Optional[float],
nearest_resistance: Optional[float],
) -> Optional[pd.Series]:
"""
Filter options chain for the best strike.
Rules:
covered_call — calls, OTM (strike > current_price), delta 0.20-0.35
cash_secured_put — puts, OTM (strike < current_price), |delta| 0.20-0.35,
strike preferably > nearest_support
Returns the best row or None.
"""
df = chain_df.copy()
# Ensure we have a usable delta column
has_delta = "delta" in df.columns and df["delta"].notna().any()
if strategy == "covered_call":
df = df[df["strike"] > current_price]
if has_delta:
df = df[(df["delta"] >= DELTA_MIN) & (df["delta"] <= DELTA_MAX)]
else:
# Approximate OTM calls: within 5% above current price
df = df[df["strike"] <= current_price * 1.07]
elif strategy == "cash_secured_put":
df = df[df["strike"] < current_price]
if has_delta:
df = df[(df["delta"].abs() >= DELTA_MIN) & (df["delta"].abs() <= DELTA_MAX)]
else:
df = df[df["strike"] >= current_price * 0.93]
# Prefer strikes above nearest support to avoid selling below key level
if nearest_support:
above_support = df[df["strike"] >= nearest_support * 0.99]
if not above_support.empty:
df = above_support
if df.empty:
return None
# Compute mid-price and pick highest premium
df = df.copy()
df["mid"] = (df["bid"] + df["ask"]) / 2
df = df[df["mid"] > 0]
if df.empty:
return None
return df.loc[df["mid"].idxmax()]
def build_recommendation(
device_id: int,
ticker: str,
strategy: str,
time_horizon: str,
snapshot: Optional[SignalSnapshot] = None,
) -> Optional[Recommendation]:
"""
Build a Recommendation ORM object for the given parameters.
Returns None if not enough data exists.
"""
# 1. Get signal snapshot (reuse if provided to avoid duplicate yfinance calls)
if snapshot is None:
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)
nearest_support = swing["nearest_support"]
nearest_resistance = swing["nearest_resistance"]
sma_50 = smas["sma_50"]
sma_200 = smas["sma_200"]
else:
current_price = snapshot.current_price
iv_rank = snapshot.iv_rank
sma_50 = snapshot.sma_50
sma_200 = snapshot.sma_200
nearest_support = snapshot.nearest_support
nearest_resistance = snapshot.nearest_resistance
trend = snapshot.trend
earnings_date = snapshot.earnings_date
# 2. Determine target expiry
expiry_str = _target_expiry(ticker, time_horizon)
if expiry_str is None:
logger.debug(f"No expiry available for {ticker} {time_horizon}")
return None
expiry_date = date.fromisoformat(expiry_str)
# 3. Earnings warning
earnings_warning = bool(earnings_date and earnings_date <= expiry_date)
# 4. Fetch options chain
chain = md.get_options_chain(ticker, expiry_str)
if chain is None:
return None
chain_df = chain["calls"] if strategy == "covered_call" else chain["puts"]
# 5. Pick best strike
best = _best_strike(chain_df, strategy, current_price, nearest_support, nearest_resistance)
if best is None:
logger.debug(f"No qualifying strike for {ticker} {strategy} {time_horizon}")
return None
strike = float(best["strike"])
mid_price = float((best["bid"] + best["ask"]) / 2)
delta = float(best["delta"]) if not math.isnan(best.get("delta", float("nan"))) else _estimate_delta(strike, current_price, strategy)
theta = float(best["theta"]) if not math.isnan(best.get("theta", float("nan"))) else 0.0
# 6. Signal strength
signal_strength = compute_signal_strength(
iv_rank=iv_rank,
trend=trend,
strategy=strategy,
nearest_support=nearest_support,
nearest_resistance=nearest_resistance,
recommended_strike=strike,
earnings_warning=earnings_warning,
)
# 7. Signal hash
sig_hash = compute_signal_hash(
iv_rank=iv_rank,
sma_50=sma_50,
sma_200=sma_200,
nearest_support=nearest_support,
nearest_resistance=nearest_resistance,
recommended_strike=strike,
recommended_expiration=expiry_date,
earnings_warning=earnings_warning,
)
# 8. Build human-readable rationale
rationale = _build_rationale(
strategy=strategy,
time_horizon=time_horizon,
current_price=current_price,
strike=strike,
iv_rank=iv_rank,
trend=trend,
earnings_warning=earnings_warning,
earnings_date=earnings_date,
signal_strength=signal_strength,
)
return Recommendation(
device_id=device_id,
ticker=ticker,
strategy=strategy,
time_horizon=time_horizon,
current_price=current_price,
recommended_strike=strike,
recommended_expiration=expiry_date,
estimated_premium=mid_price,
delta=delta,
theta=theta,
iv_rank=iv_rank,
signal_strength=signal_strength,
earnings_warning=earnings_warning,
earnings_date=earnings_date,
rationale=rationale,
signal_hash=sig_hash,
created_at=datetime.utcnow(),
)
def _estimate_delta(strike: float, current_price: float, strategy: str) -> float:
"""Rough delta estimate when yfinance doesn't provide it."""
moneyness = (strike - current_price) / current_price
if strategy == "covered_call":
return max(0.05, 0.50 - moneyness * 3)
else:
return max(0.05, 0.50 - abs(moneyness) * 3)
def _build_rationale(
strategy: str,
time_horizon: str,
current_price: float,
strike: float,
iv_rank: float,
trend: str,
earnings_warning: bool,
earnings_date: Optional[date],
signal_strength: str,
) -> str:
parts = []
strategy_label = "Covered Call" if strategy == "covered_call" else "Cash-Secured Put"
direction = "above" if strategy == "covered_call" else "below"
parts.append(
f"{signal_strength.capitalize()} {strategy_label} setup. "
f"Strike ${strike:.2f} is {direction} current price ${current_price:.2f}."
)
if iv_rank >= 50:
parts.append(f"IV rank is elevated at {iv_rank:.0f}% — favorable premium-selling environment.")
elif iv_rank >= 30:
parts.append(f"IV rank is moderate at {iv_rank:.0f}%.")
else:
parts.append(f"IV rank is low at {iv_rank:.0f}% — premiums may be thin.")
trend_map = {"uptrend": "bullish uptrend", "downtrend": "bearish downtrend", "sideways": "sideways range"}
parts.append(f"Price is in a {trend_map.get(trend, trend)}.")
if earnings_warning and earnings_date:
parts.append(
f"⚠️ Earnings on {earnings_date} fall within this expiry — elevated risk. Consider a shorter expiry."
)
horizon_map = {"0dte": "0DTE", "1dte": "1DTE", "weekly": "weekly", "monthly": "monthly"}
parts.append(f"Horizon: {horizon_map.get(time_horizon, time_horizon)}.")
return " ".join(parts)

View File

@@ -0,0 +1,274 @@
"""
signal_engine.py — Compute all signals for a ticker.
Signals:
- IV Rank (using rolling 30-day HV as proxy, since yfinance lacks historical IV)
- SMA-50, SMA-200
- Swing-based support / resistance (20-day window)
- Trend direction
- Signal strength score
- Signal hash (for change detection)
"""
import hashlib
import json
import logging
import math
from datetime import date
from typing import Optional
import numpy as np
import pandas as pd
from app.services.market_data import get_price_history, get_earnings_date, get_current_price
from app.models.schemas import SignalSnapshot
logger = logging.getLogger(__name__)
# ─── IV Rank ──────────────────────────────────────────────────────────────────
def compute_iv_rank(df: pd.DataFrame) -> float:
"""
Compute IV Rank (0-100) using a 30-day rolling realized volatility
as a proxy for implied volatility.
Returns the current HV's rank within the past 52-week HV range.
Clamped to [0, 100].
"""
if df is None or len(df) < 32:
return 50.0 # neutral fallback
closes = df["Close"].squeeze()
log_returns = np.log(closes / closes.shift(1)).dropna()
# 30-day rolling std, annualized
hv_series = log_returns.rolling(30).std() * math.sqrt(252) * 100 # as percentage
hv_series = hv_series.dropna()
if len(hv_series) < 2:
return 50.0
window = hv_series.iloc[-252:] # last 52 weeks
hv_low = float(window.min())
hv_high = float(window.max())
current_hv = float(hv_series.iloc[-1])
if hv_high == hv_low:
return 50.0
rank = (current_hv - hv_low) / (hv_high - hv_low) * 100
return max(0.0, min(100.0, rank))
# ─── Moving Averages ──────────────────────────────────────────────────────────
def compute_smas(df: pd.DataFrame) -> dict:
"""Return {'sma_50': float, 'sma_200': float}. Uses NaN if insufficient data."""
closes = df["Close"].squeeze()
sma_50 = float(closes.rolling(50).mean().iloc[-1]) if len(closes) >= 50 else float("nan")
sma_200 = float(closes.rolling(200).mean().iloc[-1]) if len(closes) >= 200 else float("nan")
return {"sma_50": sma_50, "sma_200": sma_200}
def compute_trend(current_price: float, sma_50: float, sma_200: float) -> str:
"""
uptrend — price > sma50 > sma200
downtrend — price < sma50 < sma200
sideways — otherwise
"""
if any(math.isnan(v) for v in [sma_50, sma_200]):
return "sideways"
if current_price > sma_50 > sma_200:
return "uptrend"
if current_price < sma_50 < sma_200:
return "downtrend"
return "sideways"
# ─── Support / Resistance ─────────────────────────────────────────────────────
def compute_swing_levels(df: pd.DataFrame, lookback: int = 20, neighbors: int = 2) -> dict:
"""
Find swing highs (resistance) and swing lows (support) over the last
`lookback` trading days using a `neighbors`-bar pivot rule.
Returns:
{
'nearest_support': float | None,
'nearest_resistance': float | None,
'support_levels': [float],
'resistance_levels': [float],
}
"""
recent = df.tail(lookback + neighbors * 2)
highs = recent["High"].squeeze().values
lows = recent["Low"].squeeze().values
n = len(highs)
swing_highs = []
swing_lows = []
for i in range(neighbors, n - neighbors):
if all(highs[i] > highs[i - j] for j in range(1, neighbors + 1)) and \
all(highs[i] > highs[i + j] for j in range(1, neighbors + 1)):
swing_highs.append(float(highs[i]))
if all(lows[i] < lows[i - j] for j in range(1, neighbors + 1)) and \
all(lows[i] < lows[i + j] for j in range(1, neighbors + 1)):
swing_lows.append(float(lows[i]))
def cluster(levels: list[float], pct: float = 0.005) -> list[float]:
"""Merge levels within pct% of each other into their mean."""
if not levels:
return []
sorted_levels = sorted(levels)
clusters = [[sorted_levels[0]]]
for lvl in sorted_levels[1:]:
if abs(lvl - clusters[-1][-1]) / clusters[-1][-1] <= pct:
clusters[-1].append(lvl)
else:
clusters.append([lvl])
return [sum(c) / len(c) for c in clusters]
resistance_levels = cluster(swing_highs)
support_levels = cluster(swing_lows)
# Get current price for context
current_price = float(df["Close"].squeeze().iloc[-1])
nearest_resistance = min(
(r for r in resistance_levels if r > current_price), default=None
)
nearest_support = max(
(s for s in support_levels if s < current_price), default=None
)
return {
"nearest_support": nearest_support,
"nearest_resistance": nearest_resistance,
"support_levels": support_levels,
"resistance_levels": resistance_levels,
}
# ─── Signal Strength Scoring ──────────────────────────────────────────────────
def compute_signal_strength(
iv_rank: float,
trend: str,
strategy: str,
nearest_support: Optional[float],
nearest_resistance: Optional[float],
recommended_strike: Optional[float],
earnings_warning: bool,
) -> str:
"""
Score the signal quality and return 'strong', 'moderate', or 'weak'.
Scoring:
+2 iv_rank >= 50 (premium-rich environment)
+1 iv_rank >= 30
+1 trend aligned (uptrend for covered_call, not downtrend for csp)
+1 sma alignment bonus (trend == uptrend for CC)
+1 strike positioned well vs nearest level
-2 earnings_warning (dangerous to hold through earnings)
"""
score = 0
if iv_rank >= 50:
score += 2
elif iv_rank >= 30:
score += 1
if strategy == "covered_call":
if trend == "uptrend":
score += 2 # trend aligned (counts as +1 trend + +1 sma bonus)
elif trend == "sideways":
score += 1
if nearest_resistance and recommended_strike and recommended_strike < nearest_resistance:
score += 1
elif strategy == "cash_secured_put":
if trend != "downtrend":
score += 2
if trend == "uptrend":
score += 1 # extra bonus for strong bullish trend on a bullish strategy
if nearest_support and recommended_strike and recommended_strike > nearest_support:
score += 1
if earnings_warning:
score -= 2
if score >= 5:
return "strong"
elif score >= 3:
return "moderate"
else:
return "weak"
# ─── Signal Hash ──────────────────────────────────────────────────────────────
def compute_signal_hash(
iv_rank: float,
sma_50: float,
sma_200: float,
nearest_support: Optional[float],
nearest_resistance: Optional[float],
recommended_strike: Optional[float],
recommended_expiration: Optional[date],
earnings_warning: bool,
) -> str:
"""
16-char deterministic hash of rounded signal inputs.
Only changes when signals shift meaningfully — not on every tick.
"""
payload = {
"ivr": round(iv_rank, 1),
"sma50": round(sma_50, 2) if sma_50 and not math.isnan(sma_50) else None,
"sma200": round(sma_200, 2) if sma_200 and not math.isnan(sma_200) else None,
"support": round(nearest_support, 2) if nearest_support else None,
"resistance": round(nearest_resistance, 2) if nearest_resistance else None,
"strike": recommended_strike,
"expiry": str(recommended_expiration) if recommended_expiration else None,
"ew": earnings_warning,
}
raw = json.dumps(payload, sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()[:16]
# ─── Full Signal Computation ──────────────────────────────────────────────────
def compute_signals(ticker: str) -> Optional[SignalSnapshot]:
"""
Compute and return a full SignalSnapshot for a ticker.
Returns None if market data is unavailable.
"""
from datetime import datetime
df = get_price_history(ticker)
if df is None or df.empty:
logger.warning(f"No price history for {ticker} — cannot compute signals")
return None
current_price = get_current_price(ticker)
if current_price is None:
current_price = float(df["Close"].squeeze().iloc[-1])
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 = 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(),
)

View File

View File

@@ -0,0 +1,102 @@
from datetime import date, timedelta
import pandas_market_calendars as _mcal # optional dep — fall back gracefully
from typing import Optional
import logging
logger = logging.getLogger(__name__)
_NYSE_CAL = None
def _get_nyse():
global _NYSE_CAL
if _NYSE_CAL is None:
try:
import pandas_market_calendars as mcal
_NYSE_CAL = mcal.get_calendar("NYSE")
except ImportError:
pass
return _NYSE_CAL
def is_trading_day(d: date) -> bool:
"""Return True if d is a NYSE trading day."""
cal = _get_nyse()
if cal is None:
return d.weekday() < 5
import pandas as pd
schedule = cal.schedule(start_date=str(d), end_date=str(d))
return not schedule.empty
def next_trading_day(d: date) -> date:
"""Return the next trading day after d."""
candidate = d + timedelta(days=1)
while not is_trading_day(candidate):
candidate += timedelta(days=1)
return candidate
def next_friday(from_date: Optional[date] = None) -> date:
"""Return the next Friday on or after from_date."""
d = from_date or date.today()
days_ahead = 4 - d.weekday() # Friday is weekday 4
if days_ahead < 0:
days_ahead += 7
elif days_ahead == 0:
pass # today is Friday
return d + timedelta(days=days_ahead)
def nearest_monthly_expiry(from_date: Optional[date] = None, target_dte: int = 30) -> date:
"""Return the standard monthly expiry (third Friday) closest to target_dte from from_date."""
d = from_date or date.today()
target = d + timedelta(days=target_dte)
# Find third Friday of that month
year, month = target.year, target.month
third_friday = _third_friday(year, month)
# If the third Friday of target month is already past relative to today, advance one month
if third_friday <= d:
if month == 12:
year += 1
month = 1
else:
month += 1
third_friday = _third_friday(year, month)
return third_friday
def _third_friday(year: int, month: int) -> date:
"""Return the third Friday of the given month."""
first = date(year, month, 1)
# Find first Friday
days_to_friday = (4 - first.weekday()) % 7
first_friday = first + timedelta(days=days_to_friday)
return first_friday + timedelta(weeks=2)
def market_is_open_now() -> bool:
"""Best-effort check: is the US market currently open (9:3016:00 ET)?"""
from datetime import datetime
import zoneinfo
now_et = datetime.now(tz=zoneinfo.ZoneInfo("America/New_York"))
if not is_trading_day(now_et.date()):
return False
open_time = now_et.replace(hour=9, minute=30, second=0, microsecond=0)
close_time = now_et.replace(hour=16, minute=0, second=0, microsecond=0)
return open_time <= now_et <= close_time
def within_dte_window_for_0dte() -> bool:
"""True if it's a trading day and between 9:30 AM and 2:00 PM ET."""
from datetime import datetime
import zoneinfo
now_et = datetime.now(tz=zoneinfo.ZoneInfo("America/New_York"))
if not is_trading_day(now_et.date()):
return False
open_time = now_et.replace(hour=9, minute=30, second=0, microsecond=0)
cutoff = now_et.replace(hour=14, minute=0, second=0, microsecond=0)
return open_time <= now_et <= cutoff

View File

@@ -0,0 +1,10 @@
"""Run this once to initialize the SQLite database schema."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.database import init_db
if __name__ == "__main__":
init_db()
print("Database initialized.")

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
yfinance==0.2.50
pandas==2.2.3
numpy==2.2.1
apscheduler==3.10.4
httpx==0.28.1
PyJWT==2.10.1
cryptography==44.0.0
python-dotenv==1.0.1
pydantic==2.10.4
pydantic-settings==2.7.0
pytest==8.3.4
pytest-asyncio==0.25.0
httpx[test]

View File

View File

@@ -0,0 +1,4 @@
"""Pytest configuration — sets asyncio mode."""
import pytest
pytest_plugins = ["pytest_asyncio"]

220
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,220 @@
"""
Integration tests for the FastAPI API layer.
Uses TestClient with an in-memory SQLite DB — no live market data.
"""
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from app.main import app
# ─── Test DB setup ─────────────────────────────────────────────────────────────
TEST_DB_URL = "sqlite://" # in-memory
test_engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
def override_get_db():
db = TestSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=test_engine)
yield
Base.metadata.drop_all(bind=test_engine)
app.dependency_overrides[get_db] = override_get_db
# Disable scheduler during tests
with patch("app.main.start_scheduler"), patch("app.main.stop_scheduler"):
client = TestClient(app, raise_server_exceptions=True)
FAKE_TOKEN = "abc123device0000000000000000000000000000000000000000000000000000"
# ─── Device registration ───────────────────────────────────────────────────────
def test_register_device():
resp = client.post("/api/v1/devices/register", json={"apns_token": FAKE_TOKEN, "device_name": "Test iPhone"})
assert resp.status_code == 200
data = resp.json()
assert data["apns_token"] == FAKE_TOKEN
assert "id" in data
def test_register_device_idempotent():
client.post("/api/v1/devices/register", json={"apns_token": FAKE_TOKEN})
resp = client.post("/api/v1/devices/register", json={"apns_token": FAKE_TOKEN})
assert resp.status_code == 200
# ─── Portfolio ─────────────────────────────────────────────────────────────────
@pytest.fixture
def registered_device():
client.post("/api/v1/devices/register", json={"apns_token": FAKE_TOKEN})
return FAKE_TOKEN
def test_add_portfolio(registered_device):
resp = client.post(
"/api/v1/portfolio",
json=[{"ticker": "AAPL", "shares": 100}, {"ticker": "MSFT", "shares": 200}],
headers={"X-Device-Token": registered_device},
)
assert resp.status_code == 200
tickers = [p["ticker"] for p in resp.json()]
assert "AAPL" in tickers
assert "MSFT" in tickers
def test_get_portfolio_empty(registered_device):
resp = client.get("/api/v1/portfolio", headers={"X-Device-Token": registered_device})
assert resp.status_code == 200
assert resp.json() == []
def test_get_portfolio_after_add(registered_device):
client.post(
"/api/v1/portfolio",
json=[{"ticker": "NVDA", "shares": 50}],
headers={"X-Device-Token": registered_device},
)
resp = client.get("/api/v1/portfolio", headers={"X-Device-Token": registered_device})
assert resp.status_code == 200
assert resp.json()[0]["ticker"] == "NVDA"
def test_delete_ticker(registered_device):
client.post(
"/api/v1/portfolio",
json=[{"ticker": "AMD", "shares": 100}],
headers={"X-Device-Token": registered_device},
)
resp = client.delete("/api/v1/portfolio/AMD", headers={"X-Device-Token": registered_device})
assert resp.status_code == 204
remaining = client.get("/api/v1/portfolio", headers={"X-Device-Token": registered_device}).json()
assert all(p["ticker"] != "AMD" for p in remaining)
def test_portfolio_unregistered_device():
resp = client.get("/api/v1/portfolio", headers={"X-Device-Token": "nonexistent_token"})
assert resp.status_code == 404
# ─── Option Positions ──────────────────────────────────────────────────────────
def test_log_position(registered_device):
expiry = str(date.today() + timedelta(days=14))
resp = client.post(
"/api/v1/positions",
json={
"ticker": "AAPL",
"strategy": "covered_call",
"strike": 195.0,
"expiration": expiry,
"premium_received": 2.50,
"contracts": 1,
},
headers={"X-Device-Token": registered_device},
)
assert resp.status_code == 201
data = resp.json()
assert data["ticker"] == "AAPL"
assert data["status"] == "open"
assert data["id"] is not None
def test_close_position(registered_device):
expiry = str(date.today() + timedelta(days=14))
pos = client.post(
"/api/v1/positions",
json={
"ticker": "AAPL",
"strategy": "covered_call",
"strike": 195.0,
"expiration": expiry,
"premium_received": 2.50,
},
headers={"X-Device-Token": registered_device},
).json()
resp = client.patch(
f"/api/v1/positions/{pos['id']}",
json={"status": "closed", "close_reason": "bought_back"},
headers={"X-Device-Token": registered_device},
)
assert resp.status_code == 200
assert resp.json()["status"] == "closed"
def test_get_open_positions_filter(registered_device):
expiry = str(date.today() + timedelta(days=14))
client.post(
"/api/v1/positions",
json={"ticker": "TSLA", "strategy": "cash_secured_put", "strike": 200.0, "expiration": expiry, "premium_received": 3.0},
headers={"X-Device-Token": registered_device},
)
resp = client.get("/api/v1/positions?status=open", headers={"X-Device-Token": registered_device})
assert resp.status_code == 200
assert len(resp.json()) >= 1
assert all(p["status"] == "open" for p in resp.json())
# ─── Alerts ────────────────────────────────────────────────────────────────────
def test_get_alerts_empty(registered_device):
resp = client.get("/api/v1/alerts", headers={"X-Device-Token": registered_device})
assert resp.status_code == 200
assert resp.json() == []
def test_acknowledge_alert(registered_device):
from app.models.db_models import Alert
from datetime import datetime
db = TestSessionLocal()
device_id = client.post("/api/v1/devices/register", json={"apns_token": FAKE_TOKEN}).json()["id"]
alert = Alert(
device_id=device_id,
ticker="AAPL",
alert_type="close_early",
message="Test alert",
sent_at=datetime.utcnow(),
acknowledged=False,
)
db.add(alert)
db.commit()
db.refresh(alert)
alert_id = alert.id
db.close()
resp = client.patch(
f"/api/v1/alerts/{alert_id}/acknowledge",
headers={"X-Device-Token": FAKE_TOKEN},
)
assert resp.status_code == 200
assert resp.json()["acknowledged"] is True
# ─── Health ────────────────────────────────────────────────────────────────────
def test_health():
with patch("app.routers"), patch("app.services.position_monitor.last_run", None):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"

View File

@@ -0,0 +1,267 @@
"""Unit tests for signal_engine.py — all run without live market data."""
import math
from datetime import date, timedelta
from unittest.mock import patch
import numpy as np
import pandas as pd
import pytest
from app.services.signal_engine import (
compute_iv_rank,
compute_smas,
compute_swing_levels,
compute_trend,
compute_signal_strength,
compute_signal_hash,
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _make_price_df(closes: list[float]) -> pd.DataFrame:
"""Create a minimal OHLCV DataFrame from a list of close prices."""
n = len(closes)
dates = pd.date_range(end="2024-01-31", periods=n, freq="B")
arr = np.array(closes)
df = pd.DataFrame(
{
"Open": arr * 0.999,
"High": arr * 1.005,
"Low": arr * 0.995,
"Close": arr,
"Volume": np.ones(n) * 1_000_000,
},
index=dates,
)
return df
def _trending_prices(start: float, end: float, n: int = 252) -> list[float]:
return list(np.linspace(start, end, n))
# ─── IV Rank ──────────────────────────────────────────────────────────────────
def test_iv_rank_high_returns_above_50():
"""When current volatility is near the 52-week high, IVR should be > 50."""
# Create prices that spike at the end (high recent volatility)
stable = [100.0] * 220
volatile = list(np.linspace(100, 130, 16)) + list(np.linspace(130, 85, 16))
closes = stable + volatile
df = _make_price_df(closes)
ivr = compute_iv_rank(df)
assert ivr > 50, f"Expected ivr > 50, got {ivr:.1f}"
def test_iv_rank_low_returns_below_50():
"""Flat price series → low historical volatility → IVR near 0."""
closes = [100.0] * 252
df = _make_price_df(closes)
ivr = compute_iv_rank(df)
assert ivr <= 50, f"Expected ivr <= 50, got {ivr:.1f}"
def test_iv_rank_insufficient_data_returns_neutral():
"""Too few data points should return the 50.0 neutral fallback."""
df = _make_price_df([100.0] * 20)
ivr = compute_iv_rank(df)
assert ivr == 50.0
def test_iv_rank_clamped_to_0_100():
closes = [100.0] * 252
df = _make_price_df(closes)
ivr = compute_iv_rank(df)
assert 0.0 <= ivr <= 100.0
# ─── SMAs ─────────────────────────────────────────────────────────────────────
def test_sma_50_correct_value():
closes = list(range(1, 253)) # 1..252
df = _make_price_df(closes)
smas = compute_smas(df)
expected_sma50 = sum(range(203, 253)) / 50 # last 50 values: 203-252
assert abs(smas["sma_50"] - expected_sma50) < 0.01
def test_sma_200_nan_when_insufficient_data():
df = _make_price_df([100.0] * 100)
smas = compute_smas(df)
assert math.isnan(smas["sma_200"])
def test_sma_50_nan_when_insufficient_data():
df = _make_price_df([100.0] * 30)
smas = compute_smas(df)
assert math.isnan(smas["sma_50"])
# ─── Trend ────────────────────────────────────────────────────────────────────
def test_trend_uptrend():
assert compute_trend(110.0, 105.0, 100.0) == "uptrend"
def test_trend_downtrend():
assert compute_trend(90.0, 95.0, 100.0) == "downtrend"
def test_trend_sideways_mixed():
# price > sma50 but sma50 < sma200
assert compute_trend(106.0, 105.0, 107.0) == "sideways"
def test_trend_sideways_with_nan_smas():
assert compute_trend(100.0, float("nan"), float("nan")) == "sideways"
# ─── Support / Resistance ─────────────────────────────────────────────────────
def test_swing_levels_detects_swing_high():
"""A clear peak in the middle of the window should be detected as resistance."""
closes = [100.0] * 25
highs = [100.0] * 25
lows = [98.0] * 25
# Create a swing high at index 12
highs[12] = 108.0
closes[12] = 107.0
dates = pd.date_range(end="2024-01-31", periods=25, freq="B")
df = pd.DataFrame({"Open": closes, "High": highs, "Low": lows, "Close": closes, "Volume": [1e6] * 25}, index=dates)
result = compute_swing_levels(df, lookback=20)
assert result["nearest_resistance"] is not None
assert abs(result["nearest_resistance"] - 108.0) < 1.0
def test_swing_levels_detects_swing_low():
"""A clear trough should be detected as support."""
closes = [100.0] * 25
highs = [102.0] * 25
lows = [98.0] * 25
lows[12] = 90.0
closes[12] = 91.0
dates = pd.date_range(end="2024-01-31", periods=25, freq="B")
df = pd.DataFrame({"Open": closes, "High": highs, "Low": lows, "Close": closes, "Volume": [1e6] * 25}, index=dates)
result = compute_swing_levels(df, lookback=20)
assert result["nearest_support"] is not None
assert result["nearest_support"] < 100.0
def test_swing_levels_flat_no_crash():
"""Flat price should return None for support/resistance without crashing."""
df = _make_price_df([100.0] * 30)
result = compute_swing_levels(df, lookback=20)
# No assertion on values — just shouldn't raise
assert "nearest_support" in result
assert "nearest_resistance" in result
# ─── Signal Strength ──────────────────────────────────────────────────────────
def test_signal_strength_strong_covered_call():
strength = compute_signal_strength(
iv_rank=70.0,
trend="uptrend",
strategy="covered_call",
nearest_support=95.0,
nearest_resistance=110.0,
recommended_strike=107.0,
earnings_warning=False,
)
assert strength == "strong"
def test_signal_strength_earnings_warning_downgrades():
strength_no_ew = compute_signal_strength(
iv_rank=70.0, trend="uptrend", strategy="covered_call",
nearest_support=95.0, nearest_resistance=110.0,
recommended_strike=107.0, earnings_warning=False,
)
strength_ew = compute_signal_strength(
iv_rank=70.0, trend="uptrend", strategy="covered_call",
nearest_support=95.0, nearest_resistance=110.0,
recommended_strike=107.0, earnings_warning=True,
)
assert strength_ew in ("weak", "moderate")
assert strength_no_ew == "strong"
def test_signal_strength_low_ivr_gives_weak():
strength = compute_signal_strength(
iv_rank=10.0,
trend="downtrend",
strategy="covered_call",
nearest_support=None,
nearest_resistance=None,
recommended_strike=None,
earnings_warning=True,
)
assert strength == "weak"
def test_signal_strength_csp_uptrend_bonus():
strength = compute_signal_strength(
iv_rank=55.0,
trend="uptrend",
strategy="cash_secured_put",
nearest_support=95.0,
nearest_resistance=110.0,
recommended_strike=97.0,
earnings_warning=False,
)
assert strength in ("strong", "moderate")
# ─── Signal Hash ──────────────────────────────────────────────────────────────
def test_signal_hash_stability():
"""Same inputs must always produce the same hash."""
args = dict(
iv_rank=45.5,
sma_50=150.0,
sma_200=145.0,
nearest_support=148.0,
nearest_resistance=155.0,
recommended_strike=153.0,
recommended_expiration=date(2024, 3, 15),
earnings_warning=False,
)
h1 = compute_signal_hash(**args)
h2 = compute_signal_hash(**args)
assert h1 == h2
assert len(h1) == 16
def test_signal_hash_changes_on_strike_change():
base = dict(
iv_rank=45.5, sma_50=150.0, sma_200=145.0,
nearest_support=148.0, nearest_resistance=155.0,
recommended_strike=153.0, recommended_expiration=date(2024, 3, 15),
earnings_warning=False,
)
h1 = compute_signal_hash(**base)
base["recommended_strike"] = 155.0
h2 = compute_signal_hash(**base)
assert h1 != h2
def test_signal_hash_stable_on_noise():
"""Tiny IV rank change (< 0.1) should produce same hash due to rounding."""
base = dict(
iv_rank=45.500,
sma_50=150.01, sma_200=145.009,
nearest_support=148.001, nearest_resistance=155.002,
recommended_strike=153.0, recommended_expiration=date(2024, 3, 15),
earnings_warning=False,
)
tweaked = dict(base)
tweaked["iv_rank"] = 45.503 # < 0.1 change → rounds to same 45.5
h1 = compute_signal_hash(**base)
h2 = compute_signal_hash(**tweaked)
assert h1 == h2

View File

@@ -0,0 +1,59 @@
import UIKit
import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
NotificationHandler.shared.setup()
return true
}
// Called after user grants permission and iOS assigns a device token
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
LocalStore.shared.deviceToken = token
Task {
await registerDevice(token: token)
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("APNs registration failed: \(error.localizedDescription)")
}
// Private
private func registerDevice(token: String) async {
struct DeviceRegisterBody: Encodable {
let apns_token: String
let device_name: String?
}
struct DeviceResponse: Decodable {
let id: Int
}
do {
let body = DeviceRegisterBody(
apns_token: token,
device_name: UIDevice.current.name
)
// Temporarily set the token so APIClient has it, but use requestNoAuth
// since this is the registration call itself
let old = LocalStore.shared.deviceToken
LocalStore.shared.deviceToken = token
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
.registerDevice,
body: body
)
LocalStore.shared.deviceId = response.id
print("Device registered with id: \(response.id)")
} catch {
print("Device registration failed: \(error.localizedDescription)")
}
}
}

View File

@@ -0,0 +1,26 @@
import SwiftUI
enum Constants {
// API
/// Change this to your deployed Railway/Render URL before shipping.
/// For local dev, use your Mac's LAN IP so the simulator can reach it.
static let baseURL = "http://localhost:8000"
static let apiPrefix = "/api/v1"
static var apiBaseURL: String { baseURL + apiPrefix }
// Colors
enum Color {
static let strong = SwiftUI.Color.green
static let moderate = SwiftUI.Color.yellow
static let weak = SwiftUI.Color(red: 0.9, green: 0.4, blue: 0.2)
static let accent = SwiftUI.Color.blue
static let destructive = SwiftUI.Color.red
static let warning = SwiftUI.Color.orange
}
// App
static let appName = "Options Sidekick"
static let notificationCategory = "POSITION_ALERT"
static let notificationActionView = "VIEW_POSITION"
}

View File

@@ -0,0 +1,33 @@
import Foundation
struct AppAlert: Codable, Identifiable, Hashable {
let id: Int
let ticker: String
let optionPositionId: Int?
let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning"
let message: String
let sentAt: Date
var acknowledged: Bool
enum CodingKeys: String, CodingKey {
case id, ticker, message, acknowledged
case optionPositionId = "option_position_id"
case alertType = "alert_type"
case sentAt = "sent_at"
}
var typeLabel: String {
switch alertType {
case "close_early": return "Close Early"
case "roll_out": return "Roll Out"
case "roll_up_down": return "Roll Strike"
case "earnings_warning": return "Earnings Warning"
case "new_rec": return "New Recommendation"
default: return alertType.replacingOccurrences(of: "_", with: " ").capitalized
}
}
var isUrgent: Bool {
alertType == "close_early" || alertType == "earnings_warning"
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
struct OptionPosition: Codable, Identifiable, Hashable {
let id: Int
let ticker: String
let strategy: String // "covered_call" | "cash_secured_put"
let strike: Double
let expiration: String // ISO date string "YYYY-MM-DD"
let premiumReceived: Double
let contracts: Int
let status: String // "open" | "closed" | "rolled"
let closeReason: String?
let openedAt: Date
let closedAt: Date?
let lastSignalHash: String?
enum CodingKeys: String, CodingKey {
case id, ticker, strategy, strike, expiration, contracts, status
case premiumReceived = "premium_received"
case closeReason = "close_reason"
case openedAt = "opened_at"
case closedAt = "closed_at"
case lastSignalHash = "last_signal_hash"
}
var strategyLabel: String {
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
}
var expirationDate: Date? {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: expiration)
}
var daysToExpiry: Int? {
guard let exp = expirationDate else { return nil }
let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day
return days
}
var totalCredit: Double {
premiumReceived * Double(contracts) * 100
}
}
struct OptionPositionCreate: Codable {
let ticker: String
let strategy: String
let strike: Double
let expiration: String
let premiumReceived: Double
let contracts: Int
enum CodingKeys: String, CodingKey {
case ticker, strategy, strike, expiration, contracts
case premiumReceived = "premium_received"
}
}
struct OptionPositionClose: Codable {
let status: String
let closeReason: String?
enum CodingKeys: String, CodingKey {
case status
case closeReason = "close_reason"
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
struct PortfolioPosition: Codable, Identifiable, Hashable {
let id: Int
let ticker: String
let shares: Int
let costBasis: Double?
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, ticker, shares
case costBasis = "cost_basis"
case createdAt = "created_at"
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
struct Recommendation: Codable, Identifiable, Hashable {
let id: Int
let ticker: String
let strategy: String
let timeHorizon: String
let currentPrice: Double
let recommendedStrike: Double
let recommendedExpiration: String // ISO date "YYYY-MM-DD"
let estimatedPremium: Double
let delta: Double
let theta: Double
let ivRank: Double
let signalStrength: String // "strong" | "moderate" | "weak"
let earningsWarning: Bool
let earningsDate: String?
let rationale: String
let signalHash: String
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, ticker, strategy, rationale
case timeHorizon = "time_horizon"
case currentPrice = "current_price"
case recommendedStrike = "recommended_strike"
case recommendedExpiration = "recommended_expiration"
case estimatedPremium = "estimated_premium"
case delta, theta
case ivRank = "iv_rank"
case signalStrength = "signal_strength"
case earningsWarning = "earnings_warning"
case earningsDate = "earnings_date"
case signalHash = "signal_hash"
case createdAt = "created_at"
}
var strategyLabel: String {
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
}
var horizonLabel: String {
switch timeHorizon {
case "0dte": return "0DTE"
case "1dte": return "1DTE"
case "weekly": return "Weekly"
case "monthly": return "Monthly"
default: return timeHorizon.capitalized
}
}
var annualizedPremiumPct: Double {
guard currentPrice > 0 else { return 0 }
let daysToExpiry = expirationDate.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30 } ?? 30
let dailyReturn = estimatedPremium / currentPrice
return dailyReturn * (365.0 / max(1, Double(daysToExpiry))) * 100
}
var expirationDate: Date? {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: recommendedExpiration)
}
}
struct SignalSnapshot: Codable {
let ticker: String
let currentPrice: Double
let ivRank: Double
let sma50: Double
let sma200: Double
let nearestSupport: Double?
let nearestResistance: Double?
let trend: String
let earningsDate: String?
let computedAt: Date
enum CodingKeys: String, CodingKey {
case ticker
case currentPrice = "current_price"
case ivRank = "iv_rank"
case sma50 = "sma_50"
case sma200 = "sma_200"
case nearestSupport = "nearest_support"
case nearestResistance = "nearest_resistance"
case trend
case earningsDate = "earnings_date"
case computedAt = "computed_at"
}
}
struct RecommendationWithSignals: Codable {
let recommendation: Recommendation
let signals: SignalSnapshot
}

View File

@@ -0,0 +1,144 @@
import Foundation
/// Central networking client.
/// Adds X-Device-Token header automatically from LocalStore.
/// All requests are async/await.
final class APIClient {
static let shared = APIClient()
private init() {}
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
return URLSession(configuration: config)
}()
private var decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// Try ISO8601 with fractional seconds first, then without
let formatters: [ISO8601DateFormatter] = [
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}(),
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}(),
]
for fmt in formatters {
if let date = fmt.date(from: str) { return date }
}
// Try plain date (YYYY-MM-DD) for date-only fields decoded as Date
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: str) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot parse date: \(str)"))
}
return d
}()
private var encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
// Core request builder
func request<T: Decodable>(
_ endpoint: Endpoint,
body: (some Encodable)? = Optional<EmptyBody>.none
) async throws -> T {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
/// Version that doesn't return a body (e.g. DELETE 204)
func requestVoid(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
}
/// For device registration (no token yet)
func requestNoAuth<T: Decodable>(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws -> T {
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
// Helpers
private func buildRequest(_ endpoint: Endpoint, deviceToken: String?, body: (some Encodable)?) throws -> URLRequest {
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = deviceToken {
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
}
if let body {
request.httpBody = try encoder.encode(body)
}
return request
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
break
case 404:
throw APIError.notFound
case 503:
throw APIError.serviceUnavailable
default:
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
throw APIError.serverError(httpResponse.statusCode, message)
}
}
}
// Supporting types
private struct EmptyBody: Encodable {}
enum APIError: LocalizedError {
case noDeviceToken
case invalidURL
case invalidResponse
case notFound
case serviceUnavailable
case serverError(Int, String)
var errorDescription: String? {
switch self {
case .noDeviceToken: return "Device not registered. Please restart the app."
case .invalidURL: return "Invalid API URL."
case .invalidResponse: return "Invalid response from server."
case .notFound: return "Resource not found."
case .serviceUnavailable: return "Market data unavailable. Try again shortly."
case .serverError(let code, let msg): return "Server error \(code): \(msg)"
}
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
struct Endpoint {
let path: String
let method: String
// Devices
static let registerDevice = Endpoint(path: "/devices/register", method: "POST")
// Portfolio
static let getPortfolio = Endpoint(path: "/portfolio", method: "GET")
static let setPortfolio = Endpoint(path: "/portfolio", method: "POST")
static func deleteTicker(_ ticker: String) -> Endpoint {
Endpoint(path: "/portfolio/\(ticker)", method: "DELETE")
}
// Recommendations
static func getRecommendations(timeHorizon: String? = nil) -> Endpoint {
let query = timeHorizon.map { "?time_horizon=\($0)" } ?? ""
return Endpoint(path: "/recommendations\(query)", method: "GET")
}
static func getRecommendation(ticker: String, strategy: String, timeHorizon: String) -> Endpoint {
Endpoint(path: "/recommendations/\(ticker)?strategy=\(strategy)&time_horizon=\(timeHorizon)", method: "GET")
}
static let refreshRecommendations = Endpoint(path: "/recommendations/refresh", method: "POST")
// Positions
static func getPositions(status: String? = nil) -> Endpoint {
let query = status.map { "?status=\($0)" } ?? ""
return Endpoint(path: "/positions\(query)", method: "GET")
}
static let logPosition = Endpoint(path: "/positions", method: "POST")
static func closePosition(_ id: Int) -> Endpoint {
Endpoint(path: "/positions/\(id)", method: "PATCH")
}
// Signals
static func getSignals(_ ticker: String) -> Endpoint {
Endpoint(path: "/signals/\(ticker)", method: "GET")
}
// Alerts
static func getAlerts(unreadOnly: Bool = false) -> Endpoint {
let query = unreadOnly ? "?unread_only=true" : ""
return Endpoint(path: "/alerts\(query)", method: "GET")
}
static func acknowledgeAlert(_ id: Int) -> Endpoint {
Endpoint(path: "/alerts/\(id)/acknowledge", method: "PATCH")
}
// Health
static let health = Endpoint(path: "/health", method: "GET")
}

View File

@@ -0,0 +1,47 @@
import Foundation
import UserNotifications
/// Handles incoming push notifications both foreground and background tap.
@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NotificationHandler()
/// Published so views can react to a deep-link navigation request.
@Published var navigateToPositionId: Int? = nil
@Published var inAppAlertMessage: String? = nil
private override init() {
super.init()
}
func setup() {
UNUserNotificationCenter.current().delegate = self
}
// Foreground notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
if let message = notification.request.content.body as String? {
inAppAlertMessage = message
}
// Still show the banner even when foregrounded so user doesn't miss it
completionHandler([.banner, .sound, .badge])
}
// Background tap / action tap
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
if let positionId = userInfo["position_id"] as? Int {
navigateToPositionId = positionId
}
completionHandler()
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
import UserNotifications
@MainActor
final class NotificationPermissions {
static let shared = NotificationPermissions()
private init() {}
func requestIfNeeded() async {
guard !LocalStore.shared.notificationPermissionRequested else { return }
LocalStore.shared.notificationPermissionRequested = true
let center = UNUserNotificationCenter.current()
// Register a custom category with a "View" action for deep linking
let viewAction = UNNotificationAction(
identifier: Constants.notificationActionView,
title: "View Position",
options: [.foreground]
)
let category = UNNotificationCategory(
identifier: Constants.notificationCategory,
actions: [viewAction],
intentIdentifiers: [],
options: []
)
center.setNotificationCategories([category])
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
} catch {
print("Notification permission error: \(error)")
}
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct OptionsSidekickApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(NotificationHandler.shared)
}
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
/// Lightweight UserDefaults wrapper for device-local state.
final class LocalStore {
static let shared = LocalStore()
private init() {}
private let defaults = UserDefaults.standard
// APNs device token
var deviceToken: String? {
get { defaults.string(forKey: "apns_device_token") }
set { defaults.set(newValue, forKey: "apns_device_token") }
}
var deviceId: Int? {
get {
let v = defaults.integer(forKey: "device_id")
return v == 0 ? nil : v
}
set { defaults.set(newValue, forKey: "device_id") }
}
// Notification permission
var notificationPermissionRequested: Bool {
get { defaults.bool(forKey: "notification_permission_requested") }
set { defaults.set(newValue, forKey: "notification_permission_requested") }
}
// Unread alert badge count
var unreadAlertCount: Int {
get { defaults.integer(forKey: "unread_alert_count") }
set { defaults.set(newValue, forKey: "unread_alert_count") }
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Options Sidekick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
final class AlertsViewModel: ObservableObject {
@Published var alerts: [AppAlert] = []
@Published var isLoading = false
@Published var error: String? = nil
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
func load(unreadOnly: Bool = false) async {
isLoading = true
error = nil
do {
alerts = try await APIClient.shared.request(
.getAlerts(unreadOnly: unreadOnly),
body: Optional<String>.none
)
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func acknowledge(_ alert: AppAlert) async {
do {
let updated: AppAlert = try await APIClient.shared.request(
.acknowledgeAlert(alert.id),
body: Optional<String>.none
)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated
}
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
}
func acknowledgeAll() async {
for alert in alerts where !alert.acknowledged {
await acknowledge(alert)
}
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
import Combine
@MainActor
final class DashboardViewModel: ObservableObject {
@Published var stockPositions: [PortfolioPosition] = []
@Published var openOptionPositions: [OptionPosition] = []
@Published var recommendations: [Recommendation] = []
@Published var unreadAlerts: [AppAlert] = []
@Published var isLoading = false
@Published var error: String? = nil
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
/// Top-level recommendation per ticker (best signal strength)
var topRecommendations: [Recommendation] {
let order = ["strong": 0, "moderate": 1, "weak": 2]
var best: [String: Recommendation] = [:]
for rec in recommendations {
let current = best[rec.ticker]
if current == nil || (order[rec.signalStrength] ?? 2) < (order[current!.signalStrength] ?? 2) {
best[rec.ticker] = rec
}
}
return Array(best.values).sorted { $0.ticker < $1.ticker }
}
func loadAll() async {
isLoading = true
error = nil
async let stocks: [PortfolioPosition] = loadStocks()
async let options: [OptionPosition] = loadOptions()
async let recs: [Recommendation] = loadRecommendations()
async let alerts: [AppAlert] = loadAlerts()
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
isLoading = false
}
func refresh() async {
await loadAll()
}
// Private loaders
private func loadStocks() async -> [PortfolioPosition] {
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
}
private func loadOptions() async -> [OptionPosition] {
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
}
private func loadRecommendations() async -> [Recommendation] {
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
}
private func loadAlerts() async -> [AppAlert] {
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
}
}

View File

@@ -0,0 +1,67 @@
import Foundation
@MainActor
final class PortfolioViewModel: ObservableObject {
@Published var positions: [PortfolioPosition] = []
@Published var isLoading = false
@Published var error: String? = nil
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func save(_ newPositions: [PortfolioPosition]) async {
isLoading = true
error = nil
struct PositionBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let body = newPositions.map { PositionBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
do {
positions = try await APIClient.shared.request(.setPortfolio, body: body)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func add(ticker: String, shares: Int, costBasis: Double?) async {
var updated = positions
struct AddBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let allBody = updated.map { AddBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
+ [AddBody(ticker: ticker.uppercased(), shares: shares, cost_basis: costBasis)]
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.setPortfolio, body: allBody)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func delete(ticker: String) async {
isLoading = true
error = nil
do {
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
positions.removeAll { $0.ticker == ticker }
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
@MainActor
final class PositionsViewModel: ObservableObject {
@Published var positions: [OptionPosition] = []
@Published var isLoading = false
@Published var error: String? = nil
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(
.getPositions(status: nil),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func log(create: OptionPositionCreate) async -> Bool {
isLoading = true
error = nil
do {
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
positions.insert(new, at: 0)
isLoading = false
return true
} catch {
self.error = error.localizedDescription
isLoading = false
return false
}
}
func close(position: OptionPosition, reason: String) async {
isLoading = true
error = nil
let body = OptionPositionClose(status: "closed", closeReason: reason)
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func roll(position: OptionPosition) async {
let body = OptionPositionClose(status: "rolled", closeReason: "rolled")
isLoading = true
error = nil
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
@MainActor
final class RecommendationsViewModel: ObservableObject {
@Published var recommendations: [Recommendation] = []
@Published var isLoading = false
@Published var isRefreshing = false
@Published var error: String? = nil
@Published var selectedHorizon: String = "weekly"
@Published var selectedStrategy: String = "covered_call"
var filtered: [Recommendation] {
recommendations.filter {
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
}
}
func load() async {
isLoading = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.getRecommendations(timeHorizon: nil),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func refresh() async {
isRefreshing = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.refreshRecommendations,
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isRefreshing = false
}
func getDetail(ticker: String) async -> RecommendationWithSignals? {
do {
return try await APIClient.shared.request(
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
return nil
}
}
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
struct AlertsView: View {
@StateObject private var vm = AlertsViewModel()
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.alerts.isEmpty {
LoadingView(message: "Loading alerts...")
} else if vm.alerts.isEmpty {
EmptyStateView(
icon: "bell.slash",
title: "No alerts",
subtitle: "Alerts appear here when signal changes on your open positions."
)
} else {
List(vm.alerts) { alert in
AlertRowView(alert: alert) {
Task { await vm.acknowledge(alert) }
}
}
.listStyle(.insetGrouped)
.refreshable { await vm.load() }
}
}
.navigationTitle("Alerts")
.toolbar {
if vm.unreadCount > 0 {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Mark All Read") {
Task { await vm.acknowledgeAll() }
}
.font(.caption)
}
}
}
}
.task { await vm.load() }
}
}
// Row
struct AlertRowView: View {
let alert: AppAlert
let onAcknowledge: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
// Unread indicator
Circle()
.fill(alert.acknowledged ? Color.clear : Constants.Color.accent)
.frame(width: 8, height: 8)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(alert.ticker)
.font(.headline)
AlertTypeBadge(alertType: alert.alertType)
}
Text(alert.message)
.font(.subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(RelativeDateTimeFormatter().localizedString(for: alert.sentAt, relativeTo: Date()))
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
if !alert.acknowledged {
Button {
onAcknowledge()
} label: {
Image(systemName: "checkmark.circle")
.foregroundStyle(Constants.Color.accent)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
.opacity(alert.acknowledged ? 0.6 : 1.0)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 12) {
ProgressView()
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ErrorView: View {
let message: String
let retry: () async -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(Constants.Color.destructive)
Text(message)
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
Button("Retry") {
Task { await retry() }
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(.quaternary)
Text(title)
.font(.headline)
.foregroundStyle(.secondary)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@@ -0,0 +1,78 @@
import SwiftUI
struct SignalBadge: View {
let strength: String // "strong" | "moderate" | "weak"
var color: Color {
switch strength {
case "strong": return Constants.Color.strong
case "moderate": return Constants.Color.moderate
default: return Constants.Color.weak
}
}
var body: some View {
Text(strength.capitalized)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.18))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
struct IVRankBadge: View {
let ivRank: Double
var color: Color {
if ivRank >= 50 { return Constants.Color.strong }
if ivRank >= 30 { return Constants.Color.moderate }
return Constants.Color.weak
}
var body: some View {
HStack(spacing: 2) {
Text("IV")
.font(.caption2)
.foregroundStyle(.secondary)
Text(String(format: "%.0f%%", ivRank))
.font(.caption2.weight(.bold))
.foregroundStyle(color)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
}
struct AlertTypeBadge: View {
let alertType: String
var label: String {
switch alertType {
case "close_early": return "Close"
case "roll_out": return "Roll Out"
case "roll_up_down": return "Roll"
case "earnings_warning": return "Earnings"
default: return alertType
}
}
var color: Color {
alertType == "close_early" || alertType == "earnings_warning"
? Constants.Color.destructive
: Constants.Color.warning
}
var body: some View {
Text(label)
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var notificationHandler: NotificationHandler
@State private var selectedTab = 0
@State private var navigateToPositionId: Int? = nil
var body: some View {
TabView(selection: $selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "house.fill")
}
.tag(0)
RecommendationsView()
.tabItem {
Label("Setups", systemImage: "lightbulb.fill")
}
.tag(1)
OpenPositionsView()
.tabItem {
Label("Trades", systemImage: "doc.text.fill")
}
.tag(2)
PortfolioView()
.tabItem {
Label("Portfolio", systemImage: "briefcase.fill")
}
.tag(3)
AlertsView()
.tabItem {
Label("Alerts", systemImage: "bell.fill")
}
.badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil)
.tag(4)
}
.task {
await NotificationPermissions.shared.requestIfNeeded()
}
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
if posId != nil {
// Deep link: switch to Trades tab
selectedTab = 2
navigateToPositionId = posId
}
}
}
}

View File

@@ -0,0 +1,239 @@
import SwiftUI
struct DashboardView: View {
@StateObject private var vm = DashboardViewModel()
@EnvironmentObject private var notificationHandler: NotificationHandler
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.stockPositions.isEmpty {
LoadingView(message: "Loading your positions...")
} else {
ScrollView {
LazyVStack(spacing: 0) {
// Urgent alert banners
if !vm.urgentAlerts.isEmpty {
urgentAlertSection
}
// Open options positions
if !vm.openOptionPositions.isEmpty {
openPositionsSection
}
// Recommendations
if !vm.topRecommendations.isEmpty {
recommendationsSection
}
// Empty state
if vm.stockPositions.isEmpty {
emptyState
}
}
.padding(.bottom, 20)
}
.refreshable { await vm.refresh() }
}
}
.navigationTitle("Options Sidekick")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if vm.unreadAlerts.count > 0 {
alertBell
}
}
}
}
.task { await vm.loadAll() }
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
if msg != nil { Task { await vm.refresh() } }
}
}
// Sections
private var urgentAlertSection: some View {
VStack(spacing: 0) {
ForEach(vm.urgentAlerts.prefix(3)) { alert in
AlertBannerView(alert: alert)
.padding(.horizontal)
.padding(.top, 8)
}
}
}
private var openPositionsSection: some View {
VStack(alignment: .leading, spacing: 4) {
sectionHeader("Open Positions", systemImage: "chart.line.uptrend.xyaxis")
ForEach(vm.openOptionPositions) { position in
NavigationLink(destination: PositionDetailView(position: position)) {
OpenPositionRowView(position: position)
}
.buttonStyle(.plain)
.padding(.horizontal)
}
}
.padding(.top, 16)
}
private var recommendationsSection: some View {
VStack(alignment: .leading, spacing: 4) {
sectionHeader("Today's Setups", systemImage: "lightbulb")
ForEach(vm.topRecommendations) { rec in
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker)) {
RecommendationCardView(recommendation: rec)
}
.buttonStyle(.plain)
.padding(.horizontal)
}
}
.padding(.top, 16)
}
private var emptyState: some View {
EmptyStateView(
icon: "tray",
title: "No positions yet",
subtitle: "Add stocks to your portfolio to get recommendations."
)
.padding(.top, 60)
}
private var alertBell: some View {
ZStack(alignment: .topTrailing) {
Image(systemName: "bell.fill")
.foregroundStyle(Constants.Color.warning)
Circle()
.fill(Constants.Color.destructive)
.frame(width: 8, height: 8)
.offset(x: 4, y: -4)
}
}
private func sectionHeader(_ title: String, systemImage: String) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(title)
.font(.headline)
}
.padding(.horizontal)
.padding(.bottom, 4)
}
}
// Alert Banner
struct AlertBannerView: View {
let alert: AppAlert
var body: some View {
HStack(spacing: 10) {
Image(systemName: alert.isUrgent ? "exclamationmark.triangle.fill" : "bell.fill")
.foregroundStyle(alert.isUrgent ? Constants.Color.destructive : Constants.Color.warning)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(alert.ticker)
.font(.subheadline.weight(.bold))
AlertTypeBadge(alertType: alert.alertType)
}
Text(alert.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
}
.padding(10)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
alert.isUrgent ? Constants.Color.destructive.opacity(0.4) : Constants.Color.warning.opacity(0.3),
lineWidth: 1
))
}
}
// Recommendation Card
struct RecommendationCardView: View {
let recommendation: Recommendation
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(recommendation.ticker)
.font(.headline)
Text(recommendation.strategyLabel)
.font(.caption)
.foregroundStyle(.secondary)
SignalBadge(strength: recommendation.signalStrength)
if recommendation.earningsWarning {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(Constants.Color.warning)
}
}
HStack(spacing: 12) {
label("Strike", value: String(format: "$%.0f", recommendation.recommendedStrike))
label("Exp", value: recommendation.recommendedExpiration)
label("Premium", value: String(format: "$%.2f", recommendation.estimatedPremium))
}
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
IVRankBadge(ivRank: recommendation.ivRank)
Text(String(format: "Δ %.2f", recommendation.delta))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
.padding(.vertical, 2)
}
private func label(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.caption2).foregroundStyle(.tertiary)
Text(value).font(.caption.weight(.medium))
}
}
}
// Open Position Row
struct OpenPositionRowView: View {
let position: OptionPosition
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(position.ticker).font(.headline)
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Text(String(format: "$%.0f strike", position.strike)).font(.caption)
if let dte = position.daysToExpiry {
Text("\(dte)d").font(.caption).foregroundStyle(dte <= 3 ? Constants.Color.warning : .secondary)
}
Text(String(format: "×%d", position.contracts)).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "$%.2f", position.premiumReceived))
.font(.subheadline.weight(.semibold))
Text("per contract").font(.caption2).foregroundStyle(.tertiary)
}
}
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
.padding(.vertical, 2)
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
struct PortfolioView: View {
@StateObject private var vm = PortfolioViewModel()
@State private var showAddSheet = false
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.positions.isEmpty {
LoadingView(message: "Loading portfolio...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.positions.isEmpty {
EmptyStateView(
icon: "briefcase",
title: "No stocks yet",
subtitle: "Add the tickers you hold shares in to get covered call and cash-secured put recommendations."
)
} else {
List {
ForEach(vm.positions) { position in
PortfolioRowView(position: position)
}
.onDelete { indexSet in
Task {
for i in indexSet {
await vm.delete(ticker: vm.positions[i].ticker)
}
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("My Stocks")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddSheet) {
AddPositionSheet(vm: vm)
}
}
.task { await vm.load() }
}
}
// Row
struct PortfolioRowView: View {
let position: PortfolioPosition
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(position.ticker)
.font(.headline)
if let cost = position.costBasis {
Text(String(format: "Avg cost $%.2f", cost))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(position.shares) shares")
.font(.subheadline.weight(.medium))
Text("\(position.shares / 100) contracts available")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
// Add Sheet
struct AddPositionSheet: View {
@ObservedObject var vm: PortfolioViewModel
@Environment(\.dismiss) var dismiss
@State private var ticker = ""
@State private var sharesText = ""
@State private var costBasisText = ""
@FocusState private var focusedField: Field?
enum Field { case ticker, shares, cost }
var sharesInt: Int? { Int(sharesText) }
var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) }
var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 }
var body: some View {
NavigationStack {
Form {
Section("Stock") {
TextField("Ticker (e.g. AAPL)", text: $ticker)
.textInputAutocapitalization(.characters)
.focused($focusedField, equals: .ticker)
}
Section("Position") {
TextField("Shares owned", text: $sharesText)
.keyboardType(.numberPad)
.focused($focusedField, equals: .shares)
TextField("Avg cost basis (optional)", text: $costBasisText)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .cost)
}
if let shares = sharesInt, shares > 0 {
Section("") {
HStack {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("You can sell up to \(shares / 100) covered call contracts.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Add Stock")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
Task {
await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
dismiss()
}
}
.disabled(!isValid)
}
}
}
.onAppear { focusedField = .ticker }
}
}

View File

@@ -0,0 +1,104 @@
import SwiftUI
struct LogTradeSheet: View {
@ObservedObject var vm: PositionsViewModel
@Environment(\.dismiss) var dismiss
@State private var ticker = ""
@State private var strategy = "covered_call"
@State private var strikeText = ""
@State private var expiration = Date().addingTimeInterval(14 * 86400)
@State private var premiumText = ""
@State private var contractsText = "1"
@FocusState private var focusedField: Field?
enum Field { case ticker, strike, premium, contracts }
var strike: Double? { Double(strikeText) }
var premium: Double? { Double(premiumText) }
var contracts: Int { Int(contractsText) ?? 1 }
var isValid: Bool { !ticker.isEmpty && (strike ?? 0) > 0 && (premium ?? 0) > 0 }
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
var body: some View {
NavigationStack {
Form {
Section("Trade") {
TextField("Ticker (e.g. AAPL)", text: $ticker)
.textInputAutocapitalization(.characters)
.focused($focusedField, equals: .ticker)
Picker("Strategy", selection: $strategy) {
Text("Covered Call").tag("covered_call")
Text("Cash-Secured Put").tag("cash_secured_put")
}
}
Section("Details") {
TextField("Strike price", text: $strikeText)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .strike)
DatePicker("Expiration", selection: $expiration, in: Date()..., displayedComponents: .date)
TextField("Premium received (per share)", text: $premiumText)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .premium)
TextField("Contracts", text: $contractsText)
.keyboardType(.numberPad)
.focused($focusedField, equals: .contracts)
}
if let p = premium, let s = strike, contracts > 0 {
Section("") {
HStack {
Text("Total credit received")
Spacer()
Text(String(format: "$%.2f", p * Double(contracts) * 100))
.font(.headline)
.foregroundStyle(Constants.Color.strong)
}
HStack {
Text("Max profit per contract")
Spacer()
Text(String(format: "$%.2f", p * 100))
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Log Trade")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Log") {
guard isValid else { return }
Task {
let create = OptionPositionCreate(
ticker: ticker.uppercased(),
strategy: strategy,
strike: strike!,
expiration: dateFormatter.string(from: expiration),
premiumReceived: premium!,
contracts: contracts
)
let success = await vm.log(create: create)
if success { dismiss() }
}
}
.disabled(!isValid)
}
}
}
.onAppear { focusedField = .ticker }
}
}

View File

@@ -0,0 +1,103 @@
import SwiftUI
struct OpenPositionsView: View {
@StateObject private var vm = PositionsViewModel()
@State private var showLogSheet = false
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.positions.isEmpty {
LoadingView(message: "Loading positions...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.positions.isEmpty {
EmptyStateView(
icon: "doc.text",
title: "No logged trades",
subtitle: "Log your executed options trades here. The app will monitor them and alert you when signals change."
)
} else {
List {
if !vm.openPositions.isEmpty {
Section("Open") {
ForEach(vm.openPositions) { position in
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) {
LoggedPositionRow(position: position)
}
}
}
}
if !vm.closedPositions.isEmpty {
Section("Closed / Rolled") {
ForEach(vm.closedPositions) { position in
LoggedPositionRow(position: position)
.foregroundStyle(.secondary)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await vm.load() }
}
}
.navigationTitle("My Trades")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showLogSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showLogSheet) {
LogTradeSheet(vm: vm)
}
}
.task { await vm.load() }
}
}
// Row
struct LoggedPositionRow: View {
let position: OptionPosition
var statusColor: Color {
switch position.status {
case "open": return Constants.Color.strong
case "rolled": return Constants.Color.moderate
default: return .secondary
}
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(position.ticker).font(.headline)
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
Circle().fill(statusColor).frame(width: 6, height: 6)
}
HStack(spacing: 10) {
Text(String(format: "$%.0f", position.strike)).font(.caption)
if let dte = position.daysToExpiry {
Text("\(dte)d to expiry")
.font(.caption)
.foregroundStyle(dte <= 5 ? Constants.Color.warning : .secondary)
}
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "$%.2f", position.premiumReceived))
.font(.subheadline.weight(.semibold))
Text(String(format: "×%d", position.contracts))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 3)
}
}

View File

@@ -0,0 +1,156 @@
import SwiftUI
struct PositionDetailView: View {
let position: OptionPosition
@ObservedObject var vm: PositionsViewModel
@State private var signals: SignalSnapshot? = nil
@State private var isLoadingSignals = false
@State private var showCloseConfirm = false
@State private var showRollConfirm = false
@Environment(\.dismiss) var dismiss
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
self.position = position
self.vm = vm
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Position summary
summarySection
Divider()
// Current signals
if isLoadingSignals {
HStack {
ProgressView()
Text("Loading signals...").font(.caption).foregroundStyle(.secondary)
}
.padding()
} else if let signals {
signalsSection(signals)
Divider()
}
// Actions
if position.status == "open" {
actionSection
}
}
.padding()
}
.navigationTitle("\(position.ticker) \(position.strategyLabel)")
.navigationBarTitleDisplayMode(.inline)
.task { await loadSignals() }
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
Button("Bought Back", role: .destructive) {
Task { await vm.close(position: position, reason: "bought_back"); dismiss() }
}
Button("Expired Worthless") {
Task { await vm.close(position: position, reason: "expired"); dismiss() }
}
}
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
Button("Mark as Rolled", role: .destructive) {
Task { await vm.roll(position: position); dismiss() }
}
}
}
// Sections
private var summarySection: some View {
VStack(alignment: .leading, spacing: 10) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("Strike", String(format: "$%.2f", position.strike))
metricCard("Expiration", position.expiration)
metricCard("Premium", String(format: "$%.2f", position.premiumReceived))
metricCard("Contracts", "\(position.contracts)")
metricCard("Total Credit", String(format: "$%.2f", position.totalCredit))
if let dte = position.daysToExpiry {
metricCard("Days Left", "\(dte)d", highlight: dte <= 5)
}
}
}
}
private func signalsSection(_ snap: SignalSnapshot) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Current Signals")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
metricCard("Trend", snap.trend.capitalized)
if let support = snap.nearestSupport {
metricCard("Support", String(format: "$%.2f", support))
}
if let resistance = snap.nearestResistance {
metricCard("Resistance", String(format: "$%.2f", resistance))
}
}
if let earnings = snap.earningsDate {
HStack(spacing: 6) {
Image(systemName: "calendar.badge.exclamationmark")
.foregroundStyle(Constants.Color.warning)
Text("Earnings: \(earnings)")
.font(.caption)
.foregroundStyle(Constants.Color.warning)
}
}
}
}
private var actionSection: some View {
VStack(spacing: 10) {
Button {
showCloseConfirm = true
} label: {
Label("Close Position", systemImage: "xmark.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(Constants.Color.destructive)
Button {
showRollConfirm = true
} label: {
Label("Roll Position", systemImage: "arrow.2.circlepath")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(Constants.Color.accent)
}
}
// Helpers
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
}
private func loadSignals() async {
isLoadingSignals = true
do {
signals = try await APIClient.shared.request(
.getSignals(position.ticker),
body: Optional<String>.none
)
} catch {
// Non-critical just don't show signals
}
isLoadingSignals = false
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
struct RecommendationDetailView: View {
let ticker: String
var initialRec: Recommendation? = nil
@StateObject private var vm = RecommendationsViewModel()
@State private var detail: RecommendationWithSignals? = nil
@State private var isLoading = false
var rec: Recommendation? { detail?.recommendation ?? initialRec }
var signals: SignalSnapshot? { detail?.signals }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isLoading {
LoadingView()
} else if let rec {
// Header
headerSection(rec)
Divider()
// Trade Setup
tradeSetupSection(rec)
Divider()
// Signal Detail
if let signals {
signalDetailSection(signals)
Divider()
}
// Rationale
rationaleSection(rec)
if rec.earningsWarning {
earningsWarningBanner(rec)
}
}
}
.padding()
}
.navigationTitle(ticker)
.navigationBarTitleDisplayMode(.inline)
.task { await loadDetail() }
}
// Sections
private func headerSection(_ rec: Recommendation) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(rec.strategyLabel)
.font(.title3.weight(.semibold))
Text(rec.horizonLabel)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
SignalBadge(strength: rec.signalStrength)
Text(String(format: "$%.2f", rec.currentPrice))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func tradeSetupSection(_ rec: Recommendation) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Trade Setup")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike))
metricCard("Expiration", rec.recommendedExpiration)
metricCard("Est. Premium", String(format: "$%.2f", rec.estimatedPremium))
metricCard("Ann. Return", String(format: "%.1f%%", rec.annualizedPremiumPct))
metricCard("Delta", String(format: "%.3f", rec.delta))
metricCard("Theta", String(format: "%.3f", rec.theta))
}
}
}
private func signalDetailSection(_ signals: SignalSnapshot) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Market Signals")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50)
metricCard("Trend", signals.trend.capitalized)
metricCard("SMA-50", String(format: "$%.2f", signals.sma50))
metricCard("SMA-200", String(format: "$%.2f", signals.sma200))
if let support = signals.nearestSupport {
metricCard("Support", String(format: "$%.2f", support))
}
if let resistance = signals.nearestResistance {
metricCard("Resistance", String(format: "$%.2f", resistance))
}
}
}
}
private func rationaleSection(_ rec: Recommendation) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Rationale")
.font(.headline)
Text(rec.rationale)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Constants.Color.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Earnings Warning")
.font(.subheadline.weight(.semibold))
if let earningsDate = rec.earningsDate {
Text("Earnings on \(earningsDate) fall within this expiry window.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Constants.Color.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1))
}
// Helper views
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
}
private func loadDetail() async {
isLoading = true
vm.selectedStrategy = initialRec?.strategy ?? "covered_call"
vm.selectedHorizon = initialRec?.timeHorizon ?? "weekly"
detail = await vm.getDetail(ticker: ticker)
isLoading = false
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
struct RecommendationsView: View {
@StateObject private var vm = RecommendationsViewModel()
private let horizons = ["weekly", "monthly", "0dte", "1dte"]
private let strategies = ["covered_call", "cash_secured_put"]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Pickers
VStack(spacing: 8) {
Picker("Horizon", selection: $vm.selectedHorizon) {
ForEach(horizons, id: \.self) { h in
Text(horizonLabel(h)).tag(h)
}
}
.pickerStyle(.segmented)
Picker("Strategy", selection: $vm.selectedStrategy) {
Text("Covered Call").tag("covered_call")
Text("Cash-Secured Put").tag("cash_secured_put")
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
Divider()
// Content
if vm.isLoading {
LoadingView(message: "Getting recommendations...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.filtered.isEmpty {
EmptyStateView(
icon: "chart.xyaxis.line",
title: "No recommendations",
subtitle: "Add stocks to your portfolio or try a different horizon."
)
} else {
List(vm.filtered) { rec in
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker, initialRec: rec)) {
RecommendationListRow(rec: rec)
}
}
.listStyle(.insetGrouped)
.refreshable { await vm.refresh() }
}
}
.navigationTitle("Recommendations")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if vm.isRefreshing {
ProgressView()
} else {
Button {
Task { await vm.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
}
}
}
}
.task { await vm.load() }
}
private func horizonLabel(_ h: String) -> String {
switch h {
case "0dte": return "0DTE"
case "1dte": return "1DTE"
case "weekly": return "Weekly"
case "monthly": return "Monthly"
default: return h.capitalized
}
}
}
// List Row
struct RecommendationListRow: View {
let rec: Recommendation
var body: some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(rec.ticker).font(.headline)
SignalBadge(strength: rec.signalStrength)
if rec.earningsWarning {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(Constants.Color.warning)
}
}
HStack(spacing: 14) {
dataPoint("Strike", String(format: "$%.0f", rec.recommendedStrike))
dataPoint("Exp", rec.recommendedExpiration)
dataPoint("Credit", String(format: "$%.2f", rec.estimatedPremium))
}
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
IVRankBadge(ivRank: rec.ivRank)
Text(String(format: "%.0f%% ann.", rec.annualizedPremiumPct))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private func dataPoint(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(label).font(.caption2).foregroundStyle(.tertiary)
Text(value).font(.caption.weight(.medium))
}
}
}