"""Portfolio endpoints — current value, positions, equity curve.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from enum import Enum from fastapi import APIRouter, Depends, Query, Request from services.api_gateway.auth.middleware import get_current_user from sqlalchemy import select, desc router = APIRouter(prefix="/api/portfolio", tags=["portfolio"]) class HistoryPeriod(str, Enum): ONE_DAY = "1d" ONE_WEEK = "1w" ONE_MONTH = "1m" THREE_MONTHS = "3m" SIX_MONTHS = "6m" ONE_YEAR = "1y" ALL = "all" @classmethod def _missing_(cls, value: object) -> HistoryPeriod | None: """Accept uppercase variants like '1D', '1M', 'ALL'.""" if isinstance(value, str): lower = value.lower() for member in cls: if member.value == lower: return member return None def _period_to_timedelta(period: HistoryPeriod) -> timedelta: """Convert a period enum value to a timedelta.""" mapping = { HistoryPeriod.ONE_DAY: timedelta(days=1), HistoryPeriod.ONE_WEEK: timedelta(weeks=1), HistoryPeriod.ONE_MONTH: timedelta(days=30), HistoryPeriod.THREE_MONTHS: timedelta(days=90), HistoryPeriod.SIX_MONTHS: timedelta(days=180), HistoryPeriod.ONE_YEAR: timedelta(days=365), HistoryPeriod.ALL: timedelta(days=365 * 10), # effectively "all time" } return mapping[period] @router.get("") async def get_portfolio( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Current portfolio summary — value, cash, buying power, daily P&L.""" from shared.models.timeseries import PortfolioSnapshot db = request.app.state.db_session_factory async with db() as session: latest = ( await session.execute( select(PortfolioSnapshot) .order_by(desc(PortfolioSnapshot.timestamp)) .limit(1) ) ).scalar_one_or_none() if latest is None: return { "total_value": 0.0, "cash": 0.0, "buying_power": 0.0, "daily_pnl": 0.0, "daily_pnl_pct": 0.0, "total_pnl": 0.0, "total_pnl_pct": 0.0, "trading_active": True, } # Compute percentage fields from snapshot data daily_pnl_pct = (latest.daily_pnl / (latest.total_value - latest.daily_pnl) * 100.0 if latest.total_value != latest.daily_pnl else 0.0) return { "total_value": latest.total_value, "cash": latest.cash, "buying_power": latest.cash, "daily_pnl": latest.daily_pnl, "daily_pnl_pct": round(daily_pnl_pct, 2), "total_pnl": latest.daily_pnl, # TODO: compute cumulative P&L from first snapshot "total_pnl_pct": round(daily_pnl_pct, 2), "trading_active": True, # TODO: read from Redis trading pause flag } @router.get("/positions") async def get_positions( request: Request, _user: dict = Depends(get_current_user), ) -> list[dict]: """All open positions with unrealized P&L.""" from shared.models.trading import Position db = request.app.state.db_session_factory async with db() as session: result = await session.execute(select(Position)) positions = result.scalars().all() return [ { "id": str(p.id), "ticker": p.ticker, "qty": p.qty, "avg_entry": p.avg_entry, "current_price": round( p.avg_entry + (p.unrealized_pnl or 0.0) / p.qty, 2 ) if p.qty else p.avg_entry, "unrealized_pnl": p.unrealized_pnl or 0.0, "unrealized_pnl_pct": round( (p.unrealized_pnl or 0.0) / (p.avg_entry * p.qty) * 100.0, 2 ) if p.avg_entry and p.qty else 0.0, "stop_loss": p.stop_loss, "take_profit": p.take_profit, } for p in positions ] @router.get("/metrics") async def get_portfolio_metrics( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Aggregate portfolio performance metrics — ROI, Sharpe, win rate, drawdown.""" from shared.models.trading import Trade, TradeStatus from shared.models.timeseries import StrategyMetric db = request.app.state.db_session_factory async with db() as session: # Total trades and win rate from trades table trades_result = await session.execute( select(Trade).where(Trade.status == TradeStatus.FILLED) ) trades = trades_result.scalars().all() # Latest strategy metrics for Sharpe metrics_result = await session.execute( select(StrategyMetric) .order_by(desc(StrategyMetric.timestamp)) .limit(10) ) strategy_metrics = metrics_result.scalars().all() total_trades = len(trades) winning = sum(1 for t in trades if t.pnl is not None and t.pnl > 0) win_rate = winning / total_trades if total_trades > 0 else 0.0 total_pnl = sum(t.pnl for t in trades if t.pnl is not None) # Approximate ROI from P&L (rough — proper calculation needs initial capital) roi = total_pnl / 100_000.0 * 100.0 # assumes 100k starting capital # Average Sharpe from strategy metrics sharpe_values = [m.sharpe_ratio for m in strategy_metrics if m.sharpe_ratio is not None] avg_sharpe = sum(sharpe_values) / len(sharpe_values) if sharpe_values else 0.0 return { "roi": round(roi, 4), "sharpe": round(avg_sharpe, 2), "win_rate": round(win_rate, 4), "max_drawdown": 0.0, # TODO: compute from portfolio snapshots "total_trades": total_trades, "avg_hold_duration": "0h", # TODO: compute from trade outcomes } @router.get("/history") async def get_portfolio_history( request: Request, _user: dict = Depends(get_current_user), period: HistoryPeriod = Query(default=HistoryPeriod.ONE_MONTH), ) -> list[dict]: """Equity curve from portfolio_snapshots over a given period.""" from shared.models.timeseries import PortfolioSnapshot since = datetime.now(timezone.utc) - _period_to_timedelta(period) db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(PortfolioSnapshot) .where(PortfolioSnapshot.timestamp >= since) .order_by(PortfolioSnapshot.timestamp) ) snapshots = result.scalars().all() return [ { "timestamp": s.timestamp.isoformat(), "value": s.total_value, "total_value": s.total_value, "cash": s.cash, "positions_value": s.positions_value, "daily_pnl": s.daily_pnl, } for s in snapshots ]