mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-17 12:28:07 +00:00
refactor: modular architecture v3.0 + SEC filing viewer fix + README
Architecture (3,909-line monolith → 28 focused modules, all < 300 lines):
- config/: constants.py (company lists, row maps, Damodaran baselines), theme.py (CSS/HTML)
- utils/: prefs, formatting, ticker, dcf, charts, ui_helpers
- data/: sec_parser, sec_fetcher, sec_downloader, financials, fundamentals,
valuation, ratios, scores, scores_ai, market
- ai/: gemini_core, gemini_sec, gemini_insights
- views/: sidebar, tab1_quant, tab1_ai, tab1_filings, tab2_dcf,
tab3_comps, tab4_news, tab5_markets, tab6_crypto, tab7_technical
- app.py: thin orchestrator (~118 lines)
- Strict unidirectional dependency graph (no circular imports)
- All @st.cache_data TTLs and st.session_state keys preserved identically
SEC filing viewer fix:
- Rebuilt EDGAR fetch chain: company_tickers.json → CIK → submissions API
→ filings.recent.primaryDocument[] (replaces deprecated directory.item)
- Filing type selectbox (10-K, 10-Q, 8-K, 20-F, 6-K) connected to backend
- Native HTML rendered via streamlit.components.v1.html() with CSS reset
- Errors surfaced explicitly with st.error()
- DART direct links restored for Korean-listed companies
.gitignore: data/ → data/*.json + data/*.html (preserve Python modules)
README: full rewrite for master's portfolio — 7-tab layout, architecture
diagram, modular structure tree, technical challenges, design rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7ce5661569
commit
d337c63976
+140
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Plotly chart builders: Sankey, Radar, dark theme.
|
||||
"""
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
except ImportError:
|
||||
go = None
|
||||
|
||||
|
||||
def _build_sankey_figure(data: dict) -> "go.Figure":
|
||||
"""Sankey: Revenue -> COGS + Gross Profit; Gross Profit -> OpEx + OpInc; OpInc -> Tax/Interest/Other + Net Income."""
|
||||
if go is None:
|
||||
return None
|
||||
rev, cogs, gp, opex, opinc, tax_other, ni = (
|
||||
data["revenue"], data["cogs"], data["gross_profit"], data["opex"],
|
||||
data["operating_income"], data["tax_interest_other"], data["net_income"],
|
||||
)
|
||||
if rev <= 0:
|
||||
return None
|
||||
# Format labels with dollar values
|
||||
def _fmt(label, val):
|
||||
if abs(val) >= 1e9:
|
||||
return f"{label}<br>${val/1e9:.1f}B"
|
||||
if abs(val) >= 1e6:
|
||||
return f"{label}<br>${val/1e6:.0f}M"
|
||||
return label
|
||||
nodes = [
|
||||
_fmt("Revenue", rev), _fmt("Cost of Revenue", cogs), _fmt("Gross Profit", gp),
|
||||
_fmt("Operating Exp.", opex), _fmt("Operating Inc.", opinc),
|
||||
_fmt("Tax/Int./Other", tax_other), _fmt("Net Income", ni),
|
||||
]
|
||||
node_colors = [
|
||||
"#3B82F6", # Revenue — blue
|
||||
"#F87171", # COGS — red
|
||||
"#34D399", # Gross Profit — green
|
||||
"#FB923C", # OpEx — orange
|
||||
"#60A5FA", # Operating Income — light blue
|
||||
"#9CA3AF", # Tax/Interest — grey
|
||||
"#10B981", # Net Income — bright green
|
||||
]
|
||||
link_colors = [
|
||||
"rgba(248,113,113,0.3)", # Rev -> COGS (red flow)
|
||||
"rgba(52,211,153,0.3)", # Rev -> GP (green flow)
|
||||
"rgba(251,146,60,0.3)", # GP -> OpEx (orange flow)
|
||||
"rgba(96,165,250,0.3)", # GP -> OpInc (blue flow)
|
||||
"rgba(156,163,175,0.25)", # OpInc -> Tax (grey flow)
|
||||
"rgba(16,185,129,0.35)", # OpInc -> NI (green flow)
|
||||
]
|
||||
source = [0, 0, 2, 2, 4, 4]
|
||||
target = [1, 2, 3, 4, 5, 6]
|
||||
value = [max(0, float(v)) for v in [cogs, gp, opex, opinc, tax_other, ni]]
|
||||
fig = go.Figure(data=[go.Sankey(
|
||||
node=dict(label=nodes, color=node_colors, pad=20, thickness=24,
|
||||
line=dict(color="rgba(255,255,255,0.1)", width=1)),
|
||||
link=dict(source=source, target=target, value=value, color=link_colors),
|
||||
)])
|
||||
fig.update_layout(
|
||||
title=dict(text="Income Statement Flow", font=dict(size=14, color="#F3F4F6", family="Inter")),
|
||||
height=420, margin=dict(t=45, b=15, l=10, r=10),
|
||||
font=dict(size=12, color="#D1D5DB", family="Inter"),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _build_radar_common(theta_list, r_list, title_text="Financial Health Radar") -> "go.Figure":
|
||||
"""Shared radar chart builder with Soft Navy theme."""
|
||||
if go is None:
|
||||
return None
|
||||
theta = theta_list + [theta_list[0]]
|
||||
r = r_list + [r_list[0]]
|
||||
fig = go.Figure()
|
||||
# Add a "benchmark 50" ring for reference
|
||||
fig.add_trace(go.Scatterpolar(
|
||||
r=[50] * (len(theta_list) + 1), theta=theta,
|
||||
fill="toself", fillcolor="rgba(255,255,255,0.02)",
|
||||
line=dict(color="rgba(255,255,255,0.1)", width=1, dash="dot"),
|
||||
name="Avg (50)", hoverinfo="skip",
|
||||
))
|
||||
fig.add_trace(go.Scatterpolar(
|
||||
r=r, theta=theta, fill="toself",
|
||||
fillcolor="rgba(59, 130, 246, 0.2)",
|
||||
line=dict(color="#60A5FA", width=2.5),
|
||||
marker=dict(size=6, color="#60A5FA", symbol="circle"),
|
||||
name="Score",
|
||||
))
|
||||
fig.update_layout(
|
||||
polar=dict(
|
||||
bgcolor="rgba(0,0,0,0)",
|
||||
radialaxis=dict(visible=True, range=[0, 100], tickvals=[20, 40, 60, 80],
|
||||
tickfont=dict(size=9, color="#4B5563", family="JetBrains Mono"),
|
||||
gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.06)"),
|
||||
angularaxis=dict(tickfont=dict(size=11, color="#D1D5DB", family="Inter"),
|
||||
gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.08)"),
|
||||
),
|
||||
title=dict(text=title_text, font=dict(size=14, color="#F3F4F6", family="Inter")),
|
||||
height=420, showlegend=False,
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
margin=dict(t=45, b=25, l=60, r=60),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _build_radar_figure_from_metrics(metrics: dict) -> "go.Figure":
|
||||
"""Build radar chart from precomputed metrics dict."""
|
||||
if not metrics or not metrics.get("r"):
|
||||
return None
|
||||
return _build_radar_common(metrics["theta"], metrics["r"], "Financial Health Radar (10-K Item 8)")
|
||||
|
||||
|
||||
def _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct):
|
||||
"""Normalize 5 raw metrics to 0-100 for radar (same logic as get_radar_metrics_normalized)."""
|
||||
def n_roe(x): return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
|
||||
def n_cr(x): return min(100, max(0, x / 3 * 100)) if x is not None else 50
|
||||
def n_at(x): return min(100, max(0, x * 50)) if x is not None else 50
|
||||
def n_em(x): return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
|
||||
def n_yoy(x): 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)]
|
||||
|
||||
|
||||
def _build_radar_from_manual(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) -> "go.Figure":
|
||||
"""Build radar chart from 5 manually entered ratios (fallback)."""
|
||||
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
|
||||
r = _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct)
|
||||
return _build_radar_common(theta, r, "Financial Health Radar (Manual)")
|
||||
|
||||
|
||||
def _apply_dark_theme(fig):
|
||||
"""Apply Soft Navy theme to Plotly figures."""
|
||||
fig.update_layout(
|
||||
paper_bgcolor='rgba(0,0,0,0)',
|
||||
plot_bgcolor='rgba(255,255,255,0.02)',
|
||||
font=dict(color='#D1D5DB', family='Inter, JetBrains Mono, sans-serif', size=12),
|
||||
xaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)),
|
||||
yaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)),
|
||||
legend=dict(bgcolor='rgba(0,0,0,0)', bordercolor='rgba(255,255,255,0.06)', font=dict(size=11)),
|
||||
title_font=dict(color='#F3F4F6', size=14),
|
||||
margin=dict(l=40, r=20, t=40, b=40),
|
||||
)
|
||||
return fig
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
DCF valuation models and Damodaran WACC mapping.
|
||||
"""
|
||||
from config.constants import DAMODARAN_WACC
|
||||
|
||||
|
||||
def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_growth: float, years: int = 5) -> float:
|
||||
"""5-year DCF: project FCF with fcf_growth, then terminal value; discount at WACC. Returns enterprise value. Robust: avoids div by zero."""
|
||||
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 2-Stage DCF. Stage 1 (Y1-5): FCF grows at fcf_growth. Stage 2 (Y6-10): growth linearly fades from fcf_growth to term_growth by Y10. TV at Y10; discount all to PV."""
|
||||
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:
|
||||
"""10Y 2-Stage DCF: EV = PV(FCF Y1-10) + PV(TV); Equity = EV - Debt + Cash; Value per share = Equity / 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}
|
||||
|
||||
|
||||
def _damodaran_wacc_for_sector(sector: str) -> float:
|
||||
"""Map 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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Small formatting/type-safety helpers used across multiple modules.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _safe_float(x) -> Optional[float]:
|
||||
if x is None or (isinstance(x, float) and (x != x or pd.isna(x))):
|
||||
return None
|
||||
try:
|
||||
return float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_shares_display(shares: float) -> str:
|
||||
"""Format share count for UI, e.g. 15.42B Shares or 1.2B Shares."""
|
||||
if shares is None or shares <= 0:
|
||||
return "N/A"
|
||||
s = float(shares)
|
||||
if s >= 1e9:
|
||||
return f"{s / 1e9:.2f}B Shares"
|
||||
if s >= 1e6:
|
||||
return f"{s / 1e6:.2f}M Shares"
|
||||
if s >= 1e3:
|
||||
return f"{s / 1e3:.2f}K Shares"
|
||||
return f"{s:.0f} Shares"
|
||||
|
||||
|
||||
def _na(x):
|
||||
"""Return N/A for None/NaN, else value (for display)."""
|
||||
if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)):
|
||||
return "N/A"
|
||||
return x
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Local preferences: API keys, email, last ticker. Saved to .app_prefs.json.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_PREFS_PATH = Path(__file__).resolve().parent.parent / ".app_prefs.json"
|
||||
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def _load_prefs() -> dict:
|
||||
"""Load saved API keys and email from local file. Keys: google_api_key, sec_email."""
|
||||
try:
|
||||
if _PREFS_PATH.exists():
|
||||
with open(_PREFS_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_prefs(google_api_key: str, sec_email: str, last_ticker: str = None, last_company_options: list = None, last_company_symbols: list = None) -> None:
|
||||
"""Save API keys, email, and last selected company to local file."""
|
||||
try:
|
||||
data = {}
|
||||
if _PREFS_PATH.exists():
|
||||
try:
|
||||
with open(_PREFS_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
data["google_api_key"] = (google_api_key or "").strip()
|
||||
data["sec_email"] = (sec_email or "").strip()
|
||||
if last_ticker is not None:
|
||||
data["last_ticker"] = (last_ticker or "").strip()
|
||||
if last_company_options is not None:
|
||||
data["last_company_options"] = list(last_company_options) if last_company_options else []
|
||||
if last_company_symbols is not None:
|
||||
data["last_company_symbols"] = list(last_company_symbols) if last_company_symbols else []
|
||||
with open(_PREFS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Ticker formatting and market inference utilities.
|
||||
"""
|
||||
from config.constants import MARKET_OPTIONS
|
||||
|
||||
|
||||
def get_global_ticker(ticker: str, market: str) -> str:
|
||||
"""Format ticker for Yahoo Finance by market. US: as-is. South Korea: .KS or .KQ. Japan: .T. UK: .L. If ticker already has suffix, return as-is."""
|
||||
if not (ticker or "").strip():
|
||||
return (ticker or "").strip()
|
||||
t = (ticker or "").strip()
|
||||
if t.upper().endswith((".KS", ".KQ", ".T", ".L")):
|
||||
return t
|
||||
m = (market or "").strip()
|
||||
if "US" in m or not m:
|
||||
return t
|
||||
if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m:
|
||||
return t + ".KS"
|
||||
if "Japan" in m or "Nikkei" in m:
|
||||
return t + ".T"
|
||||
if "UK" in m or "LSE" in m:
|
||||
return t + ".L"
|
||||
return t
|
||||
|
||||
|
||||
def infer_market_from_ticker(ticker: str) -> str:
|
||||
"""Infer market label from ticker suffix (for Deep-Dive routing when no Market selector)."""
|
||||
if not (ticker or "").strip():
|
||||
return MARKET_OPTIONS[0]
|
||||
t = (ticker or "").strip().upper()
|
||||
if t.endswith(".KS") or t.endswith(".KQ"):
|
||||
return "South Korea (KOSPI/KOSDAQ)"
|
||||
if t.endswith(".T"):
|
||||
return "Japan (Nikkei)"
|
||||
if t.endswith(".L"):
|
||||
return "UK (LSE)"
|
||||
return "US (S&P/Dow/Nasdaq)"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
UI helper functions for rendering analyst consensus, sensitivity tables, etc.
|
||||
"""
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from utils.dcf import excel_style_dcf
|
||||
|
||||
|
||||
def _render_analyst_consensus(ticker: str):
|
||||
"""Render analyst consensus rating with visual badge and target prices."""
|
||||
from data.valuation import get_analyst_consensus
|
||||
consensus = get_analyst_consensus(ticker)
|
||||
if not consensus:
|
||||
st.caption("Analyst consensus data not available.")
|
||||
return
|
||||
|
||||
rec = (consensus.get("recommendationKey") or "N/A").upper()
|
||||
target_mean = consensus.get("targetMeanPrice")
|
||||
target_high = consensus.get("targetHighPrice")
|
||||
target_low = consensus.get("targetLowPrice")
|
||||
num_analysts = consensus.get("numberOfAnalystOpinions", "N/A")
|
||||
current = consensus.get("currentPrice")
|
||||
|
||||
# Rating badge colors
|
||||
if rec in ("BUY", "STRONG_BUY", "STRONG BUY"):
|
||||
badge_bg = "rgba(52, 211, 153, 0.15)"
|
||||
badge_border = "rgba(52, 211, 153, 0.4)"
|
||||
badge_color = "#34D399"
|
||||
elif rec in ("SELL", "STRONG_SELL", "STRONG SELL"):
|
||||
badge_bg = "rgba(248, 113, 113, 0.15)"
|
||||
badge_border = "rgba(248, 113, 113, 0.4)"
|
||||
badge_color = "#F87171"
|
||||
else:
|
||||
badge_bg = "rgba(251, 191, 36, 0.15)"
|
||||
badge_border = "rgba(251, 191, 36, 0.4)"
|
||||
badge_color = "#FBBF24"
|
||||
rec_display = rec.replace("_", " ")
|
||||
|
||||
upside = ""
|
||||
if target_mean and current and current > 0:
|
||||
upside_pct = (target_mean - current) / current * 100
|
||||
upside_color = "#34D399" if upside_pct > 0 else "#F87171"
|
||||
upside = f'<span style="color: {upside_color}; font-family: JetBrains Mono, monospace; font-weight: 600; margin-left: 12px;">{upside_pct:+.1f}% implied</span>'
|
||||
|
||||
if target_mean:
|
||||
st.markdown(f"""
|
||||
<div style="display: flex; align-items: center; gap: 16px; padding: 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div style="color: #6B7280; font-size: 0.65rem; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;">ANALYST RATING</div>
|
||||
<span style="display: inline-block; padding: 4px 14px; border-radius: 6px; font-weight: 700; font-family: JetBrains Mono, monospace; background: {badge_bg}; border: 1px solid {badge_border}; color: {badge_color}; font-size: 1.1rem; margin-top: 4px;">{rec_display}</span>
|
||||
{upside}
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; gap: 24px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">TARGET (MEAN)</div>
|
||||
<div style="color: #F3F4F6; font-size: 1.1rem; font-family: JetBrains Mono, monospace; font-weight: 700;">${target_mean:,.2f}</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">HIGH</div>
|
||||
<div style="color: #34D399; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">${target_high:,.2f}</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">LOW</div>
|
||||
<div style="color: #F87171; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">${target_low:,.2f}</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">ANALYSTS</div>
|
||||
<div style="color: #D1D5DB; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">{num_analysts}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
else:
|
||||
st.caption("Analyst targets not available.")
|
||||
|
||||
|
||||
def _render_sensitivity_table(fcf: float, total_debt: float, cash: float, shares: float):
|
||||
"""Render DCF sensitivity table: WACC vs Terminal Growth Rate."""
|
||||
wacc_range = [0.065, 0.070, 0.075, 0.080, 0.085, 0.090, 0.095, 0.100]
|
||||
tgr_range = [0.020, 0.025, 0.030, 0.035, 0.040]
|
||||
rows = []
|
||||
for tgr in tgr_range:
|
||||
row = {"TGR": f"{tgr*100:.1f}%"}
|
||||
for w in wacc_range:
|
||||
res = excel_style_dcf(fcf, w, tgr, 0.10, total_debt, cash, shares)
|
||||
val = res.get("value_per_share", 0)
|
||||
row[f"{w*100:.1f}%"] = f"${val:,.0f}" if val and val > 0 else "N/A"
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows).set_index("TGR")
|
||||
Reference in New Issue
Block a user