Files
All-in-one-Financial-Analysis/atlas-terminal/server/routers/portfolio.py
T

507 lines
18 KiB
Python

"""Portfolio router -- position management, OCR screenshot upload, summary."""
import asyncio
import json
import os
import time
import uuid
from pathlib import Path
from threading import Lock
from typing import List
import logging
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Query
from pydantic import BaseModel, Field
from server.core.factory import get_data_gateway
from server.models.schemas import (
PortfolioPosition,
PortfolioPositionCreate,
PortfolioSummary,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# Simple file-based persistence (production would use Supabase / Postgres)
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
_QUOTE_CACHE_TTL_SECONDS = 60
_QUOTE_CACHE: dict[str, tuple[float, dict]] = {}
_QUOTE_CACHE_LOCK = Lock()
class PositionUpdateRequest(BaseModel):
quantity: float = Field(..., gt=0)
avg_price: float = Field(..., ge=0)
exchange: str = ""
class OcrRecalculateRequest(BaseModel):
account_currency: str = "USD"
position: dict
selected_exchange: str = ""
def _load_positions() -> List[dict]:
"""Load positions from the JSON store."""
if not _PORTFOLIO_FILE.exists():
return []
try:
with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
logger.exception("portfolio endpoint failed")
return []
def _save_positions(positions: List[dict]) -> None:
"""Persist positions to the JSON store."""
_PORTFOLIO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(_PORTFOLIO_FILE, "w", encoding="utf-8") as f:
json.dump(positions, f, ensure_ascii=False, indent=2)
def _get_current_quote(ticker: str, exchange: str = "") -> dict:
"""Fetch the latest market quote for *ticker* with a short in-process TTL cache."""
try:
import yfinance as yf
from server.services.exchange_resolver import resolve_exchange_option
option = resolve_exchange_option(ticker, exchange or None) or {}
yf_ticker = str(option.get("yf_ticker") or ticker).upper()
now = time.monotonic()
with _QUOTE_CACHE_LOCK:
cached = _QUOTE_CACHE.get(yf_ticker)
if cached and now - cached[0] < _QUOTE_CACHE_TTL_SECONDS:
return cached[1]
t = yf.Ticker(yf_ticker)
price = None
fast = getattr(t, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if not price or float(price) <= 0:
hist = t.history(period="1d")
if hist is not None and not hist.empty:
price = float(hist["Close"].iloc[-1])
quote = {
"price": float(price) if price and float(price) > 0 else None,
"currency": str(option.get("currency") or "").upper(),
"yf_ticker": yf_ticker,
}
with _QUOTE_CACHE_LOCK:
_QUOTE_CACHE[yf_ticker] = (now, quote)
return quote
except Exception:
return {"price": None, "currency": "", "yf_ticker": ticker.upper()}
async def _get_current_quote_async(ticker: str, exchange: str, semaphore: asyncio.Semaphore) -> dict:
async with semaphore:
return await asyncio.to_thread(_get_current_quote, ticker, exchange)
def _history_range_for_window(window: int) -> str:
if window <= 31:
return "1mo"
if window <= 100:
return "3mo"
if window <= 190:
return "6mo"
return "1y"
def _resolved_history_symbol(position: dict) -> str:
try:
from server.services.exchange_resolver import resolve_exchange_option
ticker = str(position.get("ticker", "")).upper()
option = resolve_exchange_option(ticker, str(position.get("exchange", "")) or None) or {}
return str(option.get("yf_ticker") or ticker).upper()
except Exception:
return str(position.get("ticker", "")).upper()
def _daily_returns_from_bars(bars: list) -> dict[str, float]:
closes: list[tuple[str, float]] = []
for bar in bars:
close = getattr(bar, "close", None)
day = getattr(bar, "date", None)
if close is None or day is None:
continue
try:
closes.append((str(day), float(close)))
except (TypeError, ValueError):
continue
closes.sort(key=lambda item: item[0])
returns: dict[str, float] = {}
for idx in range(1, len(closes)):
prev = closes[idx - 1][1]
curr = closes[idx][1]
if prev:
returns[closes[idx][0]] = (curr - prev) / prev
return returns
def _pairwise_corr(a: dict[str, float], b: dict[str, float]) -> float | None:
common = sorted(set(a).intersection(b))
if len(common) < 2:
return None
xs = [a[day] for day in common]
ys = [b[day] for day in common]
if len(set(xs)) <= 1 or len(set(ys)) <= 1:
return None
try:
import numpy as np
value = float(np.corrcoef(xs, ys)[0, 1])
if value != value:
return None
return round(value, 3)
except Exception:
return None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/positions",
response_model=List[PortfolioPosition],
summary="List portfolio positions",
)
async def list_positions():
"""Return all portfolio positions (without live pricing)."""
try:
positions = _load_positions()
return [PortfolioPosition(**p) for p in positions]
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to load positions: {exc}") from exc
@router.post(
"/positions",
response_model=PortfolioPosition,
summary="Add or update a portfolio position",
)
async def add_position(pos: PortfolioPositionCreate):
"""Add a new position. If ticker exists, update quantity/avg/currency."""
try:
positions = _load_positions()
ticker = pos.ticker.upper()
existing = next((p for p in positions if str(p.get("ticker", "")).upper() == ticker), None)
if existing is not None:
existing["company_name"] = pos.company_name or existing.get("company_name", "")
existing["quantity"] = float(pos.quantity)
existing["avg_price"] = float(pos.avg_price)
existing["currency"] = pos.currency or existing.get("currency", "USD")
existing["exchange"] = pos.exchange or existing.get("exchange", "")
existing["source"] = pos.source or existing.get("source", "manual")
saved = existing
else:
saved = {
"id": str(uuid.uuid4()),
"ticker": ticker,
"company_name": pos.company_name,
"quantity": pos.quantity,
"avg_price": pos.avg_price,
"currency": pos.currency,
"exchange": pos.exchange,
"source": pos.source,
}
positions.append(saved)
_save_positions(positions)
return PortfolioPosition(**saved)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc
@router.delete(
"/positions/{position_id}",
summary="Remove a portfolio position",
)
async def remove_position(position_id: str):
"""Delete a position by its unique ID."""
try:
positions = _load_positions()
original_len = len(positions)
positions = [p for p in positions if p.get("id") != position_id]
if len(positions) == original_len:
raise HTTPException(status_code=404, detail=f"Position {position_id} not found.")
_save_positions(positions)
return {"deleted": position_id}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to remove position: {exc}") from exc
@router.put(
"/positions/{position_id}",
response_model=PortfolioPosition,
summary="Update quantity/avg price for a position",
)
async def update_position(position_id: str, body: PositionUpdateRequest):
"""Update an existing position by unique ID."""
try:
positions = _load_positions()
updated = None
for p in positions:
if p.get("id") == position_id:
p["quantity"] = float(body.quantity)
p["avg_price"] = float(body.avg_price)
if body.exchange:
p["exchange"] = body.exchange
updated = p
break
if updated is None:
raise HTTPException(status_code=404, detail=f"Position {position_id} not found.")
_save_positions(positions)
return PortfolioPosition(**updated)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to update position: {exc}") from exc
@router.post(
"/ocr",
summary="OCR screenshot with smart reverse-engineering",
)
async def ocr_screenshot(
file: UploadFile = File(...),
x_gemini_api_key: str | None = Header(default=None),
):
"""Screenshot -> OCR extraction -> market-validated reverse-engineered positions."""
try:
from server.services.screenshot_ocr import process_portfolio_screenshot
image_bytes = await file.read()
api_key = (x_gemini_api_key or "").strip() or os.getenv("GOOGLE_API_KEY", "").strip()
result = await process_portfolio_screenshot(api_key, image_bytes)
if result.get("error"):
return {"error": result.get("error"), "positions": [], "count": 0, "warnings": []}
result["count"] = len(result.get("positions") or [])
return result
except Exception as exc:
raise HTTPException(status_code=500, detail=f"OCR processing failed: {exc}") from exc
@router.get("/exchange-options/{ticker}", summary="Available exchange options for a ticker")
async def exchange_options(ticker: str):
from server.services.exchange_resolver import get_exchange_options
return {"ticker": ticker.upper(), "options": get_exchange_options(ticker)}
@router.post("/ocr/recalculate", summary="Recalculate one OCR row with selected exchange")
async def ocr_recalculate(body: OcrRecalculateRequest):
from server.services.screenshot_ocr import reverse_engineer_positions
ticker = str((body.position or {}).get("ticker", "")).upper()
if not ticker:
raise HTTPException(status_code=400, detail="position.ticker is required")
payload = {"account_currency": body.account_currency, "positions": [body.position]}
overrides = {ticker: body.selected_exchange} if body.selected_exchange else {}
recalculated = reverse_engineer_positions(payload, overrides)
if not recalculated:
raise HTTPException(status_code=400, detail="Failed to recalculate position")
return {"position": recalculated[0]}
@router.post(
"/screenshot",
summary="Upload screenshot for OCR analysis",
)
async def upload_screenshot(file: UploadFile = File(...)):
"""Accept a screenshot image (PNG/JPG) and attempt to extract portfolio
positions via OCR. Returns the recognised text and any parsed positions.
This is a best-effort feature; parsing accuracy depends on the
screenshot layout.
"""
try:
contents = await file.read()
# Try pytesseract for OCR
try:
from PIL import Image
import pytesseract
import io
image = Image.open(io.BytesIO(contents))
text = pytesseract.image_to_string(image)
except ImportError:
text = "(OCR not available -- install pytesseract and Pillow)"
except Exception as ocr_err:
text = f"(OCR failed: {ocr_err})"
return {
"filename": file.filename,
"size": len(contents),
"ocr_text": text,
"parsed_positions": [], # Future: parse text into positions
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Screenshot processing failed: {exc}") from exc
@router.get("/risk", summary="Portfolio risk metrics (VaR, Sharpe, MDD)")
async def portfolio_risk():
"""Compute portfolio risk metrics from current positions."""
try:
from server.services.risk_metrics import compute_portfolio_risk
positions = _load_positions()
if not positions:
return {"error": "No positions in portfolio"}
result = compute_portfolio_risk(positions)
return result
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Risk metrics failed: {exc}") from exc
@router.get("/correlation", summary="Cross-asset correlation matrix")
async def portfolio_correlation(
user_id: str = "local",
window: int = Query(90, ge=20, le=365),
):
"""Compute pairwise daily-return correlations for current portfolio positions."""
try:
positions = _load_positions()
symbols = []
for position in positions:
ticker = str(position.get("ticker", "")).upper()
if ticker and ticker not in symbols:
symbols.append(ticker)
if len(symbols) < 2:
return {
"user_id": user_id,
"available": False,
"message": "Add at least two positions to compute correlation.",
"window": window,
"tickers": symbols,
"matrix": [],
}
gateway = get_data_gateway()
range_key = _history_range_for_window(window)
by_ticker = {str(p.get("ticker", "")).upper(): p for p in positions if p.get("ticker")}
semaphore = asyncio.Semaphore(5)
async def fetch_history(ticker: str):
async with semaphore:
symbol = _resolved_history_symbol(by_ticker[ticker])
try:
return ticker, await gateway.history(symbol, range_key)
except Exception:
logger.exception("correlation history failed for %s", ticker)
return ticker, None
histories = await asyncio.gather(*[fetch_history(ticker) for ticker in symbols])
returns_by_ticker = {
ticker: _daily_returns_from_bars(history.bars)
for ticker, history in histories
if history is not None and getattr(history, "bars", None)
}
usable = [ticker for ticker in symbols if ticker in returns_by_ticker]
if len(usable) < 2:
return {
"user_id": user_id,
"available": False,
"message": "Not enough price history to compute correlation.",
"window": window,
"tickers": usable,
"matrix": [],
}
matrix = []
for left in usable:
row = []
for right in usable:
row.append(1.0 if left == right else _pairwise_corr(returns_by_ticker[left], returns_by_ticker[right]))
matrix.append(row)
return {
"user_id": user_id,
"available": True,
"window": window,
"range": range_key,
"tickers": usable,
"matrix": matrix,
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Correlation matrix failed: {exc}") from exc
@router.get(
"/summary",
response_model=PortfolioSummary,
summary="Portfolio summary with current prices",
)
async def portfolio_summary():
"""Return all positions enriched with current market prices,
market values, and P&L.
"""
try:
positions = _load_positions()
enriched: List[PortfolioPosition] = []
total_value = 0.0
total_cost = 0.0
quote_semaphore = asyncio.Semaphore(8)
quotes = await asyncio.gather(
*[
_get_current_quote_async(str(p.get("ticker", "")), str(p.get("exchange", "")), quote_semaphore)
for p in positions
],
return_exceptions=True,
)
for p, quote_result in zip(positions, quotes):
quote = quote_result if isinstance(quote_result, dict) else {"price": None, "currency": "", "yf_ticker": p.get("ticker", "")}
ticker = p.get("ticker", "")
quantity = float(p.get("quantity", 0))
avg_price = float(p.get("avg_price", 0))
cost = quantity * avg_price
total_cost += cost
current_price = quote.get("price")
market_value = (quantity * current_price) if current_price else None
pnl = (market_value - cost) if market_value is not None else None
pnl_pct = (pnl / cost * 100) if (pnl is not None and cost > 0) else None
if market_value is not None:
total_value += market_value
enriched.append(PortfolioPosition(
id=p.get("id"),
ticker=ticker,
company_name=p.get("company_name", ""),
quantity=quantity,
avg_price=avg_price,
currency=p.get("currency", "USD"),
exchange=p.get("exchange", ""),
source=p.get("source", "manual"),
current_price=current_price,
stock_currency=quote.get("currency") or p.get("currency", "USD"),
yf_ticker=quote.get("yf_ticker") or ticker,
market_value=market_value,
pnl=pnl,
pnl_pct=round(pnl_pct, 2) if pnl_pct is not None else None,
))
total_pnl = total_value - total_cost
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else None
return PortfolioSummary(
total_value=round(total_value, 2),
total_cost=round(total_cost, 2),
total_pnl=round(total_pnl, 2),
total_pnl_pct=round(total_pnl_pct, 2) if total_pnl_pct is not None else None,
positions=enriched,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Portfolio summary failed: {exc}") from exc