feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration

Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend.

Frontend (Next.js 14):
- 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings
- Terminal Noir dark theme with custom Tailwind config
- TradingView Lightweight Charts for candlestick/volume
- Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF
- Financial Statements table with YoY growth badges and margin rows
- SEC EDGAR inline filing viewer with section tabs
- News split-view with iframe article embedding
- Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages
- Earnings beat/miss visualization
- AI Copilot chat panel with Gemini integration

Backend (FastAPI):
- 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx
- Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators
- yfinance + yahooquery data sources with fallback pattern
- SQLite caching layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
@@ -0,0 +1,158 @@
"""Crypto price fetcher -- Bithumb (KRW) and Binance (USD) public APIs.
Provides standalone fetcher functions that can be used by the crypto router
or any other service that needs cryptocurrency price data.
"""
import time
from typing import Any, Dict, List, Optional
import requests
# ---------------------------------------------------------------------------
# Top 20 coins
# ---------------------------------------------------------------------------
TOP_20_COINS: List[str] = [
"BTC", "ETH", "BNB", "XRP", "SOL", "ADA", "DOGE", "AVAX", "DOT", "MATIC",
"LINK", "SHIB", "TRX", "UNI", "ATOM", "LTC", "ETC", "XLM", "NEAR", "APT",
]
# ---------------------------------------------------------------------------
# In-memory cache
# ---------------------------------------------------------------------------
_cache: Dict[str, Any] = {}
_cache_ts: Dict[str, float] = {}
_CACHE_TTL = 30 # seconds
def _get_cached(key: str) -> Optional[Any]:
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
return _cache[key]
return None
def _set_cached(key: str, value: Any) -> None:
_cache[key] = value
_cache_ts[key] = time.time()
# ---------------------------------------------------------------------------
# Bithumb (KRW)
# ---------------------------------------------------------------------------
def fetch_bithumb_all_krw(symbols: Optional[List[str]] = None) -> Dict[str, float]:
"""Fetch KRW prices from Bithumb ALL_KRW endpoint.
Uses the bulk endpoint (https://api.bithumb.com/public/ticker/ALL_KRW)
to avoid per-symbol rate limits.
Parameters
----------
symbols:
Coin symbols to include. Defaults to TOP_20_COINS.
Returns
-------
dict
Mapping of symbol -> KRW price (float).
"""
cached = _get_cached("bithumb_all_krw")
if cached is not None:
return cached
symbols = symbols or TOP_20_COINS
url = "https://api.bithumb.com/public/ticker/ALL_KRW"
prices: Dict[str, float] = {}
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
body = resp.json()
if body.get("status") != "0000":
return prices
data = body.get("data", {})
for sym in symbols:
coin_data = data.get(sym.upper())
if coin_data and isinstance(coin_data, dict):
closing = coin_data.get("closing_price")
if closing:
prices[sym.upper()] = float(closing)
except Exception:
pass
_set_cached("bithumb_all_krw", prices)
return prices
# ---------------------------------------------------------------------------
# Binance (USD)
# ---------------------------------------------------------------------------
def fetch_binance_prices(symbols: Optional[List[str]] = None) -> Dict[str, float]:
"""Fetch USD prices from Binance ticker/price endpoint.
Parameters
----------
symbols:
Coin symbols to include. Defaults to TOP_20_COINS.
Returns
-------
dict
Mapping of symbol -> USD price (float).
"""
cached = _get_cached("binance_prices")
if cached is not None:
return cached
symbols = symbols or TOP_20_COINS
url = "https://api.binance.com/api/v3/ticker/price"
prices: Dict[str, float] = {}
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
lookup = {item["symbol"]: float(item["price"]) for item in data}
for sym in symbols:
key = f"{sym.upper()}USDT"
if key in lookup:
prices[sym.upper()] = lookup[key]
except Exception:
pass
_set_cached("binance_prices", prices)
return prices
# ---------------------------------------------------------------------------
# Combined
# ---------------------------------------------------------------------------
def fetch_top20_prices(
symbols: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Return top-20 crypto prices with both KRW and USD.
Each entry contains:
- symbol: str
- price_usd: float | None
- price_krw: float | None
Parameters
----------
symbols:
Override default TOP_20_COINS list.
"""
symbols = symbols or TOP_20_COINS
usd = fetch_binance_prices(symbols)
krw = fetch_bithumb_all_krw(symbols)
results: List[Dict[str, Any]] = []
for sym in symbols:
results.append({
"symbol": sym.upper(),
"price_usd": usd.get(sym.upper()),
"price_krw": krw.get(sym.upper()),
})
return results
@@ -0,0 +1,247 @@
"""Discounted Cash Flow (DCF) valuation engine.
Implements multiple DCF model variants:
- Simple 5-year single-stage DCF
- 10-year two-stage DCF (growth fades from Stage 1 to terminal)
- Excel-style full DCF (EV -> Equity -> per-share value)
Also includes Damodaran sector WACC reference data and smart-default
assumption generation from CAPM beta and analyst growth estimates.
"""
from typing import Dict, List, Optional
from server.utils.safe_float import _safe_float
try:
from scipy.optimize import brentq
except ImportError:
brentq = None # type: ignore[assignment]
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Damodaran sector WACC reference (approx. 2024/2025 baseline)
# ---------------------------------------------------------------------------
DAMODARAN_WACC: Dict[str, float] = {
"Software": 8.5,
"Retail": 7.5,
"Hardware": 9.0,
"Financials": 8.0,
"Healthcare": 7.2,
"Consumer": 7.5,
"Technology": 8.5,
"Industrial": 7.8,
"Energy": 8.2,
"Utilities": 6.5,
}
DAMODARAN_ERP_PCT: float = 4.6
"""US Equity Risk Premium (Damodaran estimate)."""
DAMODARAN_RF_PCT: float = 4.2
"""10-year risk-free rate (Damodaran estimate)."""
# ---------------------------------------------------------------------------
# DCF models
# ---------------------------------------------------------------------------
def dcf_intrinsic_value(
fcf: float,
wacc: float,
terminal_growth: float,
fcf_growth: float,
years: int = 5,
) -> float:
"""5-year single-stage DCF returning enterprise value.
Projects FCF at *fcf_growth* for *years* periods, then computes a
Gordon Growth terminal value discounted at *wacc*.
"""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= terminal_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, years + 1):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
terminal_fcf = fcft
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
pv += tv / ((1 + wacc) ** years)
return pv
def dcf_10y_2stage(
fcf: float,
wacc: float,
term_growth: float,
fcf_growth: float,
) -> float:
"""10-year two-stage DCF.
Stage 1 (Y1-5): FCF grows at *fcf_growth*.
Stage 2 (Y6-10): growth linearly fades to *term_growth*.
Terminal value at Y10 using Gordon Growth.
"""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= term_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, 6):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
for t in range(6, 11):
fade = (t - 6) / 4.0
g_t = fcf_growth + fade * (term_growth - fcf_growth)
fcft *= (1 + g_t)
pv += fcft / ((1 + wacc) ** t)
tv = fcft * (1 + term_growth) / (wacc - term_growth)
pv += tv / ((1 + wacc) ** 10)
return pv
def excel_style_dcf(
fcf_base: float,
wacc: float,
term_growth: float,
fcf_growth: float,
total_debt: float,
cash: float,
shares: float,
) -> Dict[str, Optional[float]]:
"""Full DCF: EV -> Equity Value -> Value per Share.
Returns
-------
dict
Keys: ``ev``, ``equity_value``, ``value_per_share``, ``shares``.
"""
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
equity = ev - total_debt + cash
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
value_per_share = (equity / shares_safe) if shares_safe else None
return {
"ev": ev,
"equity_value": equity,
"value_per_share": value_per_share,
"shares": shares_safe,
}
# ---------------------------------------------------------------------------
# WACC helpers
# ---------------------------------------------------------------------------
def reverse_dcf(
current_price: float,
shares: float,
total_debt: float,
cash: float,
wacc: float,
term_growth: float,
fcf_base: float,
projection_years: int = 10,
) -> Optional[float]:
"""Solve for the implied FCF growth rate that produces the current market price.
Uses Brent's root-finding method (scipy.optimize.brentq) to find the
growth rate *g* such that ``excel_style_dcf(..., g)["value_per_share"] == current_price``.
Returns
-------
float | None
Implied annual FCF growth rate (decimal), or None if no solution is found.
"""
if brentq is None:
return None
if shares <= 0 or current_price <= 0 or wacc <= term_growth:
return None
def _objective(g: float) -> float:
result = excel_style_dcf(fcf_base, wacc, term_growth, g, total_debt, cash, shares)
vps = result.get("value_per_share")
if vps is None:
return -current_price
return vps - current_price
try:
implied_growth = brentq(_objective, -0.50, 1.00, xtol=1e-6, maxiter=200)
return round(implied_growth, 6)
except (ValueError, RuntimeError):
return None
def _damodaran_wacc_for_sector(sector: str) -> float:
"""Map a yfinance sector string to closest Damodaran WACC (default 8.0%)."""
if not sector:
return 8.0
s = (sector or "").lower()
if "software" in s or "technology" in s or "internet" in s:
return DAMODARAN_WACC.get("Software", 8.5)
if "hardware" in s or "semiconductor" in s:
return DAMODARAN_WACC.get("Hardware", 9.0)
if "retail" in s or "consumer" in s or "cyclical" in s:
return DAMODARAN_WACC.get("Retail", 7.5)
if "financial" in s or "bank" in s or "insurance" in s:
return DAMODARAN_WACC.get("Financials", 8.0)
if "health" in s or "pharma" in s:
return DAMODARAN_WACC.get("Healthcare", 7.2)
if "industrial" in s:
return DAMODARAN_WACC.get("Industrial", 7.8)
if "energy" in s or "oil" in s:
return DAMODARAN_WACC.get("Energy", 8.2)
if "utilities" in s:
return DAMODARAN_WACC.get("Utilities", 6.5)
return 8.0
# ---------------------------------------------------------------------------
# Smart defaults
# ---------------------------------------------------------------------------
def get_dcf_smart_defaults(ticker: str) -> Dict[str, float]:
"""Auto-generate WACC, Terminal Growth, and FCF Growth from CAPM beta and analyst estimates.
Returns
-------
dict
Keys: ``wacc_pct``, ``term_growth_pct``, ``fcf_growth_pct``.
"""
out: Dict[str, float] = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
beta = info.get("beta")
if beta is None:
beta = 1.0
else:
try:
beta = float(beta)
except (TypeError, ValueError):
beta = 1.0
risk_free = 4.0
market_risk_premium = 5.0
calculated_wacc = risk_free + (beta * market_risk_premium)
out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1)
out["term_growth_pct"] = 2.5
rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth")
if rev_growth is not None:
try:
g = float(rev_growth)
out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1)
except (TypeError, ValueError):
pass
return out
except Exception:
return out
@@ -0,0 +1,188 @@
"""Financial health metrics: DuPont, Altman Z, Piotroski F-Score, radar, and sector-specific.
All functions return pure data (dicts, DataFrames) with no presentation logic.
Consumers (API routers, Streamlit UI) handle display and charting.
"""
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Radar normalisation
# ---------------------------------------------------------------------------
def _radar_norm(
roe_pct: Optional[float],
current_ratio: Optional[float],
asset_turnover: Optional[float],
equity_mult: Optional[float],
rev_yoy_pct: Optional[float],
) -> List[float]:
"""Normalise five raw metrics to 0-100 for radar chart display."""
def n_roe(x: Optional[float]) -> float:
return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
def n_cr(x: Optional[float]) -> float:
return min(100, max(0, x / 3 * 100)) if x is not None else 50
def n_at(x: Optional[float]) -> float:
return min(100, max(0, x * 50)) if x is not None else 50
def n_em(x: Optional[float]) -> float:
return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
def n_yoy(x: Optional[float]) -> float:
return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50
return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)]
# ---------------------------------------------------------------------------
# DuPont / Altman Z / Red Flags / YoY
# ---------------------------------------------------------------------------
def get_dupont_altman_redflags_yoy(ticker: str) -> Dict[str, Any]:
"""DuPont 3-step ROE, Altman Z-Score, red flags, and YoY ratio changes.
Returns
-------
dict
Keys: ``dupont`` (DataFrame), ``yoy`` (list), ``altman_z`` (float|None),
``red_flags`` (list of dicts).
"""
try:
fin, bal, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return {}
t = yf.Ticker(ticker.upper())
info = t.info or {}
col_list = fin.columns.tolist()
if col_list and str(col_list[0]).startswith("TTM"):
dates = col_list[:3]
else:
dates = sorted(col_list, reverse=True)[:3]
if not dates:
return {}
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
ebit = _get_row_series(fin, "Operating Income", "EBIT")
gross = _get_row_series(fin, "Gross Profit")
interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net")
total_assets = _get_row_series(bal, "Total Assets")
total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
current_assets = _get_row_series(bal, "Current Assets")
current_liab = _get_row_series(bal, "Current Liabilities")
retained = _get_row_series(bal, "Retained Earnings")
total_liab = _get_row_series(bal, "Total Liabilities")
market_cap = info.get("marketCap") or info.get("Market Cap")
def _v(s: Optional[pd.Series], d: Any) -> Optional[float]:
if s is None or d not in s.index:
return None
return _safe_float(s.get(d))
rows: List[Dict[str, Any]] = []
for i, d in enumerate(dates):
yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i))
r = _v(rev, d)
net_i = _v(ni, d)
ta = _v(total_assets, d)
te = _v(total_equity, d)
if ta and ta > 0 and te and te > 0 and r and r != 0:
npm = (net_i / r * 100) if net_i is not None else None
at = r / ta
em = ta / te
roe = (net_i / te * 100) if net_i else None
else:
npm = at = em = roe = None
gross_p = _v(gross, d)
gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None
op_inc = _v(ebit, d)
op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None
ca = _v(current_assets, d)
cl = _v(current_liab, d)
current_ratio = (ca / cl) if (ca and cl and cl != 0) else None
int_exp = _v(interest, d)
interest_cov: Optional[float] = None
if op_inc is not None and int_exp is not None and int_exp != 0:
_ic = op_inc / int_exp
interest_cov = round(_ic, 2) if (_ic == _ic and not pd.isna(_ic)) else None
rows.append({
"Year": yr, "Revenue": r, "Net Income": net_i,
"NPM %": round(npm, 2) if npm is not None else None,
"Asset Turnover": round(at, 4) if at is not None else None,
"Equity Mult.": round(em, 2) if em is not None else None,
"ROE %": round(roe, 2) if roe is not None else None,
"Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None,
"Operating Margin %": round(op_margin, 2) if op_margin is not None else None,
"Current Ratio": round(current_ratio, 2) if current_ratio is not None else None,
"Interest Coverage": interest_cov,
})
dupont_df = pd.DataFrame(rows)
# YoY
yoy: List[Dict[str, Any]] = []
if len(dupont_df) >= 2:
for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]:
if col not in dupont_df.columns:
continue
cur = dupont_df[col].iloc[0]
prev = dupont_df[col].iloc[1]
if cur is None or prev is None or prev == 0 or pd.isna(cur) or pd.isna(prev):
continue
if "Margin" in col or "NPM" in col or "ROE" in col:
chg_pp = cur - prev
if pd.isna(chg_pp):
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2),
"Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"})
else:
pct = (cur - prev) / abs(prev) * 100
if pd.isna(pct):
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1),
"Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"})
# Altman Z
latest_bal_d = bal.columns[0]
wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0)
ta_l = _v(total_assets, latest_bal_d)
re_l = _v(retained, latest_bal_d)
tl_l = _v(total_liab, latest_bal_d)
ebit_l = _v(ebit, fin.columns[0])
sales_l = _v(rev, fin.columns[0])
altman_z: Optional[float] = None
if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l:
a = wc / ta_l
b = (re_l or 0) / ta_l
c = (ebit_l or 0) / ta_l
dd = market_cap / tl_l
e = sales_l / ta_l
altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * dd + 1.0 * e
# Red flags
red_flags: List[Dict[str, Any]] = []
if len(dupont_df) > 0:
row0 = dupont_df.iloc[0]
cr = row0.get("Current Ratio")
if cr is not None and cr < 1.0:
red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING",
"comment": "Current assets do not cover current liabilities; liquidity risk."})
ic = row0.get("Interest Coverage")
if ic is not None and ic < 1.5:
red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING",
"comment": "EBIT barely covers interest; default risk."})
return {"dupont": dupont_df, "yoy": yoy, "altman_z": round(altman_z, 2) if altman_z is not None else None, "red_flags": red_flags}
except Exception:
return {}
@@ -0,0 +1,411 @@
"""Extended financial metrics: Piotroski F-Score, Sankey, radar, sector-specific, quarterly.
Complements :mod:`server.services.financial_metrics` with scoring models,
income-statement flow data, and quarterly momentum indicators.
"""
from typing import Any, Dict, List, Optional
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
from server.services.financial_metrics import (
_radar_norm,
get_dupont_altman_redflags_yoy,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Income Statement Sankey
# ---------------------------------------------------------------------------
def get_income_statement_sankey_data(ticker: str) -> Dict[str, float]:
"""Revenue -> COGS -> Gross Profit -> OpEx -> OpIncome -> Net Income."""
out: Dict[str, float] = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0}
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty:
return out
try:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
gross = _get_row_series(fin, "Gross Profit")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
if rev is None or len(rev) == 0:
return out
d = rev.index[0]
revenue = abs(_safe_float(rev.get(d)) or 0)
cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
if gross_val is None:
gross_val = (revenue - cogs_val) if revenue and cogs_val is not None else revenue
gross_val = abs(gross_val) if gross_val is not None else 0
op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else 0
ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else 0
opex_val = max(0, gross_val - op_inc_val) if gross_val >= op_inc_val else 0
tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val))
return {"revenue": max(revenue, 1), "cogs": min(cogs_val, revenue - 1e-6), "gross_profit": gross_val,
"opex": opex_val, "operating_income": op_inc_val, "tax_interest_other": tax_interest_other, "net_income": ni_val}
except Exception:
return out
def sankey_data_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, float]:
"""Build Sankey input from ``get_sec_financials_llm`` result."""
cur = (ai_dict or {}).get("current_yr") or {}
revenue = max(0, (cur.get("Revenue") or 0))
cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6))
gross_profit = revenue - cogs
opex = max(0, cur.get("OperatingExpenses") or 0)
operating_income = gross_profit - opex
net_income = cur.get("NetIncome") or 0
tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income))
return {"revenue": max(revenue, 1), "cogs": cogs, "gross_profit": gross_profit, "opex": opex,
"operating_income": operating_income, "tax_interest_other": tax_interest_other, "net_income": net_income}
# ---------------------------------------------------------------------------
# Piotroski F-Score
# ---------------------------------------------------------------------------
def piotroski_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Piotroski F-Score (0-9) from AI-extracted current/previous year."""
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": True}
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return out
def v(d: dict, k: str) -> float:
return d.get(k) or 0
ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome")
ocf0 = v(cur, "OperatingCashFlow")
ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets")
roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None
roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None
lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt")
ca0, cl0 = v(cur, "CurrentAssets"), v(cur, "CurrentLiabilities")
ca1, cl1 = v(prev, "CurrentAssets"), v(prev, "CurrentLiabilities")
cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None
cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None
sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding")
rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue")
gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None
gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None
at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None
at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None
criteria: List[tuple] = [
("Net Income > 0 (profitability)", ni0 > 0),
("Operating Cash Flow > 0 (cash generative)", ocf0 > 0),
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
("OCF > Net Income (earnings quality, less accruals)", ocf0 > ni0),
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1) if ta0 and ta1 else False),
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
("No dilution: shares unchanged or lower (no equity raise)", (sh0 <= sh1) if (sh0 and sh1) else True),
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
]
out["score"] = sum(1 for _, p in criteria if p)
out["criteria"] = criteria
return out
def get_piotroski_fscore(ticker: str) -> Dict[str, Any]:
"""Piotroski F-Score from yahooquery/yfinance data."""
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": False}
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return out
if cf is None or cf.empty:
cf = pd.DataFrame()
try:
ncol = min(2, len(fin.columns))
rev = _get_row_series(fin, "Total Revenue", "Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(fin, "Gross Profit")
ta = _get_row_series(bal, "Total Assets")
lt_debt = _get_row_series(bal, "Long Term Debt")
ca = _get_row_series(bal, "Current Assets")
cl = _get_row_series(bal, "Current Liabilities")
ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None
shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number")
if shares is None and yf:
ti = yf.Ticker(ticker.upper())
info = getattr(ti, "info", None) or {}
sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding")
if sh_info is not None:
try:
shares = pd.Series([float(sh_info)] * ncol, index=fin.columns[:ncol])
except (TypeError, ValueError):
pass
def v0(s: Optional[pd.Series]) -> Optional[float]:
if s is None or len(s) == 0:
return None
x = _safe_float(s.iloc[0])
return x if (x is not None and x == x and not pd.isna(x)) else None
def v1(s: Optional[pd.Series]) -> Optional[float]:
if s is None or len(s) < 2:
return None
x = _safe_float(s.iloc[1])
return x if (x is not None and x == x and not pd.isna(x)) else None
ni0, ni1 = v0(ni), v1(ni)
ocf0 = v0(ocf) if ocf is not None else None
ta0, ta1 = v0(ta), v1(ta)
roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 and ta0 != 0) else None
roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 and ta1 != 0) else None
lt0 = v0(lt_debt) or 0
lt1 = v1(lt_debt) or 0
cl0, cl1 = v0(cl), v1(cl)
ca0, ca1 = v0(ca), v1(ca)
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
sh0, sh1 = v0(shares), v1(shares)
rev0, rev1 = v0(rev), v1(rev)
gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 and rev0 != 0) else None
gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 and rev1 != 0) else None
at0 = (rev0 / ta0) if (rev0 and ta0 and ta0 != 0) else None
at1 = (rev1 / ta1) if (rev1 and ta1 and ta1 != 0) else None
criteria = [
("Net Income > 0 (profitability)", ni0 is not None and ni0 > 0),
("Operating Cash Flow > 0 (cash generative)", ocf0 is not None and ocf0 > 0),
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
("OCF > Net Income (earnings quality, less accruals)", ocf0 is not None and ni0 is not None and ocf0 > ni0),
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta0 != 0 and ta1 and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1)),
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
("No dilution: shares unchanged or lower (no equity raise)", (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True),
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
]
out["score"] = sum(1 for _, p in criteria if p)
out["criteria"] = criteria
out["used_ttm"] = bool(any(str(c).startswith("TTM") for c in fin.columns))
return out
except Exception:
return out
# ---------------------------------------------------------------------------
# Radar metrics
# ---------------------------------------------------------------------------
def radar_metrics_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Build radar chart data from AI-extracted financials."""
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return {}
eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0)
if eq0 <= 0:
eq0 = (cur.get("TotalAssets") or 0) * 0.5
roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0
ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0
current_ratio = (ca / cl) if cl and cl != 0 else 0
ta = cur.get("TotalAssets") or 1
asset_turnover = (cur.get("Revenue") or 0) / ta
equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0
rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0
rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
return {"theta": theta, "r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy), "labels": theta}
def get_radar_metrics_normalized(ticker: str) -> Dict[str, Any]:
"""ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY normalised 0-100."""
if not ticker:
return {}
q = get_dupont_altman_redflags_yoy(ticker)
if not q:
return {}
dupont_df = q.get("dupont")
if dupont_df is None or dupont_df.empty or len(dupont_df) < 2:
return {}
row0 = dupont_df.iloc[0]
roe = row0.get("ROE %") or 0
cr = row0.get("Current Ratio") or 0
at = row0.get("Asset Turnover") or 0
em = row0.get("Equity Mult.") or 0
rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None
rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None
rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
return {"theta": theta, "r": _radar_norm(roe, cr, at, em, rev_yoy), "labels": theta}
# ---------------------------------------------------------------------------
# Sector-specific metrics
# ---------------------------------------------------------------------------
def get_sector_specific_metrics(ticker: str, sector: str) -> Dict[str, Any]:
"""Technology: Rule of 40, R&D %. Retail: Inventory Turnover. Financials: ROE/ROA."""
if not yf:
return {}
try:
t = yf.Ticker(ticker.upper())
fin = t.financials
bal = t.balance_sheet
if fin is None or fin.empty:
fin = getattr(t, "quarterly_financials", None)
if fin is not None and not fin.empty:
fin = fin.iloc[:, :4].sum(axis=1).to_frame()
if bal is None or bal.empty:
bal = getattr(t, "quarterly_balance_sheet", None)
out: Dict[str, Any] = {}
sector_lower = (sector or "").lower()
if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cf_source = t.cashflow or getattr(t, "quarterly_cashflow", None)
ocf = _get_row_series(cf_source, "Operating Cash Flow", "Cash From Operating Activities")
capx = _get_row_series(cf_source, "Capital Expenditure", "Capital Expenditures")
rd = _get_row_series(fin, "Research And Development", "Research And Development Expense")
if rev is not None and len(rev) > 0:
r0 = _safe_float(rev.iloc[0])
if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0:
fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0])
out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None
if rd is not None and len(rd) > 0:
out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None
if len(rev) >= 2:
cur_r, prev_r = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1])
rev_growth = ((cur_r - prev_r) / prev_r * 100) if prev_r and prev_r != 0 else None
if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None:
out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1)
if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower:
inv = _get_row_series(bal, "Inventory", "Total Inventory")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0:
out["Inventory Turnover"] = round(_safe_float(cogs.iloc[0]) / _safe_float(inv.iloc[0]), 2) if _safe_float(inv.iloc[0]) else None
if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0:
out["Operating Margin %"] = round(_safe_float(op_inc.iloc[0]) / _safe_float(rev.iloc[0]) * 100, 2) if _safe_float(rev.iloc[0]) else None
if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower:
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ta_s = _get_row_series(bal, "Total Assets")
if ni is not None and te is not None and len(ni) > 0 and len(te) > 0:
out["ROE %"] = round(_safe_float(ni.iloc[0]) / _safe_float(te.iloc[0]) * 100, 2) if _safe_float(te.iloc[0]) else None
if ni is not None and ta_s is not None and len(ni) > 0 and len(ta_s) > 0:
out["ROA %"] = round(_safe_float(ni.iloc[0]) / _safe_float(ta_s.iloc[0]) * 100, 2) if _safe_float(ta_s.iloc[0]) else None
return out
except Exception:
return {}
# ---------------------------------------------------------------------------
# Quarterly momentum
# ---------------------------------------------------------------------------
def get_quarterly_momentum(ticker: str) -> Dict[str, Any]:
"""Last 4 quarters Revenue/NI with QoQ growth for the most recent."""
out: Dict[str, Any] = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qfin = getattr(t, "quarterly_financials", None)
if qfin is None or qfin.empty or len(qfin.columns) < 2:
return out
rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders")
if rev is None and ni is None:
return out
cols = list(qfin.columns)[:4]
rows: List[Dict[str, Any]] = []
for c in cols:
try:
if hasattr(c, "strftime"):
q = (c.month - 1) // 3 + 1
label = c.strftime("%Y") + f"-Q{q}"
else:
label = str(c)[:12]
except Exception:
label = str(c)[:12]
r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None
n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None
rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val})
out["df"] = pd.DataFrame(rows)
if len(rows) >= 2:
r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue")
n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income")
if r0 is not None and r1 is not None and r1 != 0:
out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1)
if n0 is not None and n1 is not None and n1 != 0:
out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1)
return out
except Exception:
return out
def get_quarterly_ratio_changes(ticker: str) -> List[Dict[str, Any]]:
"""QoQ ratio changes for NPM, ROE, Gross/Operating Margin, Current Ratio, Interest Coverage."""
out: List[Dict[str, Any]] = []
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qf = getattr(t, "quarterly_financials", None)
qb = getattr(t, "quarterly_balance_sheet", None)
if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2:
return out
rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(qf, "Gross Profit")
ebit = _get_row_series(qf, "Operating Income", "EBIT")
interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net")
ta = _get_row_series(qb, "Total Assets")
te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ca = _get_row_series(qb, "Current Assets")
cl = _get_row_series(qb, "Current Liabilities")
def v(s: Optional[pd.Series], col: Any) -> Optional[float]:
if s is None or col not in s.index:
return None
return _safe_float(s.get(col))
c0, c1 = qf.columns[0], qf.columns[1]
b0, b1 = qb.columns[0], qb.columns[1]
r0, r1 = v(rev, c0), v(rev, c1)
n0, n1 = v(ni, c0), v(ni, c1)
g0, g1 = v(gross, c0), v(gross, c1)
e0, e1 = v(ebit, c0), v(ebit, c1)
i0, i1 = v(interest, c0), v(interest, c1)
te0, te1 = v(te, b0), v(te, b1)
ca0, ca1 = v(ca, b0), v(ca, b1)
cl0, cl1 = v(cl, b0), v(cl, b1)
npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None
npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None
roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None
roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None
gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None
gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None
om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None
om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None
ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None
def make_row(metric: str, cur: Optional[float], prev: Optional[float], is_pct_point: bool = False) -> Optional[Dict[str, Any]]:
if cur is None:
return None
if prev is None:
return {"Metric": metric, "Current Value": round(cur, 2), "Change": "-", "Trend": "-"}
chg = (cur - prev) if is_pct_point else (((cur - prev) / abs(prev) * 100) if prev != 0 else 0)
trend = "up" if chg > 0 else ("down" if chg < 0 else "flat")
chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp"
return {"Metric": metric, "Current Value": round(cur, 2), "Change": chg_str, "Trend": trend}
for name, cur_v, prev_v, is_pp in [
("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True),
("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False),
]:
r = make_row(name, cur_v, prev_v, is_pp)
if r:
out.append(r)
return out
except Exception:
return out
@@ -0,0 +1,151 @@
"""FX rate fetcher -- yfinance-based with in-memory TTL cache.
Provides current rates and 1-year history for major currency pairs.
"""
import time
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# In-memory cache with configurable TTL
# ---------------------------------------------------------------------------
_cache: Dict[str, Any] = {}
_cache_ts: Dict[str, float] = {}
_CACHE_TTL = 60 # seconds
# Default pairs
DEFAULT_PAIRS: List[str] = ["USDKRW=X", "GBPUSD=X", "EURUSD=X", "USDJPY=X"]
def _get_cached(key: str) -> Optional[Any]:
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
return _cache[key]
return None
def _set_cached(key: str, value: Any) -> None:
_cache[key] = value
_cache_ts[key] = time.time()
def _normalise_pair(pair: str) -> str:
"""Ensure pair is in Yahoo Finance format (e.g. 'USDKRW=X')."""
p = pair.upper().replace("/", "").strip()
if not p.endswith("=X"):
p = f"{p}=X"
return p
# ---------------------------------------------------------------------------
# Current rates
# ---------------------------------------------------------------------------
def fetch_fx_rate(pair: str) -> Optional[float]:
"""Fetch the latest exchange rate for a single currency pair.
Parameters
----------
pair:
Currency pair string, e.g. ``"USDKRW"``, ``"USDKRW=X"``, ``"EUR/USD"``.
Returns
-------
float or None
The latest rate, or None if unavailable.
"""
symbol = _normalise_pair(pair)
cache_key = f"fx_rate:{symbol}"
cached = _get_cached(cache_key)
if cached is not None:
return cached
try:
import yfinance as yf
ticker = yf.Ticker(symbol)
fast = getattr(ticker, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if price and float(price) > 0:
rate = float(price)
_set_cached(cache_key, rate)
return rate
hist = ticker.history(period="1d")
if hist is not None and not hist.empty:
rate = float(hist["Close"].iloc[-1])
_set_cached(cache_key, rate)
return rate
except Exception:
pass
return None
def fetch_multiple_rates(
pairs: Optional[List[str]] = None,
) -> Dict[str, float]:
"""Fetch current rates for multiple pairs.
Parameters
----------
pairs:
List of pair strings. Defaults to DEFAULT_PAIRS.
Returns
-------
dict
Mapping of normalised pair symbol -> rate.
"""
pairs = pairs or DEFAULT_PAIRS
rates: Dict[str, float] = {}
for pair in pairs:
rate = fetch_fx_rate(pair)
if rate is not None:
key = _normalise_pair(pair).replace("=X", "")
rates[key] = round(rate, 4)
return rates
# ---------------------------------------------------------------------------
# 1-year history
# ---------------------------------------------------------------------------
def fetch_fx_history(
pair: str,
period: str = "1y",
) -> Tuple[List[str], List[float]]:
"""Fetch historical daily closing rates for a currency pair.
Parameters
----------
pair:
Currency pair string.
period:
yfinance period string (default ``"1y"``).
Returns
-------
tuple of (dates, rates)
dates: list of ISO date strings
rates: list of float closing prices
"""
symbol = _normalise_pair(pair)
cache_key = f"fx_hist:{symbol}:{period}"
cached = _get_cached(cache_key)
if cached is not None:
return cached
try:
import yfinance as yf
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period)
if hist is None or hist.empty:
return ([], [])
dates = [d.strftime("%Y-%m-%d") for d in hist.index]
rates = [round(float(v), 4) for v in hist["Close"]]
result = (dates, rates)
_set_cached(cache_key, result)
return result
except Exception:
return ([], [])
@@ -0,0 +1,204 @@
"""High-level Gemini analysis orchestrators.
Contains the composite analysis functions that combine multiple Gemini
calls (chunked insights, comparative MD&A, industry outlook). These build
on the primitives in :mod:`server.services.gemini_service`.
"""
from typing import Any, Callable, Dict, Optional
from server.services.gemini_service import (
_gemini_forensic_audit,
_gemini_summarize_segment,
_gemini_synthesize_report,
_generate_with_retry,
_is_rate_limit_error,
get_gemini_model,
)
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
def get_mda_chunked_insights(
api_key: str,
sections: Dict[str, str],
ticker: str,
sector: str,
industry: str,
progress_callback: Optional[Callable[[str], None]] = None,
) -> str:
"""Full-text analysis: chunk 1A+7, summarise each, synthesise, then append forensic.
Parameters
----------
api_key:
Google Gemini API key.
sections:
Dict with keys ``item1a``, ``item7``, ``item3``, ``item9a``.
ticker:
Stock ticker symbol.
sector / industry:
Used for sector-aware KPI extraction.
progress_callback:
Optional ``fn(msg: str)`` called with status updates.
Returns
-------
str
Markdown-formatted Executive Insight Report.
"""
def _progress(msg: str) -> None:
if progress_callback:
progress_callback(msg)
combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "")
combined = combined.strip()
if not combined:
return "No 10-K text available to analyse."
chunks = _split_into_chunks(combined, max_chars=22_000)
if not chunks:
return "No content extracted."
summaries = []
n = len(chunks)
for i, ch in enumerate(chunks):
_progress(f"Analyzing Segment {i + 1}/{n}...")
summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i + 1}/{n}")
if summary:
summaries.append(summary)
if not summaries:
return "Segment analysis produced no summaries."
_progress("Synthesizing final report...")
report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A")
_progress("Running forensic audit (Item 3 & 9A)...")
forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker)
return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "")
def get_mda_insights(
api_key: str,
item1a_text: str,
item7_text: str,
ticker: str,
) -> str:
"""Single-shot analysis of Item 1A + Item 7 (tone, strategy, risks)."""
model = get_gemini_model(api_key)
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_text:
combined.append(clean_text_for_llm(item7_text))
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n\n"
f"The text below is from the 10-K for {ticker}: **Item 1A** and **Item 7**.\n\n"
"Provide a concise report:\n"
"1. **Management's Tone (Sentiment)**\n"
"2. **Key Strategic Shifts**\n"
"3. **Major Hidden Risks**\n\n"
"Use clear headings. Under 800 words."
)
full = f"--- 10-K Excerpt ---\n\n{combined_text}\n\n---\n\n{prompt}"
try:
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def get_mda_comparative_insights(
api_key: str,
item1a_text: str,
item7_latest: str,
item7_3y_ago: Optional[str],
ticker: str,
sector: Optional[str] = None,
industry: Optional[str] = None,
) -> str:
"""Comparative or single-year MD&A deep-dive with sector-aware KPIs."""
model = get_gemini_model(api_key)
sector_label = (sector or "N/A").strip()
industry_label = (industry or "N/A").strip()
kpi_instruction = (
f" Given that this company is in the **{sector_label}** sector"
+ (f" (industry: {industry_label})" if industry_label != "N/A" else "")
+ ", extract **industry-specific Non-GAAP KPIs** in a markdown table."
)
if not item7_3y_ago or not item7_3y_ago.strip():
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_latest:
combined.append(clean_text_for_llm(item7_latest))
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n"
f"Latest 10-K only for {ticker} (Item 1A + Item 7). Provide:\n"
"1. **Management's Tone**\n2. **Current Strategy & Priorities**\n"
"3. **Major Hidden Risks**\n4. **Forensic / Quality of Earnings**\n"
f"{kpi_instruction}\nUnder 800 words."
)
full = f"--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{prompt}"
else:
latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12_000)
past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n"
f"Below are Item 7 from the 10-K for {ticker}: LATEST and THREE YEARS AGO.\n"
"1. **Core strategy** changes\n2. **Emerging risks**\n"
"3. **Management's tone** shift\n4. **Industry-specific KPIs**\n"
f"{kpi_instruction}\nUnder 900 words."
)
full = (
f"--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n"
f"--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{prompt}"
)
try:
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def get_industry_outlook(
api_key: str,
industry_name: str,
tickers: list,
) -> str:
"""Generate a Wall Street macro-analyst-style Industry Outlook (12-18 months)."""
model = get_gemini_model(api_key)
ticker_list_str = ", ".join(str(t).upper() for t in tickers if t)
prompt = (
f"Act as an elite Wall Street macro analyst. Provide a concise "
f"**Industry Outlook** for the **{industry_name}** sector, "
f"which includes companies like {ticker_list_str}.\n\n"
"Focus on:\n"
"1. **Macro trends** (next 12-18 months)\n"
"2. **Major growth drivers**\n"
"3. **Key headwinds or regulatory risks**\n\n"
"Use clear headings. Under 600 words."
)
try:
response = _generate_with_retry(model, prompt, {"temperature": 0.4, "max_output_tokens": 2048})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No industry outlook generated."
return response.text.strip()
@@ -0,0 +1,321 @@
"""Gemini LLM integration for qualitative financial analysis.
All functions in this module talk to Google Gemini (via the
``google.generativeai`` SDK) and return plain strings or dicts.
No Streamlit dependencies.
"""
import json
import re
import time
from typing import Any, Dict, Generator, List, Optional
from server.utils.safe_float import _safe_float
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GEMINI_MODEL: str = "gemini-2.0-flash"
RATE_LIMIT_WAIT_SEC: int = 60
_REQUIRED_FINANCIAL_KEYS: List[str] = [
"Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome",
"TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt",
"OperatingCashFlow", "SharesOutstanding",
]
# ---------------------------------------------------------------------------
# Model initialisation
# ---------------------------------------------------------------------------
def get_gemini_model(api_key: str) -> Any:
"""Configure and return a ``GenerativeModel`` for :data:`GEMINI_MODEL`."""
import google.generativeai as genai
genai.configure(api_key=api_key)
return genai.GenerativeModel(GEMINI_MODEL)
# ---------------------------------------------------------------------------
# Retry / streaming helpers
# ---------------------------------------------------------------------------
def _is_rate_limit_error(e: Exception) -> bool:
"""Return ``True`` if *e* looks like a 429 / resource-exhausted error."""
err_msg = str(e).lower()
return (
"429" in err_msg
or "resourcelimited" in err_msg
or "resource exhausted" in err_msg
or getattr(e, "code", None) == 429
)
def _generate_with_retry(
model: Any,
content: str,
config: Dict[str, Any],
max_retries: int = 3,
) -> Any:
"""Call ``model.generate_content`` with automatic rate-limit back-off."""
last_err: Optional[Exception] = None
for attempt in range(max_retries + 1):
try:
return model.generate_content(content, generation_config=config)
except Exception as e:
last_err = e
if attempt < max_retries and _is_rate_limit_error(e):
time.sleep(RATE_LIMIT_WAIT_SEC)
continue
raise
raise last_err # type: ignore[misc]
def _generate_stream(
model: Any,
content: str,
config: Dict[str, Any],
) -> Generator[str, None, None]:
"""Yield text chunks from Gemini with ``stream=True``."""
response = model.generate_content(content, generation_config=config, stream=True)
for chunk in response:
if hasattr(chunk, "text") and chunk.text:
yield chunk.text
# ---------------------------------------------------------------------------
# Segment-level helpers (chunked analysis)
# ---------------------------------------------------------------------------
def _gemini_summarize_segment(
api_key: str,
segment_text: str,
ticker: str,
segment_label: str,
) -> str:
"""Summarise one segment of Item 1A / Item 7 text."""
model = get_gemini_model(api_key)
prompt = (
f"You are a senior equity analyst. The following is one segment of "
f"the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A).\n"
"Extract and list all significant: (1) strategic shifts or priorities, "
"(2) hidden or material risks, (3) management tone cues. Use concise "
f"bullet points. Do not omit important details. Segment: {segment_label}."
)
full = f"--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_synthesize_report(
api_key: str,
segment_summaries: List[str],
ticker: str,
sector: str,
industry: str,
) -> str:
"""Synthesise segment summaries into an Executive Insight Report."""
model = get_gemini_model(api_key)
combined = "\n\n---\n\n".join(segment_summaries)
kpi_note = (
f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned."
if sector and sector != "N/A"
else ""
)
prompt = (
f"You are a senior equity analyst. Use British English. Below are "
f"summarized insights from the full 10-K for {ticker} (Item 1A and "
"Item 7). Create the final **Executive Insight Report** with these sections:\n\n"
"1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence.\n"
"2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers.\n"
"3. **Major Hidden Risks**: The 3-4 most material risks investors might overlook.\n"
"4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings."
f"{kpi_note}\n\n"
"Use clear headings. Do not invent figures. Keep under 900 words."
)
full = f"--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_forensic_audit(
api_key: str,
item3: str,
item9a: str,
ticker: str,
) -> str:
"""Check Item 3 & 9A for material weaknesses, lawsuits, red flags."""
model = get_gemini_model(api_key)
combined = (item3 or "") + "\n\n---\n\n" + (item9a or "")
if not combined.strip():
return "No Item 3 / 9A text provided; skip forensic."
prompt = (
f"From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings "
"and Item 9A Controls/Internal Control), list any:\n"
"- Material weaknesses in internal control\n"
"- Significant legal proceedings or litigation\n"
"- Off-balance-sheet or governance red flags\n"
'If none, output: "No material red flags or special issues detected '
'in Item 3 and 9A."\nBe concise (under 150 words).'
)
full = f"--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512})
return (r.text or "").strip()
except Exception:
return ""
# ---------------------------------------------------------------------------
# Public analysis functions
# ---------------------------------------------------------------------------
def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> Dict[str, Any]:
"""Extract current/previous year financials from Item 8 via Gemini."""
if not (api_key or "").strip() or not (item8_text or "").strip():
return {}
payload = smart_chunk((item8_text or "").strip(), max_chars=35_000)
model = get_gemini_model(api_key)
prompt = (
f"You are a financial analyst. Below is Item 8 (Financial Statements "
f"and Supplementary Data) from the latest 10-K for {ticker}.\n\n"
"Extract figures for **Current Year** and **Previous Year**. "
"Monetary values in millions. Shares in millions.\n\n"
"Return ONLY valid JSON:\n"
'{"current_yr": {...}, "previous_yr": {...}}\n'
"Keys: Revenue, CostOfRevenue, OperatingExpenses, NetIncome, "
"TotalAssets, CurrentAssets, CurrentLiabilities, LongTermDebt, "
"OperatingCashFlow, SharesOutstanding.\n"
"If not found use 0. Output nothing except JSON."
)
full = f"--- Item 8 ---\n\n{payload}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048})
raw = (r.text or "").strip()
if not raw:
return {}
raw = re.sub(r"^```\s*json\s*", "", raw)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```\s*$", "", raw)
raw = raw.strip()
out = json.loads(raw)
cur = out.get("current_yr") or {}
prev = out.get("previous_yr") or {}
for key in _REQUIRED_FINANCIAL_KEYS:
cur[key] = _safe_float(cur.get(key)) or 0
prev[key] = _safe_float(prev.get(key)) or 0
return {"current_yr": cur, "previous_yr": prev}
except (json.JSONDecodeError, Exception):
return {}
def get_gemini_item7_strategy(
api_key: str,
item7_text: str,
ticker: str,
sector: str,
industry: str,
) -> str:
"""Analyse Item 7 for business performance and strategic shifts."""
if not (item7_text or "").strip():
return "No Item 7 (MD&A) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note}\n\n"
"Provide a concise **Management Strategy** report:\n"
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
"Use clear headings. Under 600 words. Output in British English even if source is another language."
)
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def get_gemini_item7_strategy_stream(
api_key: str,
item7_text: str,
ticker: str,
sector: str,
industry: str,
) -> Generator[str, None, None]:
"""Yield MD&A strategy report chunks for real-time streaming."""
if not (item7_text or "").strip():
yield "No Item 7 (MD&A) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 7 (MD&A)** from the latest 10-K for {ticker}.{sector_note}\n\n"
"Provide a concise **Management Strategy** report:\n"
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
"Under 600 words. British English."
)
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
def get_gemini_item1a_risks(
api_key: str,
item1a_text: str,
item3: str,
item9a: str,
ticker: str,
) -> str:
"""Analyse Item 1A risks and append forensic audit of Items 3 & 9A."""
if not (item1a_text or "").strip():
return "No Item 1A (Risk Factors) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
"Provide a concise **Risk Factors** report:\n"
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
"Under 500 words. British English."
)
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
try:
report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
risks = (report.text or "").strip()
except Exception:
risks = ""
forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker)
return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "")
def get_gemini_item1a_risks_stream(
api_key: str,
item1a_text: str,
ticker: str,
) -> Generator[str, None, None]:
"""Yield Risk Factors report chunks; caller appends forensic separately."""
if not (item1a_text or "").strip():
yield "No Item 1A (Risk Factors) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
"Provide a concise **Risk Factors** report:\n"
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
"Under 500 words. British English."
)
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
@@ -0,0 +1,218 @@
"""Market data endpoints: DCF inputs, analyst consensus, and comps.
Complements :mod:`server.services.market_fetcher` with higher-level data
retrieval functions that consume the raw financial statements and produce
ready-to-use outputs for the DCF engine and industry comparison panels.
"""
from typing import Dict, Optional
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
def get_dcf_inputs(ticker: str) -> Dict[str, Optional[float]]:
"""Return FCF, Total Debt, Cash, and Shares Outstanding for DCF.
Tries yahooquery (via ``_get_annual_financials_balance_cashflow``)
first, then falls back to direct yfinance lookups.
Returns
-------
dict
Keys: ``fcf``, ``total_debt``, ``cash``, ``shares`` (any may be ``None``).
"""
out: Dict[str, Optional[float]] = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
if not ticker:
return out
try:
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if bal is not None and not bal.empty and cf is not None and not cf.empty:
sh = _get_row_series(bal, "Share Issued")
out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None
td = _get_row_series(bal, "Total Debt")
out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0
cash_s = _get_row_series(bal, "Cash And Cash Equivalents")
out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0
ocf = _get_row_series(cf, "Operating Cash Flow")
capx = _get_row_series(cf, "Capital Expenditure")
if ocf is not None and len(ocf) > 0:
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
if ocf_val is not None:
out["fcf"] = ocf_val - (capx_val or 0)
if out.get("fcf") is not None or out.get("shares") is not None:
return out
except Exception:
pass
if not yf:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
fast_info = getattr(t, "fast_info", None)
cashflow = getattr(t, "cashflow", None)
if cashflow is None or cashflow.empty:
cashflow = getattr(t, "quarterly_cashflow", None)
balance = getattr(t, "balance_sheet", None)
if balance is None or balance.empty:
balance = getattr(t, "quarterly_balance_sheet", None)
# Shares
shares: Optional[float] = None
if fast_info is not None:
try:
s = getattr(fast_info, "shares", None)
if s is None and hasattr(fast_info, "get"):
s = fast_info.get("shares")
if s is not None and float(s) > 0:
shares = float(s)
except (TypeError, ValueError, AttributeError):
pass
if shares is None:
for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"):
s = info.get(key)
if s is not None and float(s) > 0:
shares = float(s)
break
if shares is None and balance is not None and not balance.empty:
try:
if "Share Issued" in balance.index:
shares = _safe_float(balance.loc["Share Issued"].iloc[0])
if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index:
shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["shares"] = shares if (shares is not None and shares > 0) else None
# Total Debt
total_debt: Optional[float] = None
if fast_info is not None:
try:
d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None)
if d is not None and float(d) >= 0:
total_debt = float(d)
except (TypeError, ValueError, AttributeError):
pass
if total_debt is None:
total_debt = info.get("Total Debt")
if total_debt is None and balance is not None and not balance.empty:
try:
if "Total Debt" in balance.index:
total_debt = _safe_float(balance.loc["Total Debt"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["total_debt"] = float(total_debt) if total_debt is not None else 0.0
# Cash
cash: Optional[float] = None
if fast_info is not None:
try:
c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None)
if c is not None and float(c) >= 0:
cash = float(c)
except (TypeError, ValueError, AttributeError):
pass
if cash is None:
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
if cash is None and balance is not None and not balance.empty:
try:
for row_name in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"):
if row_name in balance.index:
cash = _safe_float(balance.loc[row_name].iloc[0])
if cash is not None:
break
except (KeyError, TypeError, IndexError):
pass
out["cash"] = float(cash) if cash is not None else 0.0
# FCF
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None
if ocf is not None and len(ocf) > 0:
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
if capx_val is None:
capx_val = 0.0
if ocf_val is not None:
latest_fcf = ocf_val - capx_val
if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)):
out["fcf"] = latest_fcf
return out
except Exception:
return out
def get_analyst_consensus(ticker: str) -> Dict[str, str]:
"""Fetch analyst consensus from yfinance: target price, recommendation, growth."""
out = {"targetMeanPrice": "N/A", "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A"}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
tp = info.get("targetMeanPrice")
if tp is not None:
try:
out["targetMeanPrice"] = f"${float(tp):.2f}"
except (TypeError, ValueError):
out["targetMeanPrice"] = str(tp)
rec = info.get("recommendationKey") or info.get("recommendation")
if rec is not None:
out["recommendationKey"] = str(rec)
rg = info.get("revenueGrowth")
if rg is not None:
try:
out["revenueGrowth"] = f"{float(rg) * 100:.1f}%"
except (TypeError, ValueError):
out["revenueGrowth"] = str(rg)
eg = info.get("earningsGrowth")
if eg is not None:
try:
out["earningsGrowth"] = f"{float(eg) * 100:.1f}%"
except (TypeError, ValueError):
out["earningsGrowth"] = str(eg)
return out
except Exception:
return out
def get_comps_data(tickers: tuple) -> pd.DataFrame:
"""Fetch Forward P/E, EV/EBITDA, P/B for a set of tickers."""
if not yf:
return pd.DataFrame()
rows = []
for sym in tickers:
sym = str(sym).strip().upper()
if not sym:
continue
try:
t = yf.Ticker(sym)
info = t.info or {}
forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE")
ev_ebitda = info.get("enterpriseToEbitda")
if ev_ebitda is None:
ev, ebitda = info.get("enterpriseValue"), info.get("ebitda")
if ev is not None and ebitda is not None and ebitda != 0:
ev_ebitda = ev / ebitda
pb = info.get("priceToBook") or info.get("Price To Book")
rows.append({
"Ticker": sym,
"Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None,
"EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None,
"P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None,
})
except Exception:
rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None})
return pd.DataFrame(rows) if rows else pd.DataFrame()
@@ -0,0 +1,277 @@
"""Yahoo Finance / yahooquery data fetching for financial statements.
Provides functions to retrieve annual income statements, balance sheets,
cash-flow statements, sector/industry metadata, DCF inputs, analyst
consensus, and peer-comparable multiples. Uses yahooquery as the primary
source with yfinance as fallback; builds TTM aggregates from quarterly
data when annual data is unavailable.
"""
from typing import Dict, List, Optional, Tuple
import pandas as pd
from server.utils.safe_float import _safe_float
# ---------------------------------------------------------------------------
# Optional imports
# ---------------------------------------------------------------------------
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
try:
from yahooquery import Ticker as YQTicker
except ImportError:
YQTicker = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Row-mapping tables (yahooquery column names -> our canonical names)
# ---------------------------------------------------------------------------
_INCOME_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")),
("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")),
("Gross Profit", ("GrossProfit",)),
("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")),
("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")),
("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")),
("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")),
("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")),
]
_BALANCE_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Total Assets", ("TotalAssets",)),
("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")),
("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")),
("Current Assets", ("CurrentAssets",)),
("Current Liabilities", ("CurrentLiabilities",)),
("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")),
("Total Debt", ("TotalDebt",)),
("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")),
("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")),
("Retained Earnings", ("RetainedEarnings",)),
]
_CASHFLOW_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")),
("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")),
]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _yq_df_to_our_shape(
df: pd.DataFrame,
row_map: List[Tuple[str, Tuple[str, ...]]],
date_col: str = "asOfDate",
) -> Optional[pd.DataFrame]:
"""Pivot a yahooquery DataFrame to index=line-items, columns=dates."""
if df is None or df.empty or date_col not in df.columns:
return None
df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5)
if df.empty:
return None
dates = df[date_col].astype(str).str[:10].tolist()
data: Dict[str, list] = {}
for our_name, yq_cols in row_map:
cols = yq_cols if isinstance(yq_cols, tuple) else (yq_cols,)
val_col = next((c for c in cols if c in df.columns), None)
if val_col is None:
data[our_name] = [None] * len(dates)
else:
data[our_name] = [_safe_float(v) for v in df[val_col].tolist()]
out = pd.DataFrame(data, index=dates).T
out.columns = dates
return out
def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]:
"""Extract shares outstanding series from yahooquery balance sheet."""
if df_bal is None or df_bal.empty:
return None
for col in ("OrdinarySharesNumber", "ShareIssued"):
if col in df_bal.columns and "asOfDate" in df_bal.columns:
s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False)
s.index = s.index.astype(str).str[:10]
return s
return None
def _get_row_series(df: Optional[pd.DataFrame], *names: str) -> Optional[pd.Series]:
"""Return the first matching row from *df* as a Series, or ``None``."""
if df is None or df.empty:
return None
for name in names:
try:
if name in df.index:
return df.loc[name].copy()
except (KeyError, TypeError):
continue
return None
def _fin_or_bal_empty(df: object) -> bool:
"""True if *df* is missing, empty, or has no columns."""
return df is None or (hasattr(df, "empty") and df.empty) or (hasattr(df, "columns") and len(df.columns) == 0)
# ---------------------------------------------------------------------------
# Core fetchers
# ---------------------------------------------------------------------------
def _get_annual_financials_balance_cashflow_yahooquery(
ticker: str,
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""Fetch annual financials from yahooquery with TTM fallback."""
if not YQTicker or not ticker:
return (None, None, None)
try:
yq = YQTicker(ticker.upper())
inc_a = yq.income_statement(frequency="a", trailing=False)
bal_a = yq.balance_sheet(frequency="a", trailing=False)
cf_a = yq.cash_flow(frequency="a", trailing=False)
if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty:
inc_q = yq.income_statement(frequency="q", trailing=False)
bal_q = yq.balance_sheet(frequency="q", trailing=False)
cf_q = yq.cash_flow(frequency="q", trailing=False)
if inc_q is not None and not inc_q.empty and len(inc_q) >= 4:
ttm0 = inc_q.head(4).sum(numeric_only=True)
row0 = ttm0.to_dict()
row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0"
rows_inc = [row0]
if len(inc_q) >= 8:
ttm1 = inc_q.iloc[4:8].sum(numeric_only=True)
row1 = ttm1.to_dict()
row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1"
rows_inc.append(row1)
inc_a = pd.DataFrame(rows_inc)
if bal_q is not None and not bal_q.empty:
bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a
if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty):
ttm0_cf = cf_q.head(4).sum(numeric_only=True)
row0_cf = ttm0_cf.to_dict()
row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0"
rows_cf = [row0_cf]
if len(cf_q) >= 8:
ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True)
row1_cf = ttm1_cf.to_dict()
row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1"
rows_cf.append(row1_cf)
cf_a = pd.DataFrame(rows_cf)
fin_df = _yq_df_to_our_shape(inc_a, _INCOME_ROW_MAP)
bal_df = _yq_df_to_our_shape(bal_a, _BALANCE_ROW_MAP)
if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty:
for sh_col in ("OrdinarySharesNumber", "ShareIssued"):
if sh_col in bal_a.columns:
row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]}
if bal_df is not None and not bal_df.empty:
d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0]
extra = pd.DataFrame(row, index=[d]).T
extra.columns = [d]
bal_df = pd.concat([bal_df, extra], axis=0)
break
cf_df = _yq_df_to_our_shape(cf_a, _CASHFLOW_ROW_MAP)
return (fin_df, bal_df, cf_df)
except Exception:
return (None, None, None)
def _get_annual_financials_balance_cashflow(
ticker: str,
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""Return ``(fin_df, bal_df, cf_df)`` using yahooquery then yfinance fallback."""
if not ticker:
return (None, None, None)
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker)
if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty:
return (fin_df, bal_df, cf_df)
if not yf:
return (None, None, None)
try:
t = yf.Ticker(ticker.upper())
fin = getattr(t, "financials", None)
bal = getattr(t, "balance_sheet", None)
cf = getattr(t, "cashflow", None)
if _fin_or_bal_empty(fin):
qf = getattr(t, "quarterly_financials", None)
if qf is not None and not qf.empty:
n = len(qf.columns)
if n >= 8:
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:8].sum(axis=1)], axis=1)
fin.columns = ["TTM0", "TTM1"]
elif n >= 5:
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:n].sum(axis=1)], axis=1)
fin.columns = ["TTM0", "TTM1"]
else:
fin = qf.iloc[:, :min(4, n)].sum(axis=1).to_frame("TTM0")
if _fin_or_bal_empty(bal):
qb = getattr(t, "quarterly_balance_sheet", None)
if qb is not None and not qb.empty:
n = len(qb.columns)
bal = qb.iloc[:, :min(2, n)].copy()
bal.columns = ["B0", "B1"] if bal.shape[1] >= 2 else ["B0"]
if _fin_or_bal_empty(cf):
qc = getattr(t, "quarterly_cashflow", None)
if qc is not None and not qc.empty:
cf = qc.iloc[:, :min(4, len(qc.columns))].sum(axis=1).to_frame("TTM0")
return (fin, bal, cf)
except Exception:
return (None, None, None)
def get_sector_industry(ticker: str) -> Dict[str, str]:
"""Return ``{'sector': ..., 'industry': ...}`` from yfinance."""
if not yf:
return {"sector": "N/A", "industry": "N/A"}
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A"
industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A"
return {"sector": sector, "industry": industry}
except Exception:
return {"sector": "N/A", "industry": "N/A"}
def get_5yr_financial_trend(ticker: str) -> pd.DataFrame:
"""Up to 5 years of Revenue, Net Income, Operating Margin, FCF."""
if not yf:
return pd.DataFrame()
try:
t = yf.Ticker(ticker.upper())
financials = t.financials
cashflow = t.cashflow
if financials is None or financials.empty or cashflow is None or cashflow.empty:
return pd.DataFrame()
dates = sorted(financials.columns.tolist(), reverse=True)[:5]
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations")
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment")
revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders")
op_income = _get_row_series(financials, "Operating Income", "EBIT")
rows = []
for d in dates:
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None
net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None
op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None
oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None)
ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None
capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None
fcf = (ocf_val - capx_val) if (ocf_val is not None and capx_val is not None) else (ocf_val if ocf_val is not None else None)
rows.append({
"Year": yr,
"Revenue": rev,
"Net Income": net_i,
"Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None,
"FCF": fcf,
})
return pd.DataFrame(rows)
except Exception:
return pd.DataFrame()
@@ -0,0 +1,124 @@
"""Monte Carlo simulation for DCF valuation.
Runs N random DCF scenarios by sampling WACC and FCF growth from
normal distributions, then reports distributional statistics.
"""
from typing import Dict, Any, List
import numpy as np
def run_monte_carlo_dcf(
fcf: float,
wacc_mean: float,
wacc_std: float,
growth_mean: float,
growth_std: float,
term_growth: float,
total_debt: float,
cash: float,
shares: float,
n_simulations: int = 5000,
current_price: float | None = None,
) -> Dict[str, Any]:
"""Run a Monte Carlo DCF simulation.
Parameters
----------
fcf : float
Base free cash flow.
wacc_mean / wacc_std : float
Mean and standard deviation for WACC sampling (decimal, e.g. 0.09).
growth_mean / growth_std : float
Mean and standard deviation for FCF growth sampling (decimal).
term_growth : float
Terminal growth rate (constant across simulations).
total_debt, cash, shares : float
Balance-sheet items for equity bridge.
n_simulations : int
Number of Monte Carlo iterations (default 5 000).
current_price : float | None
Current market price; used to compute prob_above_current.
Returns
-------
dict
values list of per-share intrinsic values (sorted)
percentile_10 10th percentile
median 50th percentile
percentile_90 90th percentile
mean arithmetic mean
prob_above_current probability the simulated value exceeds current_price
current_price echo back
n_simulations echo back
"""
if shares <= 0 or fcf <= 0:
return {
"values": [],
"percentile_10": None,
"median": None,
"percentile_90": None,
"mean": None,
"prob_above_current": None,
"current_price": current_price,
"n_simulations": n_simulations,
}
rng = np.random.default_rng()
# Sample WACC and growth; clip to sensible bounds
waccs = rng.normal(wacc_mean, max(wacc_std, 1e-6), n_simulations)
waccs = np.clip(waccs, 0.01, 0.40)
growths = rng.normal(growth_mean, max(growth_std, 1e-6), n_simulations)
growths = np.clip(growths, -0.30, 0.60)
projection_years = 10
values: List[float] = []
for w, g in zip(waccs, growths):
if w <= term_growth:
continue
# 10-year two-stage DCF (simplified: constant growth then terminal)
pv = 0.0
fcft = float(fcf)
for t in range(1, projection_years + 1):
fcft *= (1 + g)
pv += fcft / ((1 + w) ** t)
tv = fcft * (1 + term_growth) / (w - term_growth)
pv += tv / ((1 + w) ** projection_years)
equity = pv - total_debt + cash
per_share = equity / shares
if per_share > 0:
values.append(round(per_share, 2))
if not values:
return {
"values": [],
"percentile_10": None,
"median": None,
"percentile_90": None,
"mean": None,
"prob_above_current": None,
"current_price": current_price,
"n_simulations": n_simulations,
}
arr = np.array(values)
arr.sort()
prob_above = None
if current_price is not None and current_price > 0:
prob_above = round(float(np.mean(arr > current_price) * 100), 1)
return {
"values": arr.tolist(),
"percentile_10": round(float(np.percentile(arr, 10)), 2),
"median": round(float(np.median(arr)), 2),
"percentile_90": round(float(np.percentile(arr, 90)), 2),
"mean": round(float(np.mean(arr)), 2),
"prob_above_current": prob_above,
"current_price": current_price,
"n_simulations": n_simulations,
}
@@ -0,0 +1,186 @@
"""News aggregation from Finviz RSS and Google News RSS.
Fetches, deduplicates, and sorts financial news articles for a given
ticker/company combination. No API keys required -- uses public RSS feeds.
"""
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.request import Request, urlopen
from urllib.error import URLError
from email.utils import parsedate_to_datetime
_USER_AGENT = "ATLAS-Terminal/1.0 (news aggregator)"
_TIMEOUT_SEC = 10
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _fetch_xml(url: str) -> Optional[str]:
"""Fetch a URL and return its body as a string, or ``None`` on error."""
try:
req = Request(url, headers={"User-Agent": _USER_AGENT})
with urlopen(req, timeout=_TIMEOUT_SEC) as resp:
return resp.read().decode("utf-8", errors="replace")
except (URLError, OSError, Exception):
return None
def _parse_rss_items(xml_text: str) -> List[Dict[str, Any]]:
"""Parse standard RSS 2.0 ``<item>`` elements into dicts."""
items: List[Dict[str, Any]] = []
if not xml_text:
return items
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return items
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
link = (item.findtext("link") or "").strip()
pub_date_str = (item.findtext("pubDate") or "").strip()
description = (item.findtext("description") or "").strip()
source = (item.findtext("source") or "").strip()
pub_dt: Optional[datetime] = None
if pub_date_str:
try:
pub_dt = parsedate_to_datetime(pub_date_str)
except (ValueError, TypeError):
pass
if title and link:
items.append({
"title": title,
"link": link,
"published": pub_dt.isoformat() if pub_dt else pub_date_str,
"published_dt": pub_dt,
"description": description[:500] if description else "",
"source": source,
})
return items
def _dedup_by_title(articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Remove duplicate articles based on normalised title."""
seen: set = set()
unique: List[Dict[str, Any]] = []
for art in articles:
key = re.sub(r"\s+", " ", art["title"].lower().strip())
if key not in seen:
seen.add(key)
unique.append(art)
return unique
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fetch_finviz_news(ticker: str) -> List[Dict[str, Any]]:
"""Fetch recent news for *ticker* from the Finviz RSS feed.
Parameters
----------
ticker:
Stock ticker symbol (e.g. ``'AAPL'``).
Returns
-------
list[dict]
Each dict has keys: ``title``, ``link``, ``published``,
``description``, ``source``.
"""
if not ticker or not ticker.strip():
return []
url = f"https://finviz.com/quote.ashx?t={ticker.strip().upper()}&ty=c&p=d&b=1"
# Finviz RSS endpoint
rss_url = f"https://finviz.com/news_export.ashx?t={ticker.strip().upper()}"
xml = _fetch_xml(rss_url)
if not xml:
return []
items = _parse_rss_items(xml)
for item in items:
if not item.get("source"):
item["source"] = "Finviz"
return items
def fetch_google_news(company_name: str) -> List[Dict[str, Any]]:
"""Fetch recent news for *company_name* from Google News RSS.
Parameters
----------
company_name:
Full company name (e.g. ``'Apple Inc.'``).
Returns
-------
list[dict]
Same structure as :func:`fetch_finviz_news`.
"""
if not company_name or not company_name.strip():
return []
# URL-encode the query
query = company_name.strip().replace(" ", "+")
rss_url = f"https://news.google.com/rss/search?q={query}+stock&hl=en-US&gl=US&ceid=US:en"
xml = _fetch_xml(rss_url)
if not xml:
return []
items = _parse_rss_items(xml)
for item in items:
if not item.get("source"):
item["source"] = "Google News"
return items
def aggregate_news(
ticker: str,
company_name: str,
max_articles: int = 30,
) -> List[Dict[str, Any]]:
"""Aggregate news from Finviz and Google News, deduplicated and sorted.
Parameters
----------
ticker:
Stock ticker symbol.
company_name:
Full company name for broader search coverage.
max_articles:
Maximum number of articles to return (default 30).
Returns
-------
list[dict]
Deduplicated articles sorted by publication time (newest first).
Each dict has: ``title``, ``link``, ``published``, ``description``,
``source``.
"""
finviz_articles = fetch_finviz_news(ticker)
google_articles = fetch_google_news(company_name)
all_articles = finviz_articles + google_articles
unique = _dedup_by_title(all_articles)
# Sort by datetime (newest first); articles without a parseable date go last
def sort_key(art: Dict[str, Any]) -> float:
dt = art.get("published_dt")
if dt is not None:
return -dt.timestamp()
return float("inf")
unique.sort(key=sort_key)
# Strip internal datetime field before returning
for art in unique:
art.pop("published_dt", None)
return unique[:max_articles]
@@ -0,0 +1,98 @@
"""Portfolio risk metrics -- VaR, Sharpe, Sortino, MDD, Beta, Correlation."""
import numpy as np
def compute_portfolio_risk(positions: list, benchmark: str = "SPY") -> dict:
"""Compute VaR, Sharpe, Sortino, MDD, Beta, Correlation for portfolio."""
import yfinance as yf
tickers = [p["ticker"] for p in positions]
if not tickers:
return {}
values = [
p.get("value", p.get("quantity", 0) * p.get("avg_price", 0))
for p in positions
]
total = sum(values) or 1
weights = np.array([v / total for v in values])
data = yf.download(tickers + [benchmark], period="1y", progress=False)["Close"]
if data.empty:
return {}
returns = data.pct_change().dropna()
if len(tickers) == 1:
port_returns = (
returns[tickers[0]]
if tickers[0] in returns.columns
else returns.iloc[:, 0]
)
else:
ticker_returns = (
returns[tickers]
if all(t in returns.columns for t in tickers)
else returns.iloc[:, : len(tickers)]
)
port_returns = (ticker_returns * weights).sum(axis=1)
bench_returns = (
returns[benchmark] if benchmark in returns.columns else returns.iloc[:, -1]
)
# VaR
var_95 = float(np.percentile(port_returns, 5))
var_99 = float(np.percentile(port_returns, 1))
# Sharpe (annualized, rf=0.04)
rf_daily = 0.04 / 252
excess = port_returns - rf_daily
sharpe = (
float(np.sqrt(252) * excess.mean() / excess.std())
if excess.std() > 0
else 0
)
# Sortino
downside = excess[excess < 0]
sortino = (
float(np.sqrt(252) * excess.mean() / downside.std())
if len(downside) > 0 and downside.std() > 0
else 0
)
# Max Drawdown
cumulative = (1 + port_returns).cumprod()
peak = cumulative.expanding().max()
drawdown = (cumulative - peak) / peak
max_dd = float(drawdown.min())
# Beta
cov = np.cov(port_returns, bench_returns)
beta = float(cov[0, 1] / cov[1, 1]) if cov[1, 1] > 0 else 1.0
# Correlation matrix
corr = {}
if len(tickers) > 1:
corr_df = (
returns[tickers].corr()
if all(t in returns.columns for t in tickers)
else {}
)
if hasattr(corr_df, "to_dict"):
corr = {
str(k): {str(k2): round(v2, 3) for k2, v2 in v.items()}
for k, v in corr_df.to_dict().items()
}
return {
"var_95": round(var_95 * 100, 2),
"var_99": round(var_99 * 100, 2),
"sharpe": round(sharpe, 2),
"sortino": round(sortino, 2),
"max_drawdown": round(max_dd * 100, 2),
"beta": round(beta, 2),
"correlation_matrix": corr,
}
@@ -0,0 +1,145 @@
"""Portfolio screenshot OCR using Gemini Vision.
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.
"""
import json
import re
from typing import Any, Dict, List, Optional
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")
def _build_prompt() -> str:
"""Return the extraction prompt for portfolio screenshots."""
return """You are a financial data extraction assistant.
Analyse this portfolio screenshot from a brokerage app (Trading 212,
Interactive Brokers, or similar).
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.
"""
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.
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>"}``.
"""
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},
)
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
except json.JSONDecodeError:
return {"error": "Failed to parse JSON from Gemini response.", "raw": raw}
except Exception as e:
return {"error": f"Screenshot analysis failed: {e}"}
@@ -0,0 +1,382 @@
"""SEC EDGAR 10-K download, parsing, section extraction, and caching.
Handles the full pipeline from downloading a 10-K filing via
``sec_edgar_downloader`` through HTML stripping to isolating individual
Item sections (1A, 3, 7, 8, 9A) and persisting the cleaned text to a
local JSON cache under ``data/``.
"""
import json
import re
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
from bs4 import BeautifulSoup
from server.services.text_chunker import clean_text_for_llm, smart_chunk
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_DATA_DIR: Path = Path(__file__).resolve().parents[3] / "data"
# ---------------------------------------------------------------------------
# Section-header regex patterns
# ---------------------------------------------------------------------------
ITEM1A_PATTERNS: List[str] = [
r"Item\s+1A\s*[.:]\s*Risk\s+Factors",
r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors",
]
ITEM7_PATTERNS: List[str] = [
r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis",
r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion",
r"Item\s+7\s*[.:]\s*[\w\s]+MD&A",
]
ITEM8_PATTERNS: List[str] = [
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
]
ITEM3_PATTERNS: List[str] = [
r"Item\s+3\s*[.:]\s*Legal\s+Proceedings",
r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings",
]
ITEM9A_PATTERNS: List[str] = [
r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures",
r"Item\s+9A\s*[.:]\s*Internal\s+Control",
r"ITEM\s+9A\s*[.:]\s*Controls",
]
# ---------------------------------------------------------------------------
# HTML helpers
# ---------------------------------------------------------------------------
def _slice_html_items_1a_to_9a(raw_html: str) -> str:
"""Fast string-level slice: keep only Item 1A through end of Item 9A."""
if not raw_html or len(raw_html) < 5000:
return raw_html
start = -1
for needle in ("Item 1A", "ITEM 1A", "Item 1a"):
i = raw_html.find(needle)
if i != -1 and (start == -1 or i < start):
start = i
if start == -1:
m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE)
start = m.start() if m else 0
else:
start = max(0, start - 200)
search_region = raw_html[start:]
end_match = re.search(
r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b",
search_region,
re.IGNORECASE,
)
end = start + end_match.start() if end_match else len(raw_html)
end = min(end, start + 8_000_000)
return raw_html[start:end]
def _extract_text_from_html_string(html_str: str) -> str:
"""Parse an HTML string and return plain text (tables/scripts removed)."""
if not html_str or not html_str.strip():
return ""
try:
soup = BeautifulSoup(html_str, "lxml")
except Exception:
soup = BeautifulSoup(html_str, "html.parser")
for tag in soup.find_all(["table", "img", "svg", "style", "script"]):
tag.decompose()
return soup.get_text(separator="\n", strip=True)
def extract_text_from_html(html_path: Path) -> str:
"""Read an HTML file, slice to Items 1A-9A, and return plain text."""
try:
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
except Exception:
with open(html_path, "r", encoding="latin-1", errors="replace") as f:
raw = f.read()
chunk = _slice_html_items_1a_to_9a(raw)
return _extract_text_from_html_string(chunk)
def extract_text_from_file(file_path: Path) -> str:
"""Extract plain text from an HTML or TXT file."""
suf = file_path.suffix.lower()
if suf in (".htm", ".html"):
return extract_text_from_html(file_path)
if suf == ".txt":
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text
return ""
# ---------------------------------------------------------------------------
# Section finders
# ---------------------------------------------------------------------------
def _find_section_start(text: str, patterns: List[str], item_num: int) -> int:
"""Return character offset where *item_num* section begins, or -1."""
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
return m.start()
m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE)
return m.start() if m else -1
def find_item_section_generic(
text: str,
patterns: List[str],
item_num: int,
title_keywords: List[str],
max_chars: int = 120_000,
) -> str:
"""Extract a single Item section from full 10-K text."""
start = _find_section_start(text, patterns, item_num)
if start == -1:
pattern = re.compile(
r"\bItem\s+" + str(item_num)
+ r"\b[.\s]*[^\n]*("
+ "|".join(re.escape(k) for k in title_keywords)
+ r")?",
re.IGNORECASE,
)
match = pattern.search(text)
if not match:
return ""
start = match.start()
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
return text[start:end].strip()
def _extract_item_from_full(
text: str,
patterns: List[str],
item_num: int,
keywords: List[str],
max_chars: int = 60_000,
) -> str:
"""Extract one item section from full 10-K text."""
start = _find_section_start(text, patterns, item_num)
if start < 0:
pat = re.compile(
r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*",
re.IGNORECASE,
)
match = pat.search(text)
start = match.start() if match else -1
if start < 0:
return ""
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
return text[start:end].strip()
# ---------------------------------------------------------------------------
# Filing directory helpers
# ---------------------------------------------------------------------------
def _get_edgar_downloader() -> type:
"""Lazy import of ``sec_edgar_downloader.Downloader``."""
from sec_edgar_downloader import Downloader
return Downloader
def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]:
"""Locate the most recent 10-K filing directory on disk."""
ticker_upper = ticker.upper()
for base in (download_root / "sec-edgar-filings", download_root):
path_10k = base / ticker_upper / "10-K"
if path_10k.exists():
subdirs = sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
if subdirs:
return subdirs[0]
for base in (download_root / "sec-edgar-filings", download_root):
if not base.exists():
continue
for company_dir in base.iterdir():
if not company_dir.is_dir():
continue
path_10k = company_dir / "10-K"
if path_10k.exists():
subdirs = sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
if subdirs:
return subdirs[0]
return None
def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> List[Path]:
"""Return all 10-K filing directories sorted newest-first."""
ticker_upper = ticker.upper()
for base in (download_root / "sec-edgar-filings", download_root):
path_10k = base / ticker_upper / "10-K"
if path_10k.exists():
return sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
return []
def get_main_10k_text(filing_dir: Path) -> str:
"""Return the longest extracted text from all files in *filing_dir*."""
all_text: List[tuple] = []
for ext in ("*.htm", "*.html", "*.txt"):
for path in filing_dir.rglob(ext):
try:
t = extract_text_from_file(path)
if len(t) > 1000:
all_text.append((path, t))
except Exception:
continue
if not all_text:
return ""
_, main_text = max(all_text, key=lambda x: len(x[1]))
return main_text
# ---------------------------------------------------------------------------
# Cache layer
# ---------------------------------------------------------------------------
def _get_10k_cache_path(ticker: str) -> Path:
"""Path for cached 10-K sections: ``data/TICKER_latest.json``."""
_DATA_DIR.mkdir(parents=True, exist_ok=True)
return _DATA_DIR / f"{ticker.upper()}_latest.json"
def _load_10k_from_cache(ticker: str) -> Optional[Dict[str, str]]:
"""Load cached sections or return ``None`` if absent."""
path = _get_10k_cache_path(ticker)
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _save_10k_to_cache(ticker: str, data: Dict[str, str]) -> None:
"""Persist cleaned 10-K sections to the JSON cache."""
path = _get_10k_cache_path(ticker)
_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=0)
# ---------------------------------------------------------------------------
# High-level download + extract
# ---------------------------------------------------------------------------
def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]:
"""Download latest 10-K, extract Items 1A/3/7/8/9A, clean and cache."""
Downloader = _get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=1, download_details=True)
filing_dir = find_downloaded_10k_path(download_root, ticker)
if not filing_dir:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_text = get_main_10k_text(filing_dir)
if not full_text:
raise ValueError("Could not extract text from the 10-K.")
item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40_000)
item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40_000)
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
text_after_7 = full_text[start7:] if start7 >= 0 else full_text
item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7 and text_after_7:
item7 = text_after_7[:120_000]
item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200_000)
data: Dict[str, str] = {
"item1a": clean_text_for_llm(item1a or ""),
"item3": clean_text_for_llm(item3 or ""),
"item9a": clean_text_for_llm(item9a or ""),
"item7": clean_text_for_llm(item7 or ""),
"item8": clean_text_for_llm(item8 or ""),
}
_save_10k_to_cache(ticker, data)
return data
def get_10k_sections(ticker: str, email: str) -> tuple[Dict[str, str], str]:
"""Return ``(sections, status)``; *status* is ``'cache'`` or ``'downloaded'``."""
cached = _load_10k_from_cache(ticker)
if cached is not None:
return cached, "cache"
return download_and_extract_all_items(ticker, email), "downloaded"
def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple[str, str, str]:
"""Fetch 10-K and return ``(full_text, item1a, item7)``."""
sections, _ = get_10k_sections(ticker, email)
return "", sections.get("item1a", "") or "", sections.get("item7", "") or ""
def download_item7_latest_and_3y_ago(
ticker: str,
email: str,
) -> tuple[Optional[str], Optional[str], Optional[str], bool]:
"""Download up to 5 10-Ks; return item1a (latest), item7 latest, item7 3y ago, has_comparison."""
Downloader = _get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=5, download_details=True)
filing_dirs = find_all_10k_filing_dirs(download_root, ticker)
if not filing_dirs:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_latest = get_main_10k_text(filing_dirs[0])
if not full_latest:
raise ValueError("Could not extract text from the latest 10-K.")
item1a = find_item_section_generic(full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
s7 = _find_section_start(full_latest, ITEM7_PATTERNS, 7)
text_after_7 = full_latest[s7:] if s7 >= 0 else full_latest
item7_latest = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7_latest and text_after_7:
item7_latest = smart_chunk(text_after_7[:120_000], max_chars=20_000)
item7_3y_ago: Optional[str] = None
has_comparison = False
if len(filing_dirs) >= 4:
full_3y = get_main_10k_text(filing_dirs[3])
if full_3y:
s7_3y = _find_section_start(full_3y, ITEM7_PATTERNS, 7)
text_3y = full_3y[s7_3y:] if s7_3y >= 0 else full_3y
item7_3y_ago = find_item_section_generic(text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7_3y_ago and text_3y:
item7_3y_ago = smart_chunk(text_3y[:120_000], max_chars=20_000)
has_comparison = bool(item7_3y_ago)
return item1a or "", item7_latest or "", item7_3y_ago, has_comparison
@@ -0,0 +1,116 @@
"""Sensitivity analysis for DCF valuation.
Provides:
- WACC vs Terminal Growth sensitivity matrix
- Tornado chart data (variable impact ranking)
"""
from typing import Dict, List, Any
from server.services.dcf_engine import excel_style_dcf
def build_sensitivity_matrix(
fcf: float,
total_debt: float,
cash: float,
shares: float,
base_wacc: float,
base_tg: float,
fcf_growth: float,
wacc_steps: int = 6,
tg_steps: int = 5,
wacc_range: float = 0.02,
tg_range: float = 0.01,
) -> Dict[str, Any]:
"""Build a 2-D sensitivity matrix: WACC (rows) x Terminal Growth (cols).
Returns
-------
dict
wacc_values : list[float] row headers (percentages, e.g. 8.0)
tg_values : list[float] column headers (percentages, e.g. 2.5)
matrix : list[list[float | None]] per-share intrinsic values
"""
# Generate evenly-spaced WACC and TG values centred on base
wacc_values = [
round(base_wacc - wacc_range + (2 * wacc_range / max(wacc_steps - 1, 1)) * i, 4)
for i in range(wacc_steps)
]
tg_values = [
round(base_tg - tg_range + (2 * tg_range / max(tg_steps - 1, 1)) * i, 4)
for i in range(tg_steps)
]
matrix: List[List[Any]] = []
for w in wacc_values:
row: List[Any] = []
for tg in tg_values:
if w <= tg or w <= 0 or shares <= 0:
row.append(None)
else:
result = excel_style_dcf(fcf, w, tg, fcf_growth, total_debt, cash, shares)
vps = result.get("value_per_share")
row.append(round(vps, 2) if vps is not None else None)
matrix.append(row)
return {
"wacc_values": [round(w * 100, 2) for w in wacc_values],
"tg_values": [round(tg * 100, 2) for tg in tg_values],
"matrix": matrix,
}
def build_tornado_data(
fcf: float,
wacc: float,
tg: float,
growth: float,
debt: float,
cash: float,
shares: float,
) -> List[Dict[str, Any]]:
"""Compute tornado-chart data by varying each input ±10 %.
Returns a list sorted descending by impact range (high low).
Each entry: {"variable", "low", "high", "base"}.
"""
if shares <= 0:
return []
def _val(f, w, t, g, d, c) -> float | None:
if w <= t or w <= 0:
return None
r = excel_style_dcf(f, w, t, g, d, c, shares)
return r.get("value_per_share")
base_val = _val(fcf, wacc, tg, growth, debt, cash)
if base_val is None:
return []
variables = [
("WACC", lambda sign: _val(fcf, wacc * (1 + sign * 0.10), tg, growth, debt, cash)),
("FCF Growth", lambda sign: _val(fcf, wacc, tg, growth * (1 + sign * 0.10), debt, cash)),
("Terminal Growth", lambda sign: _val(fcf, wacc, tg * (1 + sign * 0.10), growth, debt, cash)),
("Base FCF", lambda sign: _val(fcf * (1 + sign * 0.10), wacc, tg, growth, debt, cash)),
("Total Debt", lambda sign: _val(fcf, wacc, tg, growth, debt * (1 + sign * 0.10), cash)),
("Cash", lambda sign: _val(fcf, wacc, tg, growth, debt, cash * (1 + sign * 0.10))),
]
results: List[Dict[str, Any]] = []
for name, func in variables:
val_up = func(0.10)
val_dn = func(-0.10)
if val_up is None or val_dn is None:
continue
low = round(min(val_up, val_dn), 2)
high = round(max(val_up, val_dn), 2)
results.append({
"variable": name,
"low": low,
"high": high,
"base": round(base_val, 2),
})
results.sort(key=lambda d: d["high"] - d["low"], reverse=True)
return results
@@ -0,0 +1,134 @@
"""Technical analysis service -- compute indicators and detect signals."""
import math
import pandas as pd
import ta
import yfinance as yf
def _safe(val, default=None):
if val is None:
return default
try:
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except Exception:
return default
def _series_to_list(s):
return [_safe(v) for v in s.tolist()]
def compute_all_indicators(ticker: str, period: str = "1y") -> dict:
"""Fetch OHLCV from yfinance and compute all TA indicators."""
df = yf.Ticker(ticker).history(period=period)
if df.empty:
return {}
close = df["Close"]
high = df["High"]
low = df["Low"]
volume = df["Volume"]
return {
"dates": df.index.strftime("%Y-%m-%d").tolist(),
"ohlc": {
"open": _series_to_list(df["Open"]),
"high": _series_to_list(high),
"low": _series_to_list(low),
"close": _series_to_list(close),
},
"volume": _series_to_list(volume),
"sma_20": _series_to_list(ta.trend.sma_indicator(close, window=20)),
"sma_50": _series_to_list(ta.trend.sma_indicator(close, window=50)),
"sma_200": _series_to_list(ta.trend.sma_indicator(close, window=200)),
"ema_12": _series_to_list(ta.trend.ema_indicator(close, window=12)),
"ema_26": _series_to_list(ta.trend.ema_indicator(close, window=26)),
"rsi": _series_to_list(ta.momentum.rsi(close, window=14)),
"macd": _series_to_list(ta.trend.macd(close)),
"macd_signal": _series_to_list(ta.trend.macd_signal(close)),
"macd_histogram": _series_to_list(ta.trend.macd_diff(close)),
"bb_upper": _series_to_list(ta.volatility.bollinger_hband(close)),
"bb_lower": _series_to_list(ta.volatility.bollinger_lband(close)),
"bb_middle": _series_to_list(ta.volatility.bollinger_mavg(close)),
"ichimoku_a": _series_to_list(ta.trend.ichimoku_a(high, low)),
"ichimoku_b": _series_to_list(ta.trend.ichimoku_b(high, low)),
"ichimoku_base": _series_to_list(ta.trend.ichimoku_base_line(high, low)),
"ichimoku_conversion": _series_to_list(
ta.trend.ichimoku_conversion_line(high, low)
),
"adx": _series_to_list(ta.trend.adx(high, low, close)),
"signals": detect_signals(df),
}
def detect_signals(df: pd.DataFrame) -> list:
"""Detect Golden Cross, Death Cross, RSI signals."""
signals = []
sma50 = ta.trend.sma_indicator(df["Close"], 50)
sma200 = ta.trend.sma_indicator(df["Close"], 200)
rsi = ta.momentum.rsi(df["Close"], 14)
for i in range(1, len(df)):
if (
pd.notna(sma50.iloc[i])
and pd.notna(sma200.iloc[i])
and pd.notna(sma50.iloc[i - 1])
and pd.notna(sma200.iloc[i - 1])
):
if (
sma50.iloc[i] > sma200.iloc[i]
and sma50.iloc[i - 1] <= sma200.iloc[i - 1]
):
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "golden_cross",
"label": "Golden Cross",
}
)
if (
sma50.iloc[i] < sma200.iloc[i]
and sma50.iloc[i - 1] >= sma200.iloc[i - 1]
):
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "death_cross",
"label": "Death Cross",
}
)
if pd.notna(rsi.iloc[i]) and pd.notna(rsi.iloc[i - 1]):
if rsi.iloc[i] > 30 and rsi.iloc[i - 1] <= 30:
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "rsi_oversold_bounce",
"label": "RSI Oversold Bounce",
}
)
if rsi.iloc[i] > 70 and rsi.iloc[i - 1] <= 70:
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "rsi_overbought",
"label": "RSI Overbought",
}
)
return signals
def compute_fibonacci_levels(high_52w: float, recent_low: float) -> dict:
"""Compute Fibonacci retracement levels from 52-week high and recent low."""
diff = high_52w - recent_low
return {
"high": high_52w,
"low": recent_low,
"level_236": recent_low + diff * 0.236,
"level_382": recent_low + diff * 0.382,
"level_500": recent_low + diff * 0.500,
"level_618": recent_low + diff * 0.618,
"level_786": recent_low + diff * 0.786,
}
@@ -0,0 +1,132 @@
"""Text cleaning and chunking utilities for LLM payloads.
Provides aggressive HTML-stripping, whitespace normalisation, and
intelligent splitting of long text into sequential chunks that avoid
cutting mid-sentence when possible.
"""
import re
from typing import List
from bs4 import BeautifulSoup
def clean_text_for_llm(html_content: str) -> str:
"""Strip HTML, collapse whitespace, and remove non-ASCII for LLM input.
Removes ``<table>``, ``<img>``, ``<style>``, ``<script>``, ``<svg>``,
and ``<math>`` elements before extracting text. Drops page-number-only
lines and other layout artefacts.
Parameters
----------
html_content:
Raw HTML (or already-plain text with residual tags).
Returns
-------
str
Clean, single-line-ish text suitable for an LLM prompt.
"""
if not html_content or not html_content.strip():
return ""
try:
soup = BeautifulSoup(html_content, "lxml")
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
tag.decompose()
text = soup.get_text(separator=" ")
except Exception:
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text)
text = " ".join(text.split())
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
lines: List[str] = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
continue
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
continue
lines.append(line)
result = " ".join(lines)
result = re.sub(r"\s+", " ", result).strip()
return result
def smart_chunk(
section: str,
max_chars: int = 10_000,
head_ratio: float = 0.5,
) -> str:
"""Truncate *section* to *max_chars* keeping head and tail portions.
When the text exceeds the limit the middle is replaced with a brief
``[ ... middle omitted ... ]`` marker. Approximately
``head_ratio * max_chars`` characters come from the start and the
remainder from the end.
Parameters
----------
section:
Full text to be trimmed.
max_chars:
Hard character budget (default 10 000 ~= 2.5k tokens).
head_ratio:
Fraction of the budget allocated to the leading portion.
Returns
-------
str
Text guaranteed to be at most *max_chars* characters long.
"""
if not section or len(section) <= max_chars:
return section
head_size = int(max_chars * head_ratio)
tail_size = max_chars - head_size - 100
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
def _split_into_chunks(
text: str,
max_chars: int = 22_000,
min_chunk: int = 5_000,
) -> List[str]:
"""Split *text* into sequential chunks without cutting mid-sentence.
Prefers breaking at paragraph boundaries (double newlines). Each chunk
is at most *max_chars* characters; the algorithm avoids creating a
trailing fragment shorter than *min_chunk* unless it is the only chunk.
Parameters
----------
text:
The document to split.
max_chars:
Maximum characters per chunk.
min_chunk:
Minimum look-back distance when searching for a break point.
Returns
-------
List[str]
Non-empty stripped chunks in document order.
"""
if not text or len(text) <= max_chars:
return [text] if text and text.strip() else []
chunks: List[str] = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
if end < len(text):
break_at = text.rfind("\n\n", start, end + 1)
if break_at > start + min_chunk:
end = break_at + 2
chunks.append(text[start:end].strip())
start = end
return [c for c in chunks if c]