mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 00:38:04 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user