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