"""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, asc, func 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 _compute_max_drawdown(values: list[float]) -> float: """Compute maximum percentage drawdown from a list of portfolio values. Returns a positive decimal (e.g. 0.12 for a 12% drawdown). """ if len(values) < 2: return 0.0 peak = values[0] max_dd = 0.0 for v in values: if v > peak: peak = v if peak > 0: dd = (peak - v) / peak if dd > max_dd: max_dd = dd return max_dd def _format_duration(durations: list[timedelta]) -> str: """Compute average of timedelta list and format as human-readable string.""" if not durations: return "0h" total_seconds = sum(d.total_seconds() for d in durations) / len(durations) if total_seconds < 0: total_seconds = 0 hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) if hours >= 24: days = hours // 24 remaining_hours = hours % 24 return f"{days}d {remaining_hours}h" return f"{hours}h {minutes}m" 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] TRADING_PAUSED_KEY = "trading:paused" @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, } # Cumulative P&L: difference between latest and earliest snapshot earliest = ( await session.execute( select(PortfolioSnapshot) .order_by(asc(PortfolioSnapshot.timestamp)) .limit(1) ) ).scalar_one_or_none() total_pnl = 0.0 total_pnl_pct = 0.0 if earliest is not None and earliest.total_value > 0: total_pnl = latest.total_value - earliest.total_value total_pnl_pct = total_pnl / earliest.total_value * 100.0 daily_pnl_pct = (latest.daily_pnl / (latest.total_value - latest.daily_pnl) * 100.0 if latest.total_value != latest.daily_pnl else 0.0) # Read trading pause flag from Redis redis = request.app.state.redis paused = await redis.get(TRADING_PAUSED_KEY) if redis else None 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": round(total_pnl, 2), "total_pnl_pct": round(total_pnl_pct, 2), "trading_active": not bool(paused), } @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.learning import TradeOutcome from shared.models.trading import Trade, TradeStatus from shared.models.timeseries import PortfolioSnapshot, StrategyMetric db = request.app.state.db_session_factory async with db() as session: trades_result = await session.execute( select(Trade).where(Trade.status == TradeStatus.FILLED) ) trades = trades_result.scalars().all() 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) roi = total_pnl / 100_000.0 * 100.0 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 # Max drawdown from portfolio snapshots (peak-to-trough) snapshots_result = await session.execute( select(PortfolioSnapshot.total_value) .order_by(PortfolioSnapshot.timestamp) ) values = [row[0] for row in snapshots_result.all()] max_drawdown = _compute_max_drawdown(values) # Average hold duration from trade outcomes outcomes_result = await session.execute( select(TradeOutcome.hold_duration) .where(TradeOutcome.hold_duration.isnot(None)) ) durations = [row[0] for row in outcomes_result.all()] avg_hold = _format_duration(durations) return { "roi": round(roi, 4), "sharpe": round(avg_sharpe, 2), "win_rate": round(win_rate, 4), "max_drawdown": round(max_drawdown, 4), "total_trades": total_trades, "avg_hold_duration": avg_hold, } @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 ]