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:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
7
backend/.env.example
Normal 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
1
backend/Procfile
Normal file
@@ -0,0 +1 @@
|
|||||||
|
web: uvicorn app.main:app --host 0.0.0.0 --port $PORT
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
19
backend/app/config.py
Normal file
19
backend/app/config.py
Normal 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
27
backend/app/database.py
Normal 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
54
backend/app/main.py
Normal 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,
|
||||||
|
)
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
95
backend/app/models/db_models.py
Normal file
95
backend/app/models/db_models.py
Normal 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")
|
||||||
166
backend/app/models/schemas.py
Normal file
166
backend/app/models/schemas.py
Normal 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
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
46
backend/app/routers/alerts.py
Normal file
46
backend/app/routers/alerts.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db_models import Device, Alert
|
||||||
|
from app.models.schemas import AlertResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device(x_device_token: str = Header(...), db: Session = Depends(get_db)) -> Device:
|
||||||
|
device = db.query(Device).filter(Device.apns_token == x_device_token).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not registered.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AlertResponse])
|
||||||
|
def get_alerts(
|
||||||
|
unread_only: bool = Query(False),
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = db.query(Alert).filter(Alert.device_id == device.id)
|
||||||
|
if unread_only:
|
||||||
|
query = query.filter(Alert.acknowledged == False) # noqa: E712
|
||||||
|
return query.order_by(Alert.sent_at.desc()).limit(100).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse)
|
||||||
|
def acknowledge_alert(
|
||||||
|
alert_id: int,
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
alert = (
|
||||||
|
db.query(Alert)
|
||||||
|
.filter(Alert.id == alert_id, Alert.device_id == device.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found.")
|
||||||
|
alert.acknowledged = True
|
||||||
|
db.commit()
|
||||||
|
db.refresh(alert)
|
||||||
|
return alert
|
||||||
29
backend/app/routers/devices.py
Normal file
29
backend/app/routers/devices.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db_models import Device
|
||||||
|
from app.models.schemas import DeviceRegister, DeviceResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/devices", tags=["devices"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=DeviceResponse)
|
||||||
|
def register_device(body: DeviceRegister, db: Session = Depends(get_db)):
|
||||||
|
"""Register or refresh an APNs device token."""
|
||||||
|
device = db.query(Device).filter(Device.apns_token == body.apns_token).first()
|
||||||
|
|
||||||
|
if device:
|
||||||
|
device.device_name = body.device_name or device.device_name
|
||||||
|
device.last_seen = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
device = Device(
|
||||||
|
apns_token=body.apns_token,
|
||||||
|
device_name=body.device_name,
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(device)
|
||||||
|
return device
|
||||||
64
backend/app/routers/portfolio.py
Normal file
64
backend/app/routers/portfolio.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db_models import Device, StockPosition
|
||||||
|
from app.models.schemas import StockPositionCreate, StockPositionResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/portfolio", tags=["portfolio"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device(x_device_token: str = Header(...), db: Session = Depends(get_db)) -> Device:
|
||||||
|
device = db.query(Device).filter(Device.apns_token == x_device_token).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not registered. Call /devices/register first.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[StockPositionResponse])
|
||||||
|
def get_portfolio(device: Device = Depends(_get_device), db: Session = Depends(get_db)):
|
||||||
|
return db.query(StockPosition).filter(StockPosition.device_id == device.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=list[StockPositionResponse])
|
||||||
|
def set_portfolio(
|
||||||
|
positions: list[StockPositionCreate],
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Full replace — client sends complete list of stock holdings."""
|
||||||
|
# Delete all existing for this device
|
||||||
|
db.query(StockPosition).filter(StockPosition.device_id == device.id).delete()
|
||||||
|
|
||||||
|
new_positions = []
|
||||||
|
for p in positions:
|
||||||
|
sp = StockPosition(
|
||||||
|
device_id=device.id,
|
||||||
|
ticker=p.ticker,
|
||||||
|
shares=p.shares,
|
||||||
|
cost_basis=p.cost_basis,
|
||||||
|
)
|
||||||
|
db.add(sp)
|
||||||
|
new_positions.append(sp)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for sp in new_positions:
|
||||||
|
db.refresh(sp)
|
||||||
|
return new_positions
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{ticker}", status_code=204)
|
||||||
|
def delete_ticker(
|
||||||
|
ticker: str,
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
ticker = ticker.upper()
|
||||||
|
deleted = (
|
||||||
|
db.query(StockPosition)
|
||||||
|
.filter(StockPosition.device_id == device.id, StockPosition.ticker == ticker)
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Ticker {ticker} not in portfolio")
|
||||||
|
db.commit()
|
||||||
78
backend/app/routers/positions.py
Normal file
78
backend/app/routers/positions.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db_models import Device, OptionPosition
|
||||||
|
from app.models.schemas import OptionPositionCreate, OptionPositionClose, OptionPositionResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/positions", tags=["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device(x_device_token: str = Header(...), db: Session = Depends(get_db)) -> Device:
|
||||||
|
device = db.query(Device).filter(Device.apns_token == x_device_token).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not registered.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[OptionPositionResponse])
|
||||||
|
def get_positions(
|
||||||
|
status: str | None = Query(None),
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = db.query(OptionPosition).filter(OptionPosition.device_id == device.id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(OptionPosition.status == status)
|
||||||
|
return query.order_by(OptionPosition.opened_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OptionPositionResponse, status_code=201)
|
||||||
|
def log_position(
|
||||||
|
body: OptionPositionCreate,
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
position = OptionPosition(
|
||||||
|
device_id=device.id,
|
||||||
|
ticker=body.ticker,
|
||||||
|
strategy=body.strategy,
|
||||||
|
strike=body.strike,
|
||||||
|
expiration=body.expiration,
|
||||||
|
premium_received=body.premium_received,
|
||||||
|
contracts=body.contracts,
|
||||||
|
status="open",
|
||||||
|
)
|
||||||
|
db.add(position)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(position)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{position_id}", response_model=OptionPositionResponse)
|
||||||
|
def close_position(
|
||||||
|
position_id: int,
|
||||||
|
body: OptionPositionClose,
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
position = (
|
||||||
|
db.query(OptionPosition)
|
||||||
|
.filter(OptionPosition.id == position_id, OptionPosition.device_id == device.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not position:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found.")
|
||||||
|
|
||||||
|
valid_statuses = ("closed", "rolled")
|
||||||
|
if body.status not in valid_statuses:
|
||||||
|
raise HTTPException(status_code=422, detail=f"status must be one of {valid_statuses}")
|
||||||
|
|
||||||
|
position.status = body.status
|
||||||
|
position.close_reason = body.close_reason
|
||||||
|
position.closed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(position)
|
||||||
|
return position
|
||||||
141
backend/app/routers/recommendations.py
Normal file
141
backend/app/routers/recommendations.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.db_models import Device, StockPosition, Recommendation
|
||||||
|
from app.models.schemas import RecommendationResponse, RecommendationWithSignals
|
||||||
|
from app.services.signal_engine import compute_signals
|
||||||
|
from app.services.recommendation_engine import build_recommendation
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device(x_device_token: str = Header(...), db: Session = Depends(get_db)) -> Device:
|
||||||
|
device = db.query(Device).filter(Device.apns_token == x_device_token).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not registered.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RecommendationResponse])
|
||||||
|
def get_recommendations(
|
||||||
|
time_horizon: str | None = Query(None),
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return latest cached recommendations for all portfolio tickers."""
|
||||||
|
query = db.query(Recommendation).filter(Recommendation.device_id == device.id)
|
||||||
|
if time_horizon:
|
||||||
|
query = query.filter(Recommendation.time_horizon == time_horizon)
|
||||||
|
return query.order_by(Recommendation.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticker}", response_model=RecommendationWithSignals)
|
||||||
|
def get_recommendation_for_ticker(
|
||||||
|
ticker: str,
|
||||||
|
time_horizon: str = Query("weekly"),
|
||||||
|
strategy: str = Query("covered_call"),
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return fresh recommendation + signals for a specific ticker."""
|
||||||
|
ticker = ticker.upper()
|
||||||
|
|
||||||
|
snap = compute_signals(ticker)
|
||||||
|
if snap is None:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Could not fetch market data for {ticker}")
|
||||||
|
|
||||||
|
rec = build_recommendation(
|
||||||
|
device_id=device.id,
|
||||||
|
ticker=ticker,
|
||||||
|
strategy=strategy,
|
||||||
|
time_horizon=time_horizon,
|
||||||
|
snapshot=snap,
|
||||||
|
)
|
||||||
|
if rec is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No qualifying options found for {ticker} {strategy} {time_horizon}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist recommendation
|
||||||
|
existing = (
|
||||||
|
db.query(Recommendation)
|
||||||
|
.filter(
|
||||||
|
Recommendation.device_id == device.id,
|
||||||
|
Recommendation.ticker == ticker,
|
||||||
|
Recommendation.strategy == strategy,
|
||||||
|
Recommendation.time_horizon == time_horizon,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
db.delete(existing)
|
||||||
|
db.add(rec)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
|
||||||
|
return RecommendationWithSignals(recommendation=RecommendationResponse.model_validate(rec), signals=snap)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=list[RecommendationResponse])
|
||||||
|
def refresh_recommendations(
|
||||||
|
device: Device = Depends(_get_device),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
On-demand recalculation for all portfolio tickers.
|
||||||
|
Throttled: no-ops if last refresh was less than THROTTLE_MINUTES ago.
|
||||||
|
"""
|
||||||
|
throttle = timedelta(minutes=settings.recommendation_throttle_minutes)
|
||||||
|
most_recent = (
|
||||||
|
db.query(Recommendation)
|
||||||
|
.filter(Recommendation.device_id == device.id)
|
||||||
|
.order_by(Recommendation.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if most_recent and (datetime.utcnow() - most_recent.created_at) < throttle:
|
||||||
|
return db.query(Recommendation).filter(Recommendation.device_id == device.id).all()
|
||||||
|
|
||||||
|
stock_positions = db.query(StockPosition).filter(StockPosition.device_id == device.id).all()
|
||||||
|
if not stock_positions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for sp in stock_positions:
|
||||||
|
snap = compute_signals(sp.ticker)
|
||||||
|
if snap is None:
|
||||||
|
continue
|
||||||
|
for strategy in ("covered_call", "cash_secured_put"):
|
||||||
|
for horizon in ("weekly", "monthly"):
|
||||||
|
rec = build_recommendation(
|
||||||
|
device_id=device.id,
|
||||||
|
ticker=sp.ticker,
|
||||||
|
strategy=strategy,
|
||||||
|
time_horizon=horizon,
|
||||||
|
snapshot=snap,
|
||||||
|
)
|
||||||
|
if rec is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
db.query(Recommendation)
|
||||||
|
.filter(
|
||||||
|
Recommendation.device_id == device.id,
|
||||||
|
Recommendation.ticker == sp.ticker,
|
||||||
|
Recommendation.strategy == strategy,
|
||||||
|
Recommendation.time_horizon == horizon,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
db.delete(existing)
|
||||||
|
db.add(rec)
|
||||||
|
results.append(rec)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for r in results:
|
||||||
|
db.refresh(r)
|
||||||
|
return results
|
||||||
14
backend/app/routers/signals.py
Normal file
14
backend/app/routers/signals.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from app.models.schemas import SignalSnapshot
|
||||||
|
from app.services.signal_engine import compute_signals
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/signals", tags=["signals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticker}", response_model=SignalSnapshot)
|
||||||
|
def get_signals(ticker: str):
|
||||||
|
ticker = ticker.upper()
|
||||||
|
snap = compute_signals(ticker)
|
||||||
|
if snap is None:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Could not fetch market data for {ticker}")
|
||||||
|
return snap
|
||||||
38
backend/app/scheduler.py
Normal file
38
backend/app/scheduler.py
Normal 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")
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
124
backend/app/services/apns_service.py
Normal file
124
backend/app/services/apns_service.py
Normal 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
|
||||||
198
backend/app/services/market_data.py
Normal file
198
backend/app/services/market_data.py
Normal 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
|
||||||
331
backend/app/services/position_monitor.py
Normal file
331
backend/app/services/position_monitor.py
Normal 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)
|
||||||
299
backend/app/services/recommendation_engine.py
Normal file
299
backend/app/services/recommendation_engine.py
Normal 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)
|
||||||
274
backend/app/services/signal_engine.py
Normal file
274
backend/app/services/signal_engine.py
Normal 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(),
|
||||||
|
)
|
||||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
102
backend/app/utils/date_helpers.py
Normal file
102
backend/app/utils/date_helpers.py
Normal 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:30–16: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
|
||||||
10
backend/migrations/init_db.py
Normal file
10
backend/migrations/init_db.py
Normal 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
16
backend/requirements.txt
Normal 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]
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
4
backend/tests/conftest.py
Normal file
4
backend/tests/conftest.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Pytest configuration — sets asyncio mode."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest_plugins = ["pytest_asyncio"]
|
||||||
220
backend/tests/test_api.py
Normal file
220
backend/tests/test_api.py
Normal 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"
|
||||||
267
backend/tests/test_signal_engine.py
Normal file
267
backend/tests/test_signal_engine.py
Normal 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
|
||||||
59
ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift
Normal file
59
ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ios/OptionsSidekick/OptionsSidekick/Config/Constants.swift
Normal file
26
ios/OptionsSidekick/OptionsSidekick/Config/Constants.swift
Normal 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"
|
||||||
|
}
|
||||||
33
ios/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift
Normal file
33
ios/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
144
ios/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift
Normal file
144
ios/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ios/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift
Normal file
13
ios/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
47
ios/OptionsSidekick/OptionsSidekick/Resources/Info.plist
Normal file
47
ios/OptionsSidekick/OptionsSidekick/Resources/Info.plist
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
52
ios/OptionsSidekick/OptionsSidekick/Views/ContentView.swift
Normal file
52
ios/OptionsSidekick/OptionsSidekick/Views/ContentView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user