Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Learning engine: expand default weights from 3 to all 9 strategies - Learning engine: resolve placeholder strategy_id with DB lookup - Learning engine: pass strategy_sources from trade execution - Trade executor: respect trading:paused Redis flag in RiskManager - Portfolio sync: compute actual daily P&L from day-start snapshot - Portfolio API: cumulative P&L from first snapshot, read pause flag - Portfolio metrics: compute max drawdown and avg hold duration - Add strategy_sources field to TradeExecution schema - Add dev_mode config (TRADING_DEV_MODE) to bypass auth for local dev - Dashboard: VITE_DEV_MODE bypasses ProtectedRoute and 401 redirects - Vite proxy target configurable via VITE_API_TARGET - Add top-level README.md and remaining-work-plan.md - Update CLAUDE.md with correct counts and remove stale TODOs - 404 tests passing Made-with: Cursor
274 lines
9 KiB
Python
274 lines
9 KiB
Python
"""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
|
|
]
|