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
@@ -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