feat: deliver multi-asset analytics, OCR exchange selection, and heatmap UX

Add asset-type aware market/overview flows, portfolio OCR reverse-engineering with exchange overrides, and interactive index heatmap features. Update README with recent updates and wire backend/frontend APIs for FX matrix, exchange options, and improved portfolio editing flows.

Made-with: Cursor
This commit is contained in:
shawnkim1997
2026-03-21 17:08:00 +00:00
parent e225c05cc8
commit 38c56a5a43
35 changed files with 3224 additions and 287 deletions
+3 -1
View File
@@ -44,7 +44,7 @@ app.add_middleware(
)
# --- Mount routers ---
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider # noqa: E402
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets # noqa: E402
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
@@ -55,10 +55,12 @@ app.include_router(estimates.router, prefix="/api/estimates", tags=["Estimates"]
app.include_router(news.router, prefix="/api/news", tags=["News"])
app.include_router(crypto.router, prefix="/api/crypto", tags=["Crypto"])
app.include_router(fx.router, prefix="/api/fx", tags=["FX"])
app.include_router(markets.router, prefix="/api/markets", tags=["Markets"])
app.include_router(portfolio.router, prefix="/api/portfolio", tags=["Portfolio"])
app.include_router(technical.router, prefix="/api/technical", tags=["Technical"])
app.include_router(earnings.router, prefix="/api/earnings", tags=["Earnings"])
app.include_router(insider.router, prefix="/api/insider", tags=["Insider Trading"])
app.include_router(screener.router, prefix="/api/screener", tags=["Screener"])
@app.get("/health")
+2
View File
@@ -75,6 +75,7 @@ class PortfolioPositionCreate(BaseModel):
quantity: float
avg_price: float
currency: str = "USD"
exchange: str = ""
source: str = "manual"
@@ -182,6 +183,7 @@ class PortfolioPosition(BaseModel):
quantity: float
avg_price: float
currency: str = "USD"
exchange: str = ""
source: str = "manual"
current_price: Optional[float] = None
market_value: Optional[float] = None
+35 -10
View File
@@ -44,19 +44,44 @@ def _fetch_fx_rate(pair: str) -> float | None:
@router.get(
"/rates",
response_model=FXRateResponse,
summary="Major FX rates",
summary="FX conversion matrix for major currencies",
)
async def fx_rates():
"""Return current exchange rates for major currency pairs
(USD/KRW, USD/JPY, EUR/USD, GBP/USD, etc.).
"""
"""Return conversion matrix (USD/GBP/EUR/JPY/KRW)."""
try:
rates: Dict[str, float] = {}
for pair in MAJOR_PAIRS:
rate = _fetch_fx_rate(pair)
if rate is not None:
rates[pair] = round(rate, 4)
return FXRateResponse(pair="MAJOR", rates=rates)
gbp_usd = _fetch_fx_rate("GBPUSD") or 1.27
eur_usd = _fetch_fx_rate("EURUSD") or 1.08
usd_jpy = _fetch_fx_rate("USDJPY") or 149.5
usd_krw = _fetch_fx_rate("USDKRW") or 1370.0
rates = {
"USD_USD": 1.0,
"USD_GBP": 1 / gbp_usd,
"USD_EUR": 1 / eur_usd,
"USD_JPY": usd_jpy,
"USD_KRW": usd_krw,
"GBP_USD": gbp_usd,
"GBP_GBP": 1.0,
"GBP_EUR": gbp_usd / eur_usd,
"GBP_JPY": gbp_usd * usd_jpy,
"GBP_KRW": gbp_usd * usd_krw,
"EUR_USD": eur_usd,
"EUR_GBP": eur_usd / gbp_usd,
"EUR_EUR": 1.0,
"EUR_JPY": eur_usd * usd_jpy,
"EUR_KRW": eur_usd * usd_krw,
"JPY_USD": 1 / usd_jpy,
"JPY_GBP": 1 / (gbp_usd * usd_jpy),
"JPY_EUR": 1 / (eur_usd * usd_jpy),
"JPY_JPY": 1.0,
"JPY_KRW": usd_krw / usd_jpy,
"KRW_USD": 1 / usd_krw,
"KRW_GBP": 1 / (gbp_usd * usd_krw),
"KRW_EUR": 1 / (eur_usd * usd_krw),
"KRW_JPY": usd_jpy / usd_krw,
"KRW_KRW": 1.0,
}
return FXRateResponse(pair="MATRIX", rates=rates)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"FX rates failed: {exc}") from exc
+145 -8
View File
@@ -3,6 +3,7 @@
from typing import Any, Dict, List
from fastapi import APIRouter, Query
from server.utils.ticker_utils import AssetType, detect_asset_type
router = APIRouter()
@@ -51,12 +52,92 @@ async def market_indices():
return []
@router.get("/overview", summary="Global market overview")
async def market_overview():
try:
from server.services.market_overview import get_market_overview
return await get_market_overview()
except Exception as e:
return {"error": str(e), "data": None}
@router.get("/sectors", summary="Sector heatmap")
async def sector_heatmap():
try:
from server.services.sector_heatmap import get_sector_heatmap
return await get_sector_heatmap()
except Exception as e:
return {"error": str(e), "data": None}
@router.get("/overview/{ticker}", summary="Asset-type aware overview")
async def market_overview_by_ticker(ticker: str):
"""Detect asset type and return overview payload for that type."""
try:
asset_type = detect_asset_type(ticker)
if asset_type == AssetType.ETF:
from server.services.etf_analysis import get_etf_overview
return {"asset_type": AssetType.ETF.value, "data": await get_etf_overview(ticker)}
if asset_type == AssetType.COMMODITY_FUTURE:
from server.services.commodity_analysis import get_commodity_overview
return {"asset_type": AssetType.COMMODITY_FUTURE.value, "data": await get_commodity_overview(ticker)}
if asset_type == AssetType.CRYPTO:
return {"asset_type": AssetType.CRYPTO.value, "data": {"name": ticker.upper()}}
if asset_type == AssetType.INDEX:
return {"asset_type": AssetType.INDEX.value, "data": {"name": ticker.upper()}}
from server.services.etf_analysis import get_equity_overview
return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)}
except Exception as e:
return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None}
@router.get("/etf/{ticker}/holdings", summary="ETF top holdings")
async def etf_holdings(ticker: str):
try:
from server.services.etf_analysis import get_etf_holdings
return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)}
except Exception as e:
return {"error": str(e), "ticker": ticker.upper(), "holdings": []}
@router.get("/commodity/{ticker}/seasonal", summary="Commodity monthly seasonal pattern")
async def commodity_seasonal(ticker: str):
try:
from server.services.commodity_analysis import get_commodity_overview
data = await get_commodity_overview(ticker)
return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})}
except Exception as e:
return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}}
@router.get("/commodity/{ticker}/correlations", summary="Commodity correlations")
async def commodity_correlations(ticker: str):
try:
from server.services.commodity_analysis import compute_commodity_correlations
return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)}
except Exception as e:
return {"error": str(e), "ticker": ticker.upper(), "correlations": {}}
@router.get("/sector/{ticker}", summary="Sector and industry classification")
async def sector_industry(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
city = (info.get("city") or "").strip()
state = (info.get("state") or "").strip()
country = (info.get("country") or "").strip()
hq_parts = [p for p in [city, state, country] if p]
hq = ", ".join(hq_parts) if hq_parts else "N/A"
return {
"sector": info.get("sector", "N/A"),
"industry": info.get("industry", "N/A"),
@@ -67,6 +148,13 @@ async def sector_industry(ticker: str):
"fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")),
"fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")),
"current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")),
"ceo": info.get("companyOfficers", [{}])[0].get("name") if isinstance(info.get("companyOfficers"), list) and info.get("companyOfficers") else None,
"employees": info.get("fullTimeEmployees"),
"founded": info.get("founded"),
"hq": hq,
"website": info.get("website"),
"ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"),
"description": info.get("longBusinessSummary"),
}
except Exception:
return {"sector": "N/A", "industry": "N/A"}
@@ -134,7 +222,15 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated
@router.get("/health/{ticker}", summary="DuPont, Altman Z-Score, Red Flags")
async def financial_health(ticker: str):
fallback = {"ticker": ticker.upper(), "dupont": {}, "altman_z": None, "red_flags": []}
fallback = {
"ticker": ticker.upper(),
"dupont": {},
"altman_z": None,
"current_ratio": None,
"interest_coverage": None,
"debt_to_equity": None,
"red_flags": [],
}
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
@@ -199,20 +295,61 @@ async def financial_health(ticker: str):
rev_ta = rev / ta
altman_z = round(1.2 * wc_ta + 1.4 * re_ta + 3.3 * ebit_ta + 0.6 * mc_tl + 1.0 * rev_ta, 2)
# Additional health metrics for overview cards
current_ratio = None
if bs is not None and not bs.empty:
col_bs = bs.columns[0]
ca = _safe_float(bs.loc["Current Assets"][col_bs]) if "Current Assets" in bs.index else 0
cl = _safe_float(bs.loc["Current Liabilities"][col_bs]) if "Current Liabilities" in bs.index else 0
current_ratio = (ca / cl) if cl else None
if current_ratio is None:
info_cr = _safe_float(info.get("currentRatio"), None)
current_ratio = info_cr if info_cr and info_cr > 0 else None
interest_coverage = None
if fin is not None and not fin.empty:
col_fin = fin.columns[0]
ebit = _safe_float(fin.loc["EBIT"][col_fin]) if "EBIT" in fin.index else _safe_float(fin.loc["Operating Income"][col_fin]) if "Operating Income" in fin.index else None
int_exp = _safe_float(fin.loc["Interest Expense"][col_fin]) if "Interest Expense" in fin.index else None
if ebit is not None and int_exp is not None and int_exp != 0:
interest_coverage = abs(ebit / int_exp)
debt_to_equity = None
if bs is not None and not bs.empty:
col_bs = bs.columns[0]
total_debt = _safe_float(bs.loc["Total Debt"][col_bs], None) if "Total Debt" in bs.index else None
if total_debt is None:
ltd = _safe_float(bs.loc["Long Term Debt"][col_bs], 0) if "Long Term Debt" in bs.index else 0
std = _safe_float(bs.loc["Current Debt"][col_bs], 0) if "Current Debt" in bs.index else 0
total_debt = ltd + std if (ltd or std) else None
equity = total_equity if total_equity else None
if total_debt is not None and equity:
debt_to_equity = total_debt / equity
if debt_to_equity is None:
de_info = _safe_float(info.get("debtToEquity"), None)
if de_info is not None:
debt_to_equity = de_info / 100 if de_info > 10 else de_info
# Red Flags
red_flags = []
cr = _safe_float(info.get("currentRatio"))
de = _safe_float(info.get("debtToEquity"))
if cr and cr < 1.0:
red_flags.append(f"Low current ratio: {cr:.2f}")
if de and de > 200:
red_flags.append(f"High debt-to-equity: {de:.1f}%")
if current_ratio is not None and current_ratio < 1.0:
red_flags.append(f"Low current ratio: {current_ratio:.2f}")
if debt_to_equity is not None and debt_to_equity > 2.0:
red_flags.append(f"High debt-to-equity: {debt_to_equity:.2f}")
if npm and npm < 0:
red_flags.append("Negative profit margin")
if roe and roe < 0:
red_flags.append("Negative ROE")
return {"ticker": ticker.upper(), "dupont": dupont, "altman_z": altman_z, "red_flags": red_flags}
return {
"ticker": ticker.upper(),
"dupont": dupont,
"altman_z": altman_z,
"current_ratio": round(current_ratio, 2) if current_ratio is not None else None,
"interest_coverage": round(interest_coverage, 2) if interest_coverage is not None else None,
"debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None,
"red_flags": red_flags,
}
except Exception as e:
return fallback
+14
View File
@@ -0,0 +1,14 @@
"""Markets router for index constituent heatmap."""
from fastapi import APIRouter, Query
router = APIRouter()
@router.get("/heatmap/{index_name}", summary="Index constituent heatmap data")
async def heatmap(index_name: str, top_n: int = Query(default=50, ge=1, le=200)):
from server.services.heatmap import get_heatmap_data
stocks = await get_heatmap_data(index_name, top_n)
return {"index": index_name, "count": len(stocks), "stocks": stocks}
+114 -14
View File
@@ -1,11 +1,13 @@
"""Portfolio router -- position management, OCR screenshot upload, summary."""
import json
import os
import uuid
from pathlib import Path
from typing import List
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi import APIRouter, HTTPException, UploadFile, File, Header
from pydantic import BaseModel, Field
from server.models.schemas import (
PortfolioPosition,
@@ -19,6 +21,18 @@ router = APIRouter()
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
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():
@@ -77,24 +91,36 @@ async def list_positions():
@router.post(
"/positions",
response_model=PortfolioPosition,
summary="Add a portfolio position",
summary="Add or update a portfolio position",
)
async def add_position(pos: PortfolioPositionCreate):
"""Add a new position to the portfolio."""
"""Add a new position. If ticker exists, update quantity/avg/currency."""
try:
positions = _load_positions()
new_pos = {
"id": str(uuid.uuid4()),
"ticker": pos.ticker.upper(),
"company_name": pos.company_name,
"quantity": pos.quantity,
"avg_price": pos.avg_price,
"currency": pos.currency,
"source": pos.source,
}
positions.append(new_pos)
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(**new_pos)
return PortfolioPosition(**saved)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc
@@ -121,6 +147,79 @@ async def remove_position(position_id: str):
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",
@@ -209,6 +308,7 @@ async def portfolio_summary():
quantity=quantity,
avg_price=avg_price,
currency=p.get("currency", "USD"),
exchange=p.get("exchange", ""),
source=p.get("source", "manual"),
current_price=current_price,
market_value=market_value,
+34
View File
@@ -0,0 +1,34 @@
"""Screener and backtesting router."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
@router.post("/search")
async def search_stocks(filters: dict):
"""Run stock screener with simple filters."""
try:
from server.services.screener import run_screener
return await run_screener(filters)
except Exception as e:
return {"error": str(e), "data": []}
@router.post("/backtest")
async def backtest(body: dict):
"""Run strategy backtest for one ticker."""
try:
from server.services.backtester import run_backtest
return await run_backtest(
ticker=body.get("ticker", ""),
strategy=body.get("strategy", "buy_and_hold"),
start_date=body.get("start_date", "2024-01-01"),
end_date=body.get("end_date", "2026-01-01"),
)
except Exception as e:
return {"error": str(e)}
@@ -0,0 +1,47 @@
"""Backtesting service for simple strategies."""
from __future__ import annotations
async def run_backtest(
ticker: str,
strategy: str,
start_date: str,
end_date: str,
initial_capital: float = 10000.0,
) -> dict:
"""Run a basic backtest for selected strategy."""
import yfinance as yf
import ta
df = yf.Ticker(ticker.upper()).history(start=start_date, end=end_date)
if df is None or df.empty:
return {"error": "No price data"}
if strategy == "sma_crossover":
df["sma50"] = ta.trend.sma_indicator(df["Close"], 50)
df["sma200"] = ta.trend.sma_indicator(df["Close"], 200)
df["signal"] = (df["sma50"] > df["sma200"]).astype(int)
elif strategy == "rsi_oversold":
df["rsi"] = ta.momentum.rsi(df["Close"], 14)
df["signal"] = 0
df.loc[df["rsi"] < 30, "signal"] = 1
df.loc[df["rsi"] > 70, "signal"] = 0
else:
df["signal"] = 1
df["returns"] = df["Close"].pct_change().fillna(0)
df["strategy_returns"] = (df["returns"] * df["signal"].shift(1)).fillna(0)
cumulative = (1 + df["strategy_returns"]).cumprod()
benchmark = (1 + df["returns"]).cumprod()
return {
"total_return_pct": round((float(cumulative.iloc[-1]) - 1) * 100, 2),
"benchmark_return_pct": round((float(benchmark.iloc[-1]) - 1) * 100, 2),
"alpha": round((float(cumulative.iloc[-1]) - float(benchmark.iloc[-1])) * 100, 2),
"max_drawdown_pct": round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2),
"sharpe_ratio": round(float(df["strategy_returns"].mean() / (df["strategy_returns"].std() + 1e-10) * (252 ** 0.5)), 2),
"equity_curve": [float(x) for x in cumulative.tolist()],
"benchmark_curve": [float(x) for x in benchmark.tolist()],
"dates": df.index.strftime("%Y-%m-%d").tolist(),
}
@@ -0,0 +1,99 @@
"""Commodity future analysis helpers."""
from __future__ import annotations
from typing import Any
from server.utils.ticker_utils import COMMODITY_FUTURES
COMMODITY_RELATED: dict[str, list[str]] = {
"GC=F": ["GLD", "SI=F", "DX-Y.NYB", "^TNX"],
"CL=F": ["USO", "BZ=F", "XLE", "^GSPC"],
"SI=F": ["SLV", "GC=F", "HG=F", "^GSPC"],
"NG=F": ["UNG", "CL=F", "XLE"],
}
def _get_related_assets(ticker: str) -> list[str]:
return COMMODITY_RELATED.get(ticker.upper(), [])
async def compute_commodity_correlations(ticker: str, period: str = "1y") -> dict:
import yfinance as yf
t = ticker.upper()
related = _get_related_assets(t)
if not related:
return {}
all_tickers = [t] + related
data = yf.download(all_tickers, period=period, auto_adjust=True, progress=False)
if data is None or data.empty:
return {}
close = data["Close"] if "Close" in data else data
returns = close.pct_change().dropna()
if returns is None or returns.empty or t not in returns.columns:
return {}
corr = returns.corr()
result = {}
for r in related:
if r in corr.columns:
result[r] = round(float(corr.loc[t, r]), 2)
return result
async def get_commodity_overview(ticker: str) -> dict:
import yfinance as yf
t = ticker.upper()
y = yf.Ticker(t)
info = y.info or {}
hist_1y = y.history(period="1y", auto_adjust=True)
hist_10y = y.history(period="10y", auto_adjust=True)
seasonal = {}
if hist_10y is not None and not hist_10y.empty:
monthly = hist_10y["Close"].resample("ME").last().pct_change().dropna()
for month in range(1, 13):
m = monthly[monthly.index.month == month]
seasonal[month] = round(float(m.mean()) * 100, 2) if len(m) > 0 else 0
related = _get_related_assets(t)
related_cards = []
if related:
data = yf.download(related, period="5d", auto_adjust=True, progress=False)
close = data["Close"] if hasattr(data, "columns") and "Close" in data.columns else data
if close is not None:
try:
if hasattr(close, "columns"):
for sym in related:
if sym not in close.columns:
continue
s = close[sym].dropna()
if len(s) < 1:
continue
cur = float(s.iloc[-1])
prev = float(s.iloc[-2]) if len(s) > 1 else cur
pct = ((cur - prev) / prev * 100) if prev else 0
related_cards.append({"symbol": sym, "price": round(cur, 2), "change_pct": round(pct, 2)})
else:
s = close.dropna()
if len(s) >= 1:
cur = float(s.iloc[-1])
prev = float(s.iloc[-2]) if len(s) > 1 else cur
pct = ((cur - prev) / prev * 100) if prev else 0
related_cards.append({"symbol": related[0], "price": round(cur, 2), "change_pct": round(pct, 2)})
except Exception:
pass
return {
"name": COMMODITY_FUTURES.get(t, info.get("shortName", t)),
"price": info.get("regularMarketPrice") or info.get("currentPrice"),
"open_interest": info.get("openInterest"),
"volume": info.get("volume"),
"high_52w": info.get("fiftyTwoWeekHigh"),
"low_52w": info.get("fiftyTwoWeekLow"),
"seasonal_pattern": seasonal,
"related_assets": related_cards,
"correlation_matrix": await compute_commodity_correlations(t),
"asset_class": "commodity_future",
}
@@ -0,0 +1,24 @@
"""Build compact copilot context with asset-type aware fields."""
from __future__ import annotations
def build_copilot_context(asset_type: str, data: dict) -> str:
parts: list[str] = []
if asset_type == "etf":
parts.append(f"[Asset Type] ETF — {data.get('category')}")
parts.append(f"[ETF] AUM: {data.get('aum')}, Expense: {data.get('expense_ratio')}")
r = data.get("returns") or {}
parts.append(f"[Performance] YTD: {r.get('ytd')}%, 1Y: {r.get('1y')}%")
elif asset_type == "commodity_future":
parts.append(f"[Asset Type] Commodity Future — {data.get('name')}")
parts.append(f"[Commodity] Open Interest: {data.get('open_interest')}")
seasonal = data.get("seasonal_pattern") or {}
if seasonal:
best_month = max(seasonal, key=lambda k: seasonal[k])
worst_month = min(seasonal, key=lambda k: seasonal[k])
parts.append(f"[Seasonal] Best month: {best_month}, Worst: {worst_month}")
else:
parts.append("[Asset Type] Equity")
parts.append(f"[Sector] {data.get('sector')}")
return "\n".join(parts)
@@ -0,0 +1,162 @@
"""ETF and equity-like overview helpers."""
from __future__ import annotations
import math
from typing import Any
def _safe_num(v: Any) -> float | None:
try:
f = float(v)
if math.isnan(f) or math.isinf(f):
return None
return f
except Exception:
return None
def _compute_sharpe(returns) -> float | None:
if returns is None or len(returns) < 2:
return None
std = returns.std()
if not std:
return None
return round(float((returns.mean() / std) * (252**0.5)), 2)
def _compute_sortino(returns) -> float | None:
if returns is None or len(returns) < 2:
return None
downside = returns[returns < 0]
if downside is None or len(downside) < 2:
return None
std = downside.std()
if not std:
return None
return round(float((returns.mean() / std) * (252**0.5)), 2)
def _max_drawdown(returns) -> float | None:
if returns is None or len(returns) < 2:
return None
curve = (1 + returns).cumprod()
dd = (curve / curve.cummax()) - 1
return round(float(dd.min()) * 100, 2)
async def get_benchmark_comparison(ticker: str, benchmark: str = "SPY", period: str = "1y") -> dict:
import yfinance as yf
data = yf.download([ticker.upper(), benchmark.upper()], period=period, auto_adjust=True, progress=False)
if data is None or data.empty:
return {}
close = data["Close"] if "Close" in data else data
if close is None or close.empty:
return {}
t_col = ticker.upper()
b_col = benchmark.upper()
if t_col not in close.columns or b_col not in close.columns:
return {}
close = close[[t_col, b_col]].dropna()
if close.empty:
return {}
normalized = close / close.iloc[0] * 100
return {
"dates": normalized.index.strftime("%Y-%m-%d").tolist(),
"ticker_values": [float(x) for x in normalized[t_col].tolist()],
"benchmark_values": [float(x) for x in normalized[b_col].tolist()],
"benchmark": benchmark.upper(),
}
async def get_etf_holdings(ticker: str, top_n: int = 10) -> list[dict]:
import yfinance as yf
t = yf.Ticker(ticker.upper())
out = []
try:
holdings = getattr(t, "fund_top_holdings", None)
if holdings is not None and not holdings.empty:
for _, row in holdings.head(top_n).iterrows():
out.append(
{
"symbol": row.get("symbol") or row.get("holdingName") or "",
"name": row.get("holdingName") or row.get("symbol") or "",
"weight_pct": _safe_num(row.get("holdingPercent")),
}
)
except Exception:
pass
return out
async def get_etf_overview(ticker: str) -> dict:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
hist = t.history(period="5y", auto_adjust=True)
def period_return(days: int) -> float | None:
if hist is None or hist.empty or len(hist) <= days:
return None
cur = _safe_num(hist["Close"].iloc[-1])
prev = _safe_num(hist["Close"].iloc[-days])
if cur is None or prev is None or prev == 0:
return None
return round((cur / prev - 1) * 100, 2)
ytd_days = 0
if hist is not None and not hist.empty:
ytd_days = int((hist.index.year == hist.index[-1].year).sum())
returns = hist["Close"].pct_change().dropna() if hist is not None and not hist.empty else None
return {
"name": info.get("longName") or info.get("shortName", ticker.upper()),
"category": info.get("category") or info.get("fundFamily") or "N/A",
"aum": _safe_num(info.get("totalAssets")),
"expense_ratio": _safe_num(info.get("annualReportExpenseRatio")),
"nav": _safe_num(info.get("navPrice")),
"inception": info.get("fundInceptionDate"),
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
"returns": {
"1m": period_return(21),
"3m": period_return(63),
"6m": period_return(126),
"ytd": period_return(ytd_days) if ytd_days else None,
"1y": period_return(252),
"3y": period_return(756),
"5y": period_return(1260),
},
"holdings": await get_etf_holdings(ticker, top_n=10),
"risk": {
"sharpe": _compute_sharpe(returns),
"sortino": _compute_sortino(returns),
"max_drawdown": _max_drawdown(returns),
"volatility": round(float(returns.std()) * (252**0.5) * 100, 2) if returns is not None and len(returns) > 1 else None,
},
"benchmark_comparison": await get_benchmark_comparison(ticker, "SPY", "1y"),
}
async def get_equity_overview(ticker: str) -> dict:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
return {
"name": info.get("longName") or info.get("shortName", ticker.upper()),
"sector": info.get("sector"),
"industry": info.get("industry"),
"market_cap": _safe_num(info.get("marketCap")),
"pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
"dividend_yield": _safe_num(info.get("dividendYield")),
"beta": _safe_num(info.get("beta")),
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
"description": info.get("longBusinessSummary"),
}
@@ -0,0 +1,51 @@
"""Resolve multi-exchange tickers for OCR/import workflows."""
from __future__ import annotations
MULTI_EXCHANGE_TICKERS = {
"SMSN": [
{"exchange": "LSE (GDR)", "yf_ticker": "SMSN.L", "currency": "USD", "default": True},
{"exchange": "KRX (Korea)", "yf_ticker": "005930.KS", "currency": "KRW"},
{"exchange": "OTC (US)", "yf_ticker": "SSNLF", "currency": "USD"},
],
"NOV": [
{"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True},
{"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"},
],
"NVO": [
{"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True},
{"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"},
],
}
T212_TICKER_MAP = {
"SMSN": "SMSN.L",
"SMSN.L": "SMSN.L",
"NOV": "NVO",
"NVDA": "NVDA",
"TSLA": "TSLA",
"NVO": "NVO",
"PLTR": "PLTR",
"IONQ": "IONQ",
"IREN": "IREN",
}
def get_exchange_options(ticker: str) -> list[dict]:
return MULTI_EXCHANGE_TICKERS.get((ticker or "").upper(), [])
def resolve_ticker_with_exchange(ticker: str, selected_exchange: str | None = None) -> str:
t = (ticker or "").upper().strip()
options = get_exchange_options(t)
if not options:
return T212_TICKER_MAP.get(t, t)
if selected_exchange:
for opt in options:
if opt.get("exchange") == selected_exchange:
return opt.get("yf_ticker", t)
for opt in options:
if opt.get("default"):
return opt.get("yf_ticker", t)
return options[0].get("yf_ticker", t)
+71
View File
@@ -0,0 +1,71 @@
"""Heatmap data service for index constituents."""
from __future__ import annotations
from typing import Any
import pandas as pd
import yfinance as yf
async def get_index_constituents(index_name: str) -> list[str]:
name = (index_name or "").lower().strip()
if name == "sp500":
try:
table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0]
return table["Symbol"].astype(str).str.replace(".", "-", regex=False).tolist()
except Exception:
return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "BRK-B", "TSLA", "UNH", "XOM"]
if name == "nasdaq100":
try:
table = pd.read_html("https://en.wikipedia.org/wiki/Nasdaq-100")[4]
return table["Ticker"].astype(str).tolist()
except Exception:
return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "AVGO", "COST", "NFLX"]
if name == "kospi":
return [
"005930.KS", "000660.KS", "035420.KS", "051910.KS", "006400.KS",
"035720.KS", "068270.KS", "028260.KS", "105560.KS", "012330.KS",
"055550.KS", "034730.KS", "003550.KS", "015760.KS", "066570.KS",
"032830.KS", "096770.KS", "009150.KS", "003670.KS", "018260.KS",
]
if name == "ftse100":
return ["SHEL.L", "AZN.L", "HSBA.L", "ULVR.L", "BP.L", "GSK.L", "RIO.L", "LSEG.L"]
return []
def _calc_change_pct(ticker: str) -> float:
try:
hist = yf.Ticker(ticker).history(period="2d")
if hist is not None and len(hist) >= 2:
prev = float(hist["Close"].iloc[-2])
cur = float(hist["Close"].iloc[-1])
if prev != 0:
return round((cur - prev) / prev * 100, 2)
except Exception:
pass
return 0.0
async def get_heatmap_data(index_name: str, top_n: int = 50) -> list[dict[str, Any]]:
tickers = (await get_index_constituents(index_name))[: max(top_n, 1)]
out: list[dict[str, Any]] = []
for ticker in tickers:
try:
info = yf.Ticker(ticker).info or {}
mcap = info.get("marketCap")
if not mcap or float(mcap) <= 0:
continue
out.append(
{
"ticker": ticker.replace(".KS", "").replace(".L", ""),
"name": info.get("shortName") or info.get("longName") or ticker,
"sector": info.get("sector") or "Other",
"market_cap": float(mcap),
"change_pct": _calc_change_pct(ticker),
}
)
except Exception:
continue
return sorted(out, key=lambda x: x["market_cap"], reverse=True)
@@ -0,0 +1,57 @@
"""Market overview service: indices, commodities, bonds, crypto, FX."""
from __future__ import annotations
INDICES = {
"S&P 500": "^GSPC",
"NASDAQ": "^IXIC",
"Dow Jones": "^DJI",
"KOSPI": "^KS11",
"Nikkei 225": "^N225",
"FTSE 100": "^FTSE",
"DAX": "^GDAXI",
"Hang Seng": "^HSI",
}
COMMODITIES = {"Gold": "GC=F", "Oil (WTI)": "CL=F", "Silver": "SI=F", "Nat Gas": "NG=F"}
BONDS = {"US 10Y": "^TNX", "US 2Y": "^IRX"}
CRYPTO = {"Bitcoin": "BTC-USD", "Ethereum": "ETH-USD"}
FX = {"EUR/USD": "EURUSD=X", "GBP/USD": "GBPUSD=X", "USD/JPY": "USDJPY=X", "USD/KRW": "USDKRW=X"}
POPULAR_ETFS = {"SPY": "SPY", "QQQ": "QQQ", "GLD": "GLD", "TLT": "TLT", "EEM": "EEM"}
async def get_market_overview() -> dict:
"""Fetch concise multi-asset market overview from yfinance."""
import yfinance as yf
results = {}
for category, tickers in [
("indices", INDICES),
("commodities", COMMODITIES),
("bonds", BONDS),
("crypto", CRYPTO),
("fx", FX),
("popular_etfs", POPULAR_ETFS),
]:
cat_data = []
for name, symbol in tickers.items():
try:
t = yf.Ticker(symbol)
hist = t.history(period="5d")
if hist is None or hist.empty:
continue
current = float(hist["Close"].iloc[-1])
prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else current
change_pct = ((current - prev) / prev * 100) if prev else 0.0
cat_data.append(
{
"name": name,
"symbol": symbol,
"price": round(current, 2),
"change_pct": round(change_pct, 2),
}
)
except Exception:
continue
results[category] = cat_data
return results
@@ -0,0 +1,47 @@
"""Stock screener service."""
from __future__ import annotations
async def run_screener(filters: dict, universe: str = "sp500") -> list[dict]:
"""Run simple screening against S&P 500 universe."""
import pandas as pd
import yfinance as yf
try:
table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0]
tickers = table["Symbol"].astype(str).tolist()
except Exception:
tickers = []
results = []
for ticker in tickers:
try:
info = yf.Ticker(ticker).info or {}
pe = info.get("forwardPE")
mcap = info.get("marketCap")
sector = info.get("sector")
div = info.get("dividendYield")
if filters.get("pe_max") and ((pe or 9999) > filters["pe_max"]):
continue
if filters.get("sector") and sector != filters["sector"]:
continue
if filters.get("market_cap_min") and ((mcap or 0) < filters["market_cap_min"]):
continue
if filters.get("div_yield_min") and (((div or 0) * 100) < filters["div_yield_min"]):
continue
results.append(
{
"ticker": ticker,
"name": info.get("shortName", ""),
"sector": sector or "",
"market_cap": mcap,
"pe": pe,
"div_yield": (div * 100) if div is not None else None,
"price": info.get("currentPrice") or info.get("regularMarketPrice"),
"change_pct": info.get("regularMarketChangePercent"),
}
)
except Exception:
continue
return results
+264 -118
View File
@@ -1,145 +1,291 @@
"""Portfolio screenshot OCR using Gemini Vision.
"""Portfolio OCR with smart reverse-engineering against live market prices."""
Analyses screenshots from Trading 212 or Interactive Brokers (IBKR) portfolio
views and extracts structured position data (ticker, quantity, market value,
gain/loss) via the Gemini multimodal API.
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any, Dict, List, Optional
from typing import Any, Optional
import yfinance as yf
from server.services.exchange_resolver import resolve_ticker_with_exchange
def _get_vision_model(api_key: str) -> Any:
"""Configure Gemini and return a multimodal model."""
import google.generativeai as genai
genai.configure(api_key=api_key)
return genai.GenerativeModel("gemini-2.0-flash")
SCREENSHOT_OCR_PROMPT = """
Analyze this screenshot of a stock trading app portfolio (Trading 212, IBKR, Webull, etc).
CRITICAL INSTRUCTIONS:
- Extract ALL positions visible in the image. There are likely 5-15 positions.
- Do NOT stop after the first position. Keep going until every position is captured.
- You MUST extract ALL positions visible in the screenshot.
- If you see 8 positions in the image, you MUST return exactly 8 objects in the positions array.
- The account currency shown at the top (£, $, €) may differ from individual stock currencies.
def _build_prompt() -> str:
"""Return the extraction prompt for portfolio screenshots."""
return """You are a financial data extraction assistant.
For EACH position, extract:
1. ticker: Stock ticker symbol exactly as shown (e.g., "IREN", "NVDA", "SMSN")
2. name: Company name
3. displayed_value: The monetary value shown (number only, no currency symbol)
4. displayed_currency: Currency symbol next to the value (£, $, €, ₩, ¥)
5. weight_pct: Portfolio weight % if shown (e.g., 28.66)
6. gain_loss_pct: P&L percentage if shown (e.g., -16.27 or +8.80)
7. gain_loss_amount: P&L monetary amount (number only)
8. shares: Number of shares if visible (preserve ALL decimals)
9. avg_price: Average purchase price if visible (number only)
10. avg_price_currency: Currency of avg price
Analyse this portfolio screenshot from a brokerage app (Trading 212,
Interactive Brokers, or similar).
ALSO extract portfolio summary from the top of the screen:
- total_value: Total portfolio value (number only)
- total_currency: Currency symbol (£, $, €)
- cost_basis: Cost basis if shown (number only)
- unrealised_pnl: Unrealised P&L (number only)
- unrealised_pnl_pct: P&L percentage
Extract every visible position and return ONLY a valid JSON object with
this structure:
{
"broker": "Trading 212" | "IBKR" | "Unknown",
"currency": "USD" | "GBP" | "EUR" | ...,
"positions": [
{
"ticker": "AAPL",
"name": "Apple Inc.",
"quantity": 10.5,
"avg_price": 150.00,
"current_price": 175.00,
"market_value": 1837.50,
"gain_loss": 262.50,
"gain_loss_pct": 16.67
}
],
"total_value": 50000.00,
"total_gain_loss": 5000.00
}
Rules:
- Use null for any field you cannot read.
- quantity may be fractional (e.g. 0.125 shares).
- Monetary values should be plain numbers, no currency symbols.
- If the screenshot is not a portfolio view, return {"error": "Not a portfolio screenshot"}.
- Output ONLY the JSON object, nothing else.
Return ONLY valid JSON, no other text.
"""
def _norm_currency(sym: str | None, default: str = "USD") -> str:
s = (sym or "").strip().upper()
mapping = {"£": "GBP", "$": "USD", "": "EUR", "": "KRW", "¥": "JPY"}
return mapping.get(s, s or default)
def analyze_portfolio_screenshot(
api_key: str,
image_bytes: bytes,
) -> Dict[str, Any]:
"""Extract portfolio positions from a brokerage screenshot.
Uses Gemini Vision (multimodal) to read the image and return
structured position data.
def _resolve_ticker(t212_ticker: str) -> str:
return resolve_ticker_with_exchange(t212_ticker, None)
Parameters
----------
api_key:
Google Gemini API key.
image_bytes:
Raw bytes of the screenshot image (PNG, JPEG, etc.).
Returns
-------
dict
Parsed portfolio data with ``broker``, ``currency``,
``positions`` (list), ``total_value``, and ``total_gain_loss``.
On error, returns ``{"error": "<description>"}``.
"""
def _get_realtime_price(ticker: str) -> Optional[dict]:
try:
yf_ticker = _resolve_ticker(ticker)
t = yf.Ticker(yf_ticker)
info = t.info or {}
price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
currency = (info.get("currency") or "USD").upper()
if price is None:
fast = getattr(t, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if price is None:
hist = t.history(period="1d")
if hist is not None and not hist.empty:
price = float(hist["Close"].iloc[-1])
if price is None:
return None
return {"price": float(price), "currency": currency, "yf_ticker": yf_ticker}
except Exception:
return None
def _get_fx_rate(from_currency: str, to_currency: str) -> float:
f = _norm_currency(from_currency)
t = _norm_currency(to_currency)
if f == t:
return 1.0
try:
pair = f"{f}{t}=X"
hist = yf.Ticker(pair).history(period="1d")
if hist is not None and not hist.empty:
return float(hist["Close"].iloc[-1])
rev = f"{t}{f}=X"
hist2 = yf.Ticker(rev).history(period="1d")
if hist2 is not None and not hist2.empty:
return 1.0 / float(hist2["Close"].iloc[-1])
except Exception:
pass
fallback = {
("GBP", "USD"): 1.27, ("USD", "GBP"): 0.79,
("EUR", "USD"): 1.08, ("USD", "EUR"): 0.93,
("USD", "KRW"): 1370.0, ("KRW", "USD"): 0.00073,
("USD", "JPY"): 149.5, ("JPY", "USD"): 0.0067,
}
return fallback.get((f, t), 1.0)
def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, str] | None = None) -> list[dict]:
account_currency = _norm_currency(ocr_result.get("account_currency"), "USD")
out: list[dict] = []
for pos in ocr_result.get("positions", []) or []:
ticker = (pos.get("ticker") or "").upper().strip()
if not ticker:
continue
selected_exchange = (exchange_overrides or {}).get(ticker)
yf_ticker = resolve_ticker_with_exchange(ticker, selected_exchange)
mkt = _get_realtime_price(yf_ticker)
if not mkt:
out.append({
"ticker": ticker,
"name": pos.get("name") or ticker,
"quantity": pos.get("shares"),
"avg_price": pos.get("avg_price"),
"avg_price_currency": _norm_currency(pos.get("avg_price_currency"), "USD"),
"current_price": None,
"stock_currency": "USD",
"account_currency": account_currency,
"current_value_account": pos.get("displayed_value"),
"pnl_pct": pos.get("gain_loss_pct"),
"confidence": "low",
"method": "ocr_only",
"yf_ticker": yf_ticker,
})
continue
stock_price = float(mkt["price"])
stock_currency = _norm_currency(mkt["currency"], "USD")
shares = pos.get("shares")
confidence = "high"
method = "ocr_shares"
if not shares:
displayed_value = pos.get("displayed_value")
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
if displayed_value and float(displayed_value) > 0:
v_stock = float(displayed_value) * _get_fx_rate(displayed_currency, stock_currency)
shares = v_stock / stock_price if stock_price > 0 else None
confidence = "medium"
method = "reverse_from_value"
else:
shares = None
confidence = "low"
method = "unknown"
avg_price = pos.get("avg_price")
avg_currency = _norm_currency(pos.get("avg_price_currency"), stock_currency)
avg_price_stock = None
avg_method = "ocr_avg"
if avg_price:
avg_price_stock = float(avg_price) * _get_fx_rate(avg_currency, stock_currency)
else:
gain_loss_pct = pos.get("gain_loss_pct")
gain_loss_amount = pos.get("gain_loss_amount")
displayed_value = pos.get("displayed_value")
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
# Method 1: reverse from PnL %
try:
if gain_loss_pct is not None and stock_price is not None:
gl_pct = float(gain_loss_pct)
denom = 1 + (gl_pct / 100.0)
if abs(denom) > 1e-9:
avg_price_stock = stock_price / denom
avg_method = "reverse_from_pnl_pct"
except Exception:
avg_price_stock = None
# Method 2: reverse from displayed value and pnl amount
if avg_price_stock is None:
try:
if gain_loss_amount is not None and displayed_value is not None and shares and float(shares) > 0:
cost_basis_display = float(displayed_value) - float(gain_loss_amount)
fx = _get_fx_rate(displayed_currency, stock_currency)
cost_basis_stock = cost_basis_display * fx
avg_price_stock = cost_basis_stock / float(shares)
avg_method = "reverse_from_pnl_amount"
except Exception:
avg_price_stock = None
# Method 3: fallback to current price
if avg_price_stock is None:
avg_price_stock = stock_price
avg_method = "fallback_current_price"
if shares and pos.get("displayed_value"):
displayed = float(pos["displayed_value"])
displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency)
calc_value = float(shares) * stock_price * _get_fx_rate(stock_currency, displayed_currency)
err = abs(calc_value - displayed) / displayed * 100 if displayed > 0 else 999
if err > 10 and stock_price > 0:
shares = displayed * _get_fx_rate(displayed_currency, stock_currency) / stock_price
confidence = "medium"
method = "reverse_recalculated"
total_pnl = None
pnl_pct = pos.get("gain_loss_pct")
if shares and avg_price_stock and stock_price:
pnl_per_share = stock_price - avg_price_stock
total_pnl = pnl_per_share * float(shares)
pnl_pct = (pnl_per_share / avg_price_stock) * 100 if avg_price_stock > 0 else None
cur_val = None
if shares:
cur_val = float(shares) * stock_price * _get_fx_rate(stock_currency, account_currency)
# If avg is reconstructed and shares are available, promote confidence.
if confidence == "medium" and avg_method in {"reverse_from_pnl_pct", "reverse_from_pnl_amount"} and shares:
confidence = "high"
out.append({
"ticker": ticker,
"name": pos.get("name") or ticker,
"quantity": round(float(shares), 6) if shares else None,
"avg_price": round(float(avg_price_stock), 4) if avg_price_stock is not None else avg_price,
"avg_price_currency": stock_currency,
"current_price": round(stock_price, 2),
"stock_currency": stock_currency,
"account_currency": account_currency,
"current_value_account": round(cur_val, 2) if cur_val is not None else None,
"total_pnl": round(float(total_pnl), 2) if total_pnl is not None else None,
"pnl_pct": round(float(pnl_pct), 2) if pnl_pct is not None else None,
"weight_pct": pos.get("weight_pct"),
"confidence": confidence,
"method": method,
"avg_method": avg_method,
"yf_ticker": yf_ticker,
})
return out
def _detect_mime(image_bytes: bytes) -> str:
if image_bytes[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if image_bytes[:4] == b"RIFF":
return "image/webp"
return "image/png"
def _parse_llm_json(text: str) -> dict:
raw = (text or "").strip()
raw = re.sub(r"^```json\s*", "", raw, flags=re.I)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
return json.loads(raw.strip())
async def process_portfolio_screenshot(api_key: str, image_bytes: bytes) -> dict:
if not api_key or not api_key.strip():
return {"error": "API key is required."}
if not image_bytes:
return {"error": "No image data provided."}
try:
model = _get_vision_model(api_key)
except Exception as e:
return {"error": f"Failed to initialise Gemini Vision: {e}"}
prompt = _build_prompt()
# Build multimodal content: image + text prompt
try:
import google.generativeai as genai
# Detect MIME type from magic bytes
mime_type = "image/png"
if image_bytes[:3] == b"\xff\xd8\xff":
mime_type = "image/jpeg"
elif image_bytes[:4] == b"\x89PNG":
mime_type = "image/png"
elif image_bytes[:4] == b"RIFF":
mime_type = "image/webp"
image_part = {"mime_type": mime_type, "data": image_bytes}
response = model.generate_content(
[image_part, prompt],
generation_config={"temperature": 0.0, "max_output_tokens": 4096},
genai.configure(api_key=api_key)
model = genai.GenerativeModel("gemini-2.0-flash")
image_part = {"mime_type": _detect_mime(image_bytes), "data": image_bytes}
response = await asyncio.to_thread(
model.generate_content,
[image_part, SCREENSHOT_OCR_PROMPT],
generation_config={"temperature": 0.0, "max_output_tokens": 8192},
)
raw = (response.text or "").strip()
if not raw:
return {"error": "Gemini returned an empty response."}
# Strip markdown code fences if present
raw = re.sub(r"^```\s*json\s*", "", raw)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```\s*$", "", raw)
raw = raw.strip()
result: Dict[str, Any] = json.loads(raw)
# Validate structure
if "error" in result:
return result
if "positions" not in result:
return {"error": "Response missing 'positions' key.", "raw": raw}
# Coerce numeric fields
for pos in result.get("positions", []):
for key in ("quantity", "avg_price", "current_price", "market_value", "gain_loss", "gain_loss_pct"):
val = pos.get(key)
if val is not None:
try:
pos[key] = float(val)
except (TypeError, ValueError):
pos[key] = None
return result
parsed = _parse_llm_json(response.text or "")
except json.JSONDecodeError:
return {"error": "Failed to parse JSON from Gemini response.", "raw": raw}
return {"error": "Failed to parse OCR result."}
except Exception as e:
return {"error": f"Screenshot analysis failed: {e}"}
return {"error": f"OCR model call failed: {e}"}
enriched = reverse_engineer_positions(parsed)
warnings = []
for p in enriched:
if p.get("confidence") == "low":
warnings.append(f"{p.get('ticker')}: Low confidence (market verify failed)")
if p.get("method") == "reverse_recalculated":
warnings.append(f"{p.get('ticker')}: Quantity recalculated due to >10% mismatch")
return {
"account_currency": _norm_currency(parsed.get("account_currency"), "USD"),
"total_value": {
"amount": parsed.get("total_value"),
"currency": _norm_currency(parsed.get("account_currency"), "USD"),
},
"positions": enriched,
"warnings": warnings,
"raw_ocr": parsed,
}
@@ -0,0 +1,37 @@
"""Sector performance heatmap service."""
from __future__ import annotations
SECTOR_ETFS = {
"Technology": "XLK",
"Healthcare": "XLV",
"Financials": "XLF",
"Consumer Disc.": "XLY",
"Industrials": "XLI",
"Energy": "XLE",
"Utilities": "XLU",
"Materials": "XLB",
"Real Estate": "XLRE",
"Comm. Services": "XLC",
"Consumer Staples": "XLP",
}
async def get_sector_heatmap() -> list[dict]:
"""Return daily percent change for major US sector ETFs."""
import yfinance as yf
results = []
for sector, etf in SECTOR_ETFS.items():
try:
hist = yf.Ticker(etf).history(period="2d")
if hist is None or len(hist) < 2:
continue
prev = float(hist["Close"].iloc[-2])
cur = float(hist["Close"].iloc[-1])
change = ((cur - prev) / prev * 100) if prev else 0.0
results.append({"sector": sector, "etf": etf, "change_pct": round(change, 2)})
except Exception:
continue
return results
@@ -5,6 +5,7 @@ Yahoo Finance-compatible identifiers with the correct market suffix,
and provides the static lookup tables for companies and sectors.
"""
from enum import Enum
from typing import List, Tuple
# ---------------------------------------------------------------------------
@@ -41,6 +42,41 @@ MARKET_OPTIONS: List[str] = [
"UK (LSE)",
]
class AssetType(str, Enum):
EQUITY = "equity"
ETF = "etf"
COMMODITY_FUTURE = "commodity_future"
CRYPTO = "crypto"
INDEX = "index"
COMMODITY_FUTURES: dict[str, str] = {
"GC=F": "Gold", "SI=F": "Silver", "PL=F": "Platinum", "PA=F": "Palladium",
"CL=F": "Crude Oil (WTI)", "BZ=F": "Brent Crude", "NG=F": "Natural Gas",
"HO=F": "Heating Oil", "RB=F": "Gasoline",
"ZC=F": "Corn", "ZS=F": "Soybeans", "ZW=F": "Wheat",
"KC=F": "Coffee", "CT=F": "Cotton", "SB=F": "Sugar",
"CC=F": "Cocoa", "OJ=F": "Orange Juice",
"LE=F": "Live Cattle", "HE=F": "Lean Hogs",
"HG=F": "Copper", "ALI=F": "Aluminum",
}
POPULAR_COMMODITY_ETFS: dict[str, str] = {
"GLD": "SPDR Gold Trust", "IAU": "iShares Gold Trust", "SLV": "iShares Silver Trust",
"PPLT": "abrdn Platinum ETF", "USO": "United States Oil Fund", "UNG": "United States Natural Gas Fund",
"XLE": "Energy Select Sector SPDR", "VDE": "Vanguard Energy ETF", "DBC": "Invesco DB Commodity Tracking",
"GSG": "iShares S&P GSCI Commodity", "PDBC": "Invesco Optimum Yield Diversified Commodity",
"COM": "Direxion Auspice Broad Commodity", "DBA": "Invesco DB Agriculture Fund",
"WEAT": "Teucrium Wheat Fund", "CORN": "Teucrium Corn Fund", "SOYB": "Teucrium Soybean Fund",
"SPY": "S&P 500 ETF", "QQQ": "Nasdaq 100 ETF", "IWM": "Russell 2000 ETF",
"EEM": "Emerging Markets ETF", "VWO": "Vanguard FTSE Emerging Markets",
"TLT": "20+ Year Treasury Bond ETF", "HYG": "High Yield Corporate Bond ETF",
"LQD": "Investment Grade Corporate Bond ETF", "ARKK": "ARK Innovation ETF",
"XLK": "Technology Select Sector SPDR", "XLF": "Financial Select Sector SPDR",
"XLV": "Health Care Select Sector SPDR",
}
# ---------------------------------------------------------------------------
# Sector / industry peer groups (top-down analysis)
# ---------------------------------------------------------------------------
@@ -120,3 +156,28 @@ def infer_market_from_ticker(ticker: str) -> str:
if t.endswith(".L"):
return "UK (LSE)"
return "US (S&P/Dow/Nasdaq)"
def detect_asset_type(ticker: str) -> AssetType:
"""Detect asset type by ticker pattern and quoteType fallback."""
t = (ticker or "").strip().upper()
if not t:
return AssetType.EQUITY
if t.endswith("=F") or t in COMMODITY_FUTURES:
return AssetType.COMMODITY_FUTURE
if t.endswith("-USD") or t.endswith("-KRW"):
return AssetType.CRYPTO
if t.startswith("^"):
return AssetType.INDEX
try:
import yfinance as yf
info = yf.Ticker(t).info or {}
quote_type = str(info.get("quoteType", "")).upper()
if quote_type in {"ETF", "MUTUALFUND"}:
return AssetType.ETF
except Exception:
pass
if t in POPULAR_COMMODITY_ETFS:
return AssetType.ETF
return AssetType.EQUITY