mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-25 08:18:05 +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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user