mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-24 16:08:04 +00:00
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:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -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]
|
||||
Reference in New Issue
Block a user