mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-22 15:18:04 +00:00
507 lines
18 KiB
Python
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
|