trading/services/api_gateway/routes/portfolio.py
Viktor Barzin d36ae40df1
feat: productionize local service — fix signal pipeline, lower thresholds, add company-name ticker extraction
- Point Ollama to local instance via host.docker.internal, use gemma3 model
- Remove Docker Ollama service (using host's Ollama instead)
- Add company-name-to-ticker mapping (Apple→AAPL, Tesla→TSLA, etc.) for RSS articles
- Lower signal thresholds for faster feedback with paper trading:
  - FinBERT confidence: 0.6→0.4, signal strength: 0.3→0.15
  - News strategy: article_count 2→1, confidence 0.5→0.3, score ±0.3→±0.15
- Fix market data BarSet access bug (BarSet.__contains__ returns False incorrectly)
- Fix market data SIP feed error by switching to IEX feed for free Alpaca accounts
- Fix nginx proxy routing for /api/auth/* to api-gateway /auth/*
- Add seed_sample_data script
- Update tests for new thresholds and alpaca mock modules
2026-02-22 22:17:26 +00:00

205 lines
7 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
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
]