mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-20 14:18:05 +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
@@ -0,0 +1,142 @@
|
||||
import os
|
||||
import streamlit as st
|
||||
from utils.prefs import _load_prefs, _save_prefs, _PREFS_PATH
|
||||
from utils.ticker import infer_market_from_ticker
|
||||
|
||||
try:
|
||||
from yahooquery import search as yq_search
|
||||
except ImportError:
|
||||
yq_search = None
|
||||
|
||||
|
||||
def render_sidebar():
|
||||
with st.sidebar:
|
||||
st.markdown("""
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06);">
|
||||
<span style="font-size: 1.3rem; font-weight: 800; color: #60A5FA; font-family: 'JetBrains Mono', monospace;">ATLAS</span>
|
||||
<span style="font-size: 1.3rem; font-weight: 300; color: #6B7280;">TERMINAL</span>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown('<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 2px; margin-bottom: 8px;">SETTINGS</div>', unsafe_allow_html=True)
|
||||
_prefs = _load_prefs()
|
||||
# Restore last selected company on refresh (session_state is empty after reload)
|
||||
if _prefs.get("last_ticker") and not st.session_state.get("company_search_options"):
|
||||
_lt = _prefs["last_ticker"]
|
||||
_opts = _prefs.get("last_company_options") or []
|
||||
_syms = _prefs.get("last_company_symbols") or []
|
||||
if not _opts and _lt:
|
||||
_opts = [f"[Saved] {_lt}"]
|
||||
_syms = [_lt]
|
||||
st.session_state["ticker"] = _lt
|
||||
st.session_state["company_search_options"] = _opts
|
||||
st.session_state["company_search_symbols"] = _syms
|
||||
_default_key = _prefs.get("google_api_key") or os.environ.get("GOOGLE_API_KEY", "")
|
||||
_default_email = _prefs.get("sec_email") or os.environ.get("SEC_EDGAR_EMAIL", "")
|
||||
google_api_key = st.text_input(
|
||||
"Google API Key (Gemini)",
|
||||
value=_default_key,
|
||||
help="Required for Tab 1 (10-K insights).",
|
||||
key="input_google_api_key",
|
||||
)
|
||||
sec_email = st.text_input(
|
||||
"SEC EDGAR Email",
|
||||
value=_default_email,
|
||||
help="Required for 10-K download.",
|
||||
key="input_sec_email",
|
||||
)
|
||||
remember_me = st.checkbox(
|
||||
"Remember API key & email (save locally)",
|
||||
value=bool(_prefs),
|
||||
help="Store in .app_prefs.json in this project. Uncheck to clear and stop saving.",
|
||||
key="remember_me",
|
||||
)
|
||||
if remember_me and (google_api_key or sec_email):
|
||||
_save_prefs(google_api_key, sec_email)
|
||||
elif not remember_me and _PREFS_PATH.exists():
|
||||
# Clear only API keys in prefs; keep last_ticker so company selection persists on refresh
|
||||
try:
|
||||
_cur = _load_prefs()
|
||||
_save_prefs("", "", last_ticker=_cur.get("last_ticker"), last_company_options=_cur.get("last_company_options"), last_company_symbols=_cur.get("last_company_symbols"))
|
||||
except Exception:
|
||||
pass
|
||||
st.markdown('<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 2px; margin: 16px 0 8px;">COMPANY SEARCH</div>', unsafe_allow_html=True)
|
||||
search_query = st.text_input(
|
||||
"Search Company Name (e.g., Apple, 삼성, Mitsubishi)",
|
||||
value=st.session_state.get("company_search_input", ""),
|
||||
key="company_search_input",
|
||||
placeholder="e.g. Apple, 삼성, Mitsubishi",
|
||||
)
|
||||
if st.button("Search Company", key="search_company_btn"):
|
||||
query = (search_query or "").strip()
|
||||
if not query:
|
||||
st.warning("Enter a company name to search.")
|
||||
elif yq_search is None:
|
||||
st.warning("yahooquery is not installed; search is unavailable.")
|
||||
else:
|
||||
try:
|
||||
raw_results = yq_search(query)
|
||||
if not isinstance(raw_results, dict):
|
||||
raw_results = {}
|
||||
quotes = raw_results.get("quotes", []) or []
|
||||
skip_types = ("INDEX", "MUTUALFUND")
|
||||
quotes = [
|
||||
q for q in quotes
|
||||
if q.get("symbol") and q.get("shortname")
|
||||
and (q.get("quoteType") or "EQUITY") not in skip_types
|
||||
]
|
||||
if not quotes:
|
||||
st.session_state["company_search_options"] = []
|
||||
st.session_state["company_search_symbols"] = []
|
||||
st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').")
|
||||
else:
|
||||
options = []
|
||||
symbols = []
|
||||
for q in quotes[:50]:
|
||||
sym = (q.get("symbol") or "").strip()
|
||||
options.append(f"[{q.get('exchange', 'N/A')}] {q.get('symbol')} - {q.get('shortname', 'Unknown')}")
|
||||
symbols.append(sym)
|
||||
st.session_state["company_search_options"] = options
|
||||
st.session_state["company_search_symbols"] = symbols
|
||||
st.session_state["ticker"] = symbols[0]
|
||||
st.success(f"Found {len(options)} result(s). Select below.")
|
||||
except Exception:
|
||||
st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').")
|
||||
st.session_state["company_search_options"] = []
|
||||
st.session_state["company_search_symbols"] = []
|
||||
|
||||
search_options = st.session_state.get("company_search_options") or []
|
||||
search_symbols = st.session_state.get("company_search_symbols") or []
|
||||
placeholder = "— Click the search button above —"
|
||||
options_for_select = [placeholder] if not search_options else search_options
|
||||
current_ticker = st.session_state.get("ticker", "NVDA")
|
||||
default_idx = 0
|
||||
if search_symbols and current_ticker:
|
||||
for i, sym in enumerate(search_symbols):
|
||||
if sym == current_ticker:
|
||||
default_idx = i
|
||||
break
|
||||
selected_option = st.selectbox(
|
||||
"Select company (ticker - name)",
|
||||
options=options_for_select,
|
||||
index=0 if not search_options else min(default_idx, len(search_options) - 1),
|
||||
key="company_select",
|
||||
)
|
||||
if search_options and selected_option and selected_option != placeholder and " - " in selected_option:
|
||||
first_part = selected_option.split(" - ", 1)[0].strip()
|
||||
sym = first_part.split("]", 1)[-1].strip() if "]" in first_part else first_part
|
||||
st.session_state["ticker"] = sym
|
||||
ticker = st.session_state.get("ticker") or (search_symbols[0] if search_symbols else "NVDA")
|
||||
st.session_state["google_api_key"] = google_api_key
|
||||
st.session_state["sec_email"] = sec_email
|
||||
st.session_state["ticker"] = ticker
|
||||
st.session_state["market"] = infer_market_from_ticker(ticker)
|
||||
# Persist selected company so it survives page refresh
|
||||
_save_prefs(
|
||||
google_api_key if st.session_state.get("remember_me") else "",
|
||||
sec_email if st.session_state.get("remember_me") else "",
|
||||
last_ticker=ticker,
|
||||
last_company_options=search_options,
|
||||
last_company_symbols=search_symbols,
|
||||
)
|
||||
st.caption("Search by name (any language), then select. Ticker suffix is set automatically.")
|
||||
return ticker
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tab 1 — Deep-Dive AI Analysis: Management Strategy & Risk Factors buttons,
|
||||
display of saved results."""
|
||||
|
||||
import streamlit as st
|
||||
from data.sec_downloader import get_10k_sections
|
||||
from data.fundamentals import get_sector_industry
|
||||
from ai.gemini_core import _gemini_forensic_audit
|
||||
from ai.gemini_sec import get_gemini_item7_strategy_stream, get_gemini_item1a_risks_stream
|
||||
|
||||
|
||||
def render_tab1_ai_analysis(ticker, quant_ticker, market):
|
||||
"""Render the Deep-Dive Analysis (AI) section of Tab 1."""
|
||||
st.markdown("---")
|
||||
st.markdown("#### 🔍 Deep-Dive Analysis (AI)")
|
||||
st.caption("10-K sections are cached in **data/**; repeat runs use cache for instant AI analysis. First run may take 20–60 s to fetch 10-K; Gemini then streams in ~5–10 s.")
|
||||
if not ticker:
|
||||
st.caption("Enter a ticker in the sidebar to enable analysis.")
|
||||
else:
|
||||
api_ok = bool(st.session_state.get("google_api_key"))
|
||||
email_ok = bool(st.session_state.get("sec_email"))
|
||||
err_msg = []
|
||||
if not api_ok:
|
||||
err_msg.append("Google API Key")
|
||||
if not email_ok:
|
||||
err_msg.append("SEC EDGAR Email")
|
||||
if err_msg:
|
||||
st.caption(f"Set **{' and '.join(err_msg)}** in the sidebar to run analysis.")
|
||||
col_a, col_b = st.columns(2)
|
||||
is_us = market and "US" in market
|
||||
is_korea = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market)
|
||||
is_japan_uk = market and ("Japan" in market or "Nikkei" in market or "UK" in market or "LSE" in market)
|
||||
# --- Button A: Management Strategy ---
|
||||
with col_a:
|
||||
if st.button("Analyze Management Strategy (MD&A)", key="run_mda_strategy"):
|
||||
if is_korea:
|
||||
st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.")
|
||||
elif is_japan_uk:
|
||||
st.warning("EDINET/LSE document parsing is currently under development.")
|
||||
elif not api_ok or not email_ok:
|
||||
st.error("Set API Key and SEC Email in the sidebar.")
|
||||
else:
|
||||
try:
|
||||
with st.status("Loading 10-K (cache or download)...", expanded=True) as status:
|
||||
sections, _ = get_10k_sections(ticker, st.session_state["sec_email"])
|
||||
si = get_sector_industry(quant_ticker)
|
||||
status.update(label="10-K loaded. Calling Gemini…", state="running")
|
||||
|
||||
# Stream OUTSIDE the status box so user sees text as it arrives
|
||||
st.markdown("### Management Strategy (Item 7)")
|
||||
st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).")
|
||||
stream_gen = get_gemini_item7_strategy_stream(
|
||||
st.session_state["google_api_key"],
|
||||
sections.get("item7") or "",
|
||||
ticker,
|
||||
si.get("sector") or "N/A",
|
||||
si.get("industry") or "N/A",
|
||||
)
|
||||
# write_stream returns the full concatenated string after it finishes streaming
|
||||
full_response = st.write_stream(stream_gen)
|
||||
|
||||
st.session_state["mda_strategy_result"] = full_response
|
||||
st.session_state["mda_strategy_ticker"] = ticker
|
||||
st.session_state["mda_strategy_error"] = None
|
||||
except Exception as e:
|
||||
st.session_state["mda_strategy_error"] = str(e)
|
||||
st.error(f"Strategy analysis failed: {str(e)}")
|
||||
# --- Button B: Risk Factors & Forensic ---
|
||||
with col_b:
|
||||
if st.button("Analyze Risk Factors (Item 1A)", key="run_mda_risk"):
|
||||
if is_korea:
|
||||
st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.")
|
||||
elif is_japan_uk:
|
||||
st.warning("EDINET/LSE document parsing is currently under development.")
|
||||
elif not api_ok or not email_ok:
|
||||
st.error("Set API Key and SEC Email in the sidebar.")
|
||||
else:
|
||||
try:
|
||||
with st.status("Loading 10-K (cache or download)...", expanded=True) as status:
|
||||
sections, _ = get_10k_sections(ticker, st.session_state["sec_email"])
|
||||
status.update(label="10-K loaded. Running forensic audit…", state="running")
|
||||
|
||||
# Run forensic silently IN THE BACKGROUND first
|
||||
forensic = _gemini_forensic_audit(
|
||||
st.session_state["google_api_key"],
|
||||
sections.get("item3") or "",
|
||||
sections.get("item9a") or "",
|
||||
ticker,
|
||||
)
|
||||
status.update(label="Done.", state="complete")
|
||||
# Stream the risk factors OUTSIDE the status box
|
||||
st.markdown("### Risk Factors (Item 1A)")
|
||||
st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).")
|
||||
stream_gen = get_gemini_item1a_risks_stream(
|
||||
st.session_state["google_api_key"],
|
||||
sections.get("item1a") or "",
|
||||
ticker,
|
||||
)
|
||||
risk_response = st.write_stream(stream_gen)
|
||||
|
||||
# Combine both for the final result
|
||||
final_out = risk_response
|
||||
if forensic and forensic.strip():
|
||||
st.markdown("### Forensic Audit (Item 3 & 9A)")
|
||||
st.markdown(forensic.strip())
|
||||
final_out += f"\n\n---\n\n### Forensic Audit (Item 3 & 9A)\n\n{forensic.strip()}"
|
||||
|
||||
st.session_state["mda_risk_result"] = final_out
|
||||
st.session_state["mda_risk_ticker"] = ticker
|
||||
st.session_state["mda_risk_error"] = None
|
||||
except Exception as e:
|
||||
st.session_state["mda_risk_error"] = str(e)
|
||||
st.error(f"Risk analysis failed: {str(e)}")
|
||||
# --- Display Saved Results if User Switches Tabs ---
|
||||
st.markdown("---")
|
||||
if st.session_state.get("mda_strategy_ticker") == ticker:
|
||||
if st.session_state.get("mda_strategy_error"):
|
||||
st.error("Strategy Error: " + st.session_state["mda_strategy_error"])
|
||||
elif st.session_state.get("mda_strategy_result"):
|
||||
with st.expander("View Previous Strategy Analysis", expanded=True):
|
||||
st.markdown(st.session_state["mda_strategy_result"])
|
||||
if st.session_state.get("mda_risk_ticker") == ticker:
|
||||
if st.session_state.get("mda_risk_error"):
|
||||
st.error("Risk Error: " + st.session_state["mda_risk_error"])
|
||||
elif st.session_state.get("mda_risk_result"):
|
||||
with st.expander("View Previous Risk & Forensic Analysis", expanded=True):
|
||||
st.markdown(st.session_state["mda_risk_result"])
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tab 1 — SEC/DART Original Filing Viewer (Native HTML rendering)."""
|
||||
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components_v1
|
||||
|
||||
from data.sec_fetcher import fetch_sec_filing_html, _wrap_edgar_html_for_iframe
|
||||
|
||||
|
||||
def render_tab1_filings(ticker, market):
|
||||
"""Render the SEC / DART original filing viewer section of Tab 1."""
|
||||
st.markdown("---")
|
||||
st.markdown("#### 공시 원본 뷰어 (SEC Filing / DART)")
|
||||
|
||||
is_us_filing = market and "US" in market
|
||||
is_kr_filing = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market)
|
||||
|
||||
if is_us_filing:
|
||||
# ── [4] Filing type selector ──
|
||||
_filing_col1, _filing_col2, _filing_col3 = st.columns([1.5, 2, 1.5])
|
||||
with _filing_col1:
|
||||
_sec_filing_type = st.selectbox(
|
||||
"SEC Filing Type",
|
||||
["10-K", "10-Q", "8-K", "20-F", "6-K"],
|
||||
index=0,
|
||||
key="sec_filing_type_select",
|
||||
)
|
||||
with _filing_col2:
|
||||
st.caption("") # spacer
|
||||
_edgar_search_url = f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK={ticker}&type={_sec_filing_type}&dateb=&owner=include&count=10"
|
||||
st.markdown(
|
||||
f'<a href="{_edgar_search_url}" target="_blank" style="font-size:0.8rem;color:#60A5FA;">↗ SEC EDGAR에서 {_sec_filing_type} 검색</a>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with _filing_col3:
|
||||
_fetch_btn = st.button(f"📄 {_sec_filing_type} 원본 가져오기", key="fetch_sec_filing_btn")
|
||||
|
||||
# ── [5] Fetch with selected filing type ──
|
||||
if _fetch_btn or st.session_state.get("_last_sec_filing_html"):
|
||||
if _fetch_btn:
|
||||
with st.spinner(f"EDGAR에서 {ticker} {_sec_filing_type} 원본 HTML을 가져오는 중..."):
|
||||
_result = fetch_sec_filing_html(ticker, _sec_filing_type)
|
||||
st.session_state["_last_sec_filing_html"] = _result.get("html")
|
||||
st.session_state["_last_sec_filing_error"] = _result.get("error")
|
||||
st.session_state["_last_sec_filing_source"] = _result.get("source")
|
||||
st.session_state["_last_sec_filing_url"] = _result.get("doc_url")
|
||||
st.session_state["_last_sec_filing_type"] = _sec_filing_type
|
||||
|
||||
_raw_html = st.session_state.get("_last_sec_filing_html")
|
||||
_fetch_error = st.session_state.get("_last_sec_filing_error")
|
||||
_html_source = st.session_state.get("_last_sec_filing_source")
|
||||
_doc_url = st.session_state.get("_last_sec_filing_url")
|
||||
|
||||
# ── [3] Show errors explicitly — never silently swallow ──
|
||||
if _fetch_error:
|
||||
st.error(f"SEC API Error: {_fetch_error}")
|
||||
if _doc_url:
|
||||
st.code(_doc_url, language="text")
|
||||
|
||||
if _raw_html:
|
||||
_doc_size_mb = len(_raw_html.encode("utf-8")) / 1e6
|
||||
_src_label = "디스크 캐시" if _html_source == "cache" else "EDGAR API"
|
||||
st.caption(f"원본 HTML 렌더링 · {_doc_size_mb:.1f} MB · 출처: {_src_label}")
|
||||
_wrapped = _wrap_edgar_html_for_iframe(_raw_html, ticker)
|
||||
components_v1.html(_wrapped, height=900, scrolling=True)
|
||||
if _doc_size_mb > 5:
|
||||
st.caption(f"⚠ 문서가 큽니다({_doc_size_mb:.1f} MB). 느릴 경우 위 EDGAR 링크에서 원본 페이지를 여세요.")
|
||||
else:
|
||||
st.info(f"위 버튼을 클릭하면 {ticker}의 최신 {_sec_filing_type} 원본 문서를 SEC EDGAR에서 가져옵니다.")
|
||||
|
||||
elif is_kr_filing:
|
||||
# ── [6] Korean DART direct links ──
|
||||
st.markdown("##### 🇰🇷 DART 공시 원본")
|
||||
_dart_code = ticker.replace(".KS", "").replace(".KQ", "").strip()
|
||||
_dart_col1, _dart_col2 = st.columns(2)
|
||||
with _dart_col1:
|
||||
_dart_company_url = f"https://dart.fss.or.kr/dsab001/main.do?autoSearch=true&textCrpNm={_dart_code}"
|
||||
st.markdown(
|
||||
f'<a href="{_dart_company_url}" target="_blank" '
|
||||
f'style="display:inline-block;padding:8px 16px;background:#1E40AF;color:white;border-radius:6px;'
|
||||
f'text-decoration:none;font-size:0.85rem;font-weight:600;">'
|
||||
f'📋 DART 전체 공시 보기 ({_dart_code})</a>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with _dart_col2:
|
||||
_dart_annual_url = f"https://dart.fss.or.kr/dsab001/main.do?autoSearch=true&textCrpNm={_dart_code}&rghtBbstp=L"
|
||||
st.markdown(
|
||||
f'<a href="{_dart_annual_url}" target="_blank" '
|
||||
f'style="display:inline-block;padding:8px 16px;background:#065F46;color:white;border-radius:6px;'
|
||||
f'text-decoration:none;font-size:0.85rem;font-weight:600;">'
|
||||
f'📊 DART 사업보고서 바로가기</a>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.caption("DART 전자공시시스템에서 사업보고서, 분기보고서, 주요사항보고서 등 원본을 열람할 수 있습니다.")
|
||||
else:
|
||||
st.caption("공시 뷰어: US 종목(SEC EDGAR) 또는 한국 종목(DART)을 선택하세요.")
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tab 1 — Financial Health (Tables & Charts): Sankey, Radar, F-Score,
|
||||
Altman Z, red flags, sector metrics, YoY changes, quarterly momentum/ratios."""
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from data.sec_downloader import get_10k_sections
|
||||
from data.ratios import get_dupont_altman_redflags_yoy, get_quarterly_momentum, get_quarterly_ratio_changes
|
||||
from data.scores import (
|
||||
get_income_statement_sankey_data, get_radar_metrics_normalized, _build_radar_figure,
|
||||
get_piotroski_fscore, get_sector_specific_metrics,
|
||||
)
|
||||
from data.scores_ai import sankey_data_from_ai, piotroski_from_ai, radar_metrics_from_ai
|
||||
from ai.gemini_sec import get_sec_financials_llm
|
||||
from utils.charts import (
|
||||
_build_sankey_figure, _build_radar_figure_from_metrics,
|
||||
_build_radar_from_manual, _apply_dark_theme,
|
||||
)
|
||||
try:
|
||||
from yahooquery import Ticker as YQTicker
|
||||
except ImportError:
|
||||
YQTicker = None
|
||||
|
||||
|
||||
def _style_change_column(df: pd.DataFrame):
|
||||
"""Green for improvement (+), red for decline (-) in Change column."""
|
||||
change_col = "Change (%)" if "Change (%)" in df.columns else "Change"
|
||||
if change_col not in df.columns or df.empty:
|
||||
return df.style
|
||||
def _cell_style(v):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)):
|
||||
return ""
|
||||
s = str(v).strip()
|
||||
if s == "—":
|
||||
return ""
|
||||
if s.startswith("+") or "↑" in s:
|
||||
return "background-color: #d4edda; color: #155724"
|
||||
if s.startswith("-") or "↓" in s:
|
||||
return "background-color: #f8d7da; color: #721c24"
|
||||
return ""
|
||||
return df.style.apply(lambda col: [_cell_style(v) for v in col], subset=[change_col])
|
||||
|
||||
|
||||
def render_tab1_quantitative(ticker, quant_ticker, market, sector, industry, google_api_key, sec_email):
|
||||
"""Render the Financial Health section of Tab 1."""
|
||||
ai_data = {}
|
||||
if market and "US" in market and google_api_key and sec_email:
|
||||
with st.spinner("SEC 10-K 원본에서 재무제표 데이터를 해독하여 그래프를 생성 중입니다... (약 30~60초 소요)"):
|
||||
sections, _ = get_10k_sections(ticker, sec_email)
|
||||
item8 = (sections or {}).get("item8") or ""
|
||||
if item8.strip():
|
||||
ai_data = get_sec_financials_llm(google_api_key, item8, ticker)
|
||||
q = get_dupont_altman_redflags_yoy(quant_ticker)
|
||||
dupont_df = (q or {}).get("dupont") if q else None
|
||||
if q or ai_data:
|
||||
st.markdown("---")
|
||||
st.markdown("#### 📊 Financial Health (Tables & Charts)")
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
if ai_data and ai_data.get("current_yr"):
|
||||
sankey_data = sankey_data_from_ai(ai_data)
|
||||
else:
|
||||
sankey_data = get_income_statement_sankey_data(quant_ticker)
|
||||
if sankey_data.get("revenue", 0) > 0:
|
||||
fig_sankey = _build_sankey_figure(sankey_data)
|
||||
if fig_sankey is not None:
|
||||
_apply_dark_theme(fig_sankey)
|
||||
st.plotly_chart(fig_sankey, use_container_width=True)
|
||||
else:
|
||||
st.caption("Income Statement flow: data not available.")
|
||||
with c2:
|
||||
if ai_data and ai_data.get("current_yr"):
|
||||
radar_metrics = radar_metrics_from_ai(ai_data)
|
||||
fig_radar = _build_radar_figure_from_metrics(radar_metrics) if radar_metrics else None
|
||||
else:
|
||||
fig_radar = _build_radar_figure(quant_ticker)
|
||||
if fig_radar is not None:
|
||||
_apply_dark_theme(fig_radar)
|
||||
st.plotly_chart(fig_radar, use_container_width=True)
|
||||
else:
|
||||
st.caption("Financial radar: need 2+ years of data.")
|
||||
with st.expander("Manual Data Entry (Radar Chart Fallback)", expanded=False):
|
||||
st.caption("Enter 5 key ratios to plot a custom radar. ROE %, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY %.")
|
||||
roe_man = st.number_input("ROE %", value=15.0, min_value=-50.0, max_value=100.0, step=1.0, key="radar_roe")
|
||||
cr_man = st.number_input("Current Ratio", value=1.5, min_value=0.0, max_value=10.0, step=0.1, key="radar_cr")
|
||||
at_man = st.number_input("Asset Turnover", value=0.8, min_value=0.0, max_value=5.0, step=0.1, key="radar_at")
|
||||
em_man = st.number_input("Equity Mult.", value=2.0, min_value=0.5, max_value=10.0, step=0.1, key="radar_em")
|
||||
yoy_man = st.number_input("Revenue YoY %", value=10.0, min_value=-50.0, max_value=200.0, step=1.0, key="radar_yoy")
|
||||
if st.button("Plot Radar", key="radar_plot_btn"):
|
||||
fig_man = _build_radar_from_manual(roe_man, cr_man, at_man, em_man, yoy_man)
|
||||
if fig_man is not None:
|
||||
st.session_state["radar_manual_fig"] = fig_man
|
||||
if st.session_state.get("radar_manual_fig") is not None:
|
||||
_apply_dark_theme(st.session_state["radar_manual_fig"])
|
||||
st.plotly_chart(st.session_state["radar_manual_fig"], use_container_width=True)
|
||||
with st.expander("Debug: Raw YahooQuery Data", expanded=False):
|
||||
if YQTicker and quant_ticker:
|
||||
try:
|
||||
yq_ticker = YQTicker(quant_ticker.upper())
|
||||
inc_raw = yq_ticker.income_statement(trailing=False)
|
||||
bal_raw = yq_ticker.balance_sheet(trailing=False)
|
||||
if inc_raw is not None and not inc_raw.empty:
|
||||
st.caption("Income statement (last 2 periods) — check column names for mapping.")
|
||||
st.dataframe(inc_raw.tail(2), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("Income statement: no data.")
|
||||
if bal_raw is not None and not bal_raw.empty:
|
||||
st.caption("Balance sheet (last 2 periods) — check column names for mapping.")
|
||||
st.dataframe(bal_raw.tail(2), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("Balance sheet: no data.")
|
||||
except Exception as e:
|
||||
st.error(f"YahooQuery debug failed: {e}")
|
||||
else:
|
||||
st.caption("YahooQuery not available or no ticker selected.")
|
||||
if ai_data and ai_data.get("current_yr"):
|
||||
piot = piotroski_from_ai(ai_data)
|
||||
else:
|
||||
piot = get_piotroski_fscore(quant_ticker)
|
||||
st.markdown("**Piotroski F-Score (9-point checklist)**")
|
||||
score = piot.get("score", 0)
|
||||
legend = "**Score 8–9: Excellent** · 4–7: Average · 0–3: High Risk"
|
||||
st.metric("F-Score", f"{score} / 9", legend)
|
||||
if ai_data and ai_data.get("current_yr"):
|
||||
st.caption("*(from SEC 10-K Item 8)*")
|
||||
elif piot.get("used_ttm"):
|
||||
st.caption("*(Estimated via TTM Data)*")
|
||||
st.caption("✅ = Good (passes criterion). ❌ = Fails criterion.")
|
||||
criteria = piot.get("criteria", [])
|
||||
if criteria:
|
||||
cols = st.columns(3)
|
||||
for i, (label, passed) in enumerate(criteria):
|
||||
with cols[i % 3]:
|
||||
st.caption(("✅ " if passed else "❌ ") + label)
|
||||
az = (q or {}).get("altman_z")
|
||||
if az is not None:
|
||||
st.caption(f"**Altman Z-Score:** {az} (Safe > 2.99 · Grey 1.81–2.99 · Distress < 1.81)")
|
||||
red_flags = (q or {}).get("red_flags") or []
|
||||
if red_flags:
|
||||
for rf in red_flags:
|
||||
val = rf.get("value")
|
||||
val_str = "N/A" if (val is None or (isinstance(val, float) and (pd.isna(val) or val != val))) else val
|
||||
st.warning(f"**{rf.get('metric')}:** {val_str} (threshold: {rf.get('threshold')})")
|
||||
elif dupont_df is not None and not dupont_df.empty:
|
||||
st.success("No red flags (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).")
|
||||
sector_metrics = get_sector_specific_metrics(quant_ticker, sector) if quant_ticker else {}
|
||||
if sector_metrics:
|
||||
st.markdown("**Sector-specific metrics**")
|
||||
cols = st.columns(min(len(sector_metrics), 4))
|
||||
for i, (k, v) in enumerate(sector_metrics.items()):
|
||||
with cols[i % len(cols)]:
|
||||
disp = f"{v}" if v is not None else "N/A"
|
||||
st.metric(k, disp, None)
|
||||
yoy_list = (q or {}).get("yoy") or []
|
||||
if yoy_list:
|
||||
st.markdown("**YoY ratio changes**")
|
||||
rows_yoy = []
|
||||
for item in yoy_list:
|
||||
cur = item.get("Latest")
|
||||
if cur is not None and isinstance(cur, (int, float)):
|
||||
cur = round(cur, 2)
|
||||
chg_pp = item.get("YoY (pp)")
|
||||
chg_pct = item.get("YoY %")
|
||||
if chg_pp is not None:
|
||||
chg_str = f"{chg_pp:+.1f}%"
|
||||
elif chg_pct is not None:
|
||||
chg_str = f"{chg_pct:+.1f}%"
|
||||
else:
|
||||
chg_str = "—"
|
||||
status = "↑" if (chg_pp is not None and chg_pp > 0) or (chg_pct is not None and chg_pct > 0) else ("↓" if (chg_pp is not None and chg_pp < 0) or (chg_pct is not None and chg_pct < 0) else "—")
|
||||
cur_disp = f"{cur:.2f}" if isinstance(cur, (int, float)) else ("—" if cur is None else str(cur))
|
||||
rows_yoy.append({"Metric": item.get("Ratio"), "Current Value": cur_disp, "Change (%)": chg_str, "Status": status})
|
||||
if rows_yoy:
|
||||
df_yoy = pd.DataFrame(rows_yoy)
|
||||
st.dataframe(_style_change_column(df_yoy), use_container_width=True, hide_index=True)
|
||||
st.markdown("**Quarter ratio changes**")
|
||||
qmom = get_quarterly_momentum(quant_ticker)
|
||||
qoq_rows = get_quarterly_ratio_changes(quant_ticker)
|
||||
qoq_r, qoq_n = qmom.get("qoq_revenue_pct"), qmom.get("qoq_ni_pct")
|
||||
build = []
|
||||
if qoq_r is not None:
|
||||
build.append({"Metric": "Revenue", "Current Value": "—", "Change (%)": f"{qoq_r:+.1f}%", "Status": "↑" if qoq_r > 0 else "↓"})
|
||||
if qoq_n is not None:
|
||||
build.append({"Metric": "Net Income", "Current Value": "—", "Change (%)": f"{qoq_n:+.1f}%", "Status": "↑" if qoq_n > 0 else "↓"})
|
||||
for r in qoq_rows:
|
||||
r_copy = dict(r)
|
||||
if "Current Value" in r_copy:
|
||||
v = r_copy["Current Value"]
|
||||
if isinstance(v, (int, float)):
|
||||
r_copy["Current Value"] = f"{round(v, 2):.2f}"
|
||||
elif v is None:
|
||||
r_copy["Current Value"] = "—"
|
||||
else:
|
||||
r_copy["Current Value"] = str(v)
|
||||
if "Change" in r_copy and "Change (%)" not in r_copy:
|
||||
r_copy["Change (%)"] = r_copy.pop("Change", "—")
|
||||
if "Trend" in r_copy:
|
||||
r_copy["Status"] = r_copy.pop("Trend", "—")
|
||||
build.append(r_copy)
|
||||
if build:
|
||||
df_q = pd.DataFrame(build)
|
||||
if "Change" in df_q.columns and "Change (%)" not in df_q.columns:
|
||||
df_q = df_q.rename(columns={"Change": "Change (%)"})
|
||||
if "Trend" in df_q.columns:
|
||||
df_q = df_q.rename(columns={"Trend": "Status"})
|
||||
st.dataframe(_style_change_column(df_q), use_container_width=True, hide_index=True)
|
||||
elif not qmom.get("df") or qmom["df"].empty:
|
||||
st.caption("Quarterly data not available for this ticker.")
|
||||
else:
|
||||
st.info("Quantitative data not available for this ticker.")
|
||||
@@ -0,0 +1,239 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from config.constants import MARKET_OPTIONS, DAMODARAN_ERP_PCT, DAMODARAN_RF_PCT
|
||||
from utils.ticker import get_global_ticker
|
||||
from utils.formatting import _format_shares_display
|
||||
from utils.dcf import excel_style_dcf, _damodaran_wacc_for_sector
|
||||
from utils.charts import _apply_dark_theme
|
||||
from utils.ui_helpers import _render_analyst_consensus, _render_sensitivity_table
|
||||
from data.fundamentals import get_sector_industry, get_5yr_financial_trend, get_dcf_inputs
|
||||
from data.valuation import get_analyst_consensus, get_dcf_smart_defaults, get_fcff_fcfe_valuation
|
||||
try:
|
||||
import plotly.express as px
|
||||
except ImportError:
|
||||
px = None
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def render_tab2(ticker):
|
||||
market_t2 = st.session_state.get("market") or MARKET_OPTIONS[0]
|
||||
quant_ticker_t2 = get_global_ticker(ticker, market_t2) if ticker else ""
|
||||
st.subheader("5-Year Financial Trend & DCF Valuation")
|
||||
if ticker:
|
||||
si_t2 = get_sector_industry(quant_ticker_t2)
|
||||
sector_t2 = (si_t2.get("sector") or "").lower()
|
||||
is_financial = "financial" in sector_t2 or "bank" in sector_t2 or "insurance" in sector_t2
|
||||
else:
|
||||
is_financial = False
|
||||
df_trend = get_5yr_financial_trend(quant_ticker_t2) if quant_ticker_t2 else pd.DataFrame()
|
||||
if not df_trend.empty and len(df_trend) >= 1:
|
||||
st.markdown("#### Key metrics (YoY % change)")
|
||||
latest = df_trend.iloc[0]
|
||||
prev = df_trend.iloc[1] if len(df_trend) >= 2 else None
|
||||
def _yoy_pct(cur, prev_val):
|
||||
if prev_val is None or cur is None or prev_val == 0:
|
||||
return None
|
||||
return (cur - prev_val) / abs(prev_val) * 100
|
||||
rev_yoy = _yoy_pct(latest.get("Revenue"), prev.get("Revenue") if prev is not None else None)
|
||||
ni_yoy = _yoy_pct(latest.get("Net Income"), prev.get("Net Income") if prev is not None else None)
|
||||
om_prev = prev.get("Operating Margin %") if prev is not None else None
|
||||
om_cur = latest.get("Operating Margin %")
|
||||
om_yoy = (om_cur - om_prev) if (om_cur is not None and om_prev is not None) else None
|
||||
fcf_yoy = _yoy_pct(latest.get("FCF"), prev.get("FCF") if prev is not None else None)
|
||||
m1, m2, m3, m4 = st.columns(4)
|
||||
rev_val = latest.get("Revenue")
|
||||
m1.metric("Revenue (latest yr)", f"${rev_val/1e9:.2f}B" if rev_val and rev_val >= 1e9 else (f"${rev_val/1e6:.0f}M" if rev_val else "\u2014"), f"{rev_yoy:+.1f}% YoY" if rev_yoy is not None else None)
|
||||
ni_val = latest.get("Net Income")
|
||||
m2.metric("Net Income", f"${ni_val/1e9:.2f}B" if ni_val and abs(ni_val) >= 1e9 else (f"${ni_val/1e6:.0f}M" if ni_val is not None else "\u2014"), f"{ni_yoy:+.1f}% YoY" if ni_yoy is not None else None)
|
||||
om_val = latest.get("Operating Margin %")
|
||||
m3.metric("Operating Margin %", f"{om_val:.1f}%" if om_val is not None else "\u2014", f"{om_yoy:+.1f}pp YoY" if om_yoy is not None else None)
|
||||
fcf_val = latest.get("FCF")
|
||||
m4.metric("FCF", f"${fcf_val/1e9:.2f}B" if fcf_val and abs(fcf_val) >= 1e9 else (f"${fcf_val/1e6:.0f}M" if fcf_val is not None else "\u2014"), f"{fcf_yoy:+.1f}% YoY" if fcf_yoy is not None else None)
|
||||
st.caption("FCF = Operating Cash Flow \u2212 Capital Expenditure." + (" For Financials, FCF/EBITDA are less relevant; see ROE/ROA in Tab 1 sector-specific metrics." if is_financial else ""))
|
||||
if len(df_trend) >= 2 and px is not None:
|
||||
st.markdown("#### 5-year trend: Revenue & FCF")
|
||||
df_plot = df_trend.copy()
|
||||
df_plot["Revenue_M"] = (df_plot["Revenue"] / 1e6).round(1)
|
||||
df_plot["FCF_M"] = (df_plot["FCF"] / 1e6).round(1)
|
||||
fig = px.line(df_plot, x="Year", y=["Revenue_M", "FCF_M"], title="Revenue & Free Cash Flow ($M)")
|
||||
fig.update_layout(yaxis_title="$M", legend_title="", hovermode="x unified")
|
||||
fig.update_traces(line=dict(width=2))
|
||||
_apply_dark_theme(fig)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
elif ticker:
|
||||
st.caption("5-year trend not available for this ticker. DCF section below uses latest FCF from yfinance.")
|
||||
st.markdown("---")
|
||||
st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output")
|
||||
dcf_inputs = get_dcf_inputs(quant_ticker_t2) if quant_ticker_t2 else {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
|
||||
fcf_fetched = dcf_inputs.get("fcf")
|
||||
total_debt = float(dcf_inputs.get("total_debt") or 0.0)
|
||||
cash = float(dcf_inputs.get("cash") or 0.0)
|
||||
shares_fetched = dcf_inputs.get("shares")
|
||||
# Base FCF
|
||||
if fcf_fetched is None or fcf_fetched <= 0:
|
||||
fcf = st.number_input("Base FCF (manual \u2014 only if yfinance missing)", value=0.0, min_value=-1e12, step=1e8, format="%.0f", key="dcf_fcf_manual")
|
||||
else:
|
||||
fcf = float(fcf_fetched)
|
||||
st.caption(f"Base FCF (OCF \u2212 CapEx): **${fcf/1e9:.2f}B**" if abs(fcf) >= 1e9 else f"Base FCF (OCF \u2212 CapEx): **${fcf/1e6:.0f}M**")
|
||||
# Shares: auto-fetched (fast_info -> info -> balance); manual only as last resort
|
||||
if shares_fetched is not None and shares_fetched > 0:
|
||||
shares = float(shares_fetched)
|
||||
st.caption(f"Shares Outstanding: **{_format_shares_display(shares)}** (real-time, auto-fetched)")
|
||||
else:
|
||||
shares = st.number_input("Shares Outstanding (manual \u2014 only if all API sources failed)", value=1e9, min_value=1.0, step=1e7, format="%.0f", key="dcf_shares_manual")
|
||||
# Total Debt & Cash: manual only when both API sources completely failed
|
||||
if total_debt == 0 and cash == 0:
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
total_debt = st.number_input("Total Debt (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_debt_manual")
|
||||
with c2:
|
||||
cash = st.number_input("Cash & Equivalents (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_cash_manual")
|
||||
else:
|
||||
st.caption(f"Total Debt: **${total_debt/1e9:.2f}B**" if total_debt >= 1e9 else f"Total Debt: **${total_debt/1e6:.0f}M**" if total_debt >= 1e6 else f"Total Debt: **${total_debt:,.0f}**")
|
||||
st.caption(f"Cash & Equivalents: **${cash/1e9:.2f}B**" if cash >= 1e9 else f"Cash & Equivalents: **${cash/1e6:.0f}M**" if cash >= 1e6 else f"Cash & Equivalents: **${cash:,.0f}**")
|
||||
dcf_defaults = get_dcf_smart_defaults(quant_ticker_t2) if quant_ticker_t2 else {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
|
||||
st.markdown("**Assumptions (sliders)**")
|
||||
st.caption("\U0001f4a1 Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates.")
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
wacc = st.slider("WACC (Discount Rate) %", 4.0, 20.0, float(dcf_defaults["wacc_pct"]), 0.5, key="dcf_wacc") / 100.0
|
||||
with col2:
|
||||
term_growth = st.slider("Terminal Growth Rate %", -2.0, 6.0, float(dcf_defaults["term_growth_pct"]), 0.25, key="dcf_term") / 100.0
|
||||
with col3:
|
||||
base_growth = st.slider("Projected FCF Growth (Stage 1, Y1\u20135) %", -10.0, 30.0, float(dcf_defaults["fcf_growth_pct"]), 0.5, key="dcf_fcf_growth") / 100.0
|
||||
bull_growth = base_growth + 0.02
|
||||
bear_growth = base_growth - 0.02
|
||||
with st.expander("Reference: Analyst & Macro Assumptions", expanded=False):
|
||||
left_col, right_col = st.columns(2)
|
||||
with left_col:
|
||||
st.markdown("**Analyst consensus (yfinance)**")
|
||||
analyst = get_analyst_consensus(quant_ticker_t2) if quant_ticker_t2 else {}
|
||||
_tmp = analyst.get('targetMeanPrice')
|
||||
st.markdown(f"- **Target mean price:** ${_tmp:,.2f}" if _tmp else "- **Target mean price:** N/A")
|
||||
st.markdown(f"- **Recommendation:** {analyst.get('recommendationKey', 'N/A')}")
|
||||
st.markdown(f"- **Revenue growth est.:** {analyst.get('revenueGrowth', 'N/A')}")
|
||||
st.markdown(f"- **Earnings growth est.:** {analyst.get('earningsGrowth', 'N/A')}")
|
||||
with right_col:
|
||||
st.markdown("**Aswath Damodaran \u2014 macro baseline**")
|
||||
sector_name = get_sector_industry(ticker).get("sector", "N/A") if ticker else "N/A"
|
||||
damodaran_wacc = _damodaran_wacc_for_sector(sector_name) if ticker else 8.0
|
||||
st.markdown(f"- **Sector WACC (ref.):** {damodaran_wacc:.1f}% (closest: {sector_name})")
|
||||
st.markdown(f"- **US equity risk premium (ERP):** {DAMODARAN_ERP_PCT}%")
|
||||
st.markdown(f"- **10Y risk-free rate:** {DAMODARAN_RF_PCT}%")
|
||||
st.markdown("[Data & methodology (Damodaran)](https://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/wacc.htm) so users can verify.")
|
||||
res_base = excel_style_dcf(fcf, wacc, term_growth, base_growth, total_debt, cash, shares)
|
||||
res_bull = excel_style_dcf(fcf, wacc, term_growth, bull_growth, total_debt, cash, shares)
|
||||
res_bear = excel_style_dcf(fcf, wacc, term_growth, bear_growth, total_debt, cash, shares)
|
||||
price_base = res_base.get("value_per_share") or 0.0
|
||||
price_bull = res_bull.get("value_per_share") or 0.0
|
||||
price_bear = res_bear.get("value_per_share") or 0.0
|
||||
current_price = None
|
||||
if quant_ticker_t2 and yf:
|
||||
try:
|
||||
info = yf.Ticker(quant_ticker_t2.upper()).info or {}
|
||||
current_price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
|
||||
except Exception:
|
||||
pass
|
||||
st.markdown("**Intrinsic value vs current price**")
|
||||
if current_price is not None and current_price > 0:
|
||||
st.metric("Current price", f"${current_price:.2f}", None)
|
||||
st.metric("Base case intrinsic value per share", f"${price_base:.2f}" if price_base else "N/A", f"vs current: {(price_base - current_price):.2f}" if (current_price and price_base) else None)
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("Bull (+2% FCF growth)", f"${price_bull:.2f}" if price_bull else "N/A", f"vs Base: +{(price_bull - price_base):.2f}" if (price_bull and price_base) else None)
|
||||
c2.metric("Base", f"${price_base:.2f}" if price_base else "N/A", "\u2014")
|
||||
c3.metric("Bear (\u22122% FCF growth)", f"${price_bear:.2f}" if price_bear else "N/A", f"vs Base: {(price_bear - price_base):.2f}" if (price_bear and price_base) else None)
|
||||
df_dcf = pd.DataFrame({
|
||||
"Scenario": ["Bull", "Base", "Bear"],
|
||||
"FCF Growth %": [f"{bull_growth*100:.1f}", f"{base_growth*100:.1f}", f"{bear_growth*100:.1f}"],
|
||||
"Intrinsic Value ($)": [round(price_bull, 2) if price_bull else "N/A", round(price_base, 2) if price_base else "N/A", round(price_bear, 2) if price_bear else "N/A"],
|
||||
})
|
||||
st.dataframe(df_dcf, use_container_width=True, hide_index=True)
|
||||
|
||||
# --- Probability-Weighted Expected Return ---
|
||||
st.markdown("**Probability-Weighted Expected Return**")
|
||||
bull_prob, base_prob, bear_prob = 0.25, 0.55, 0.20
|
||||
pw_value = bull_prob * price_bull + base_prob * price_base + bear_prob * price_bear
|
||||
if current_price and current_price > 0 and pw_value > 0:
|
||||
pw_return = (pw_value - current_price) / current_price * 100
|
||||
st.markdown(f"""
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 1px;">PROBABILITY-WEIGHTED VALUE</div>
|
||||
<div style="color: #F3F4F6; font-size: 1.4rem; font-family: JetBrains Mono, monospace; font-weight: 700;">${pw_value:,.2f}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 1px;">EXPECTED RETURN</div>
|
||||
<div style="color: {'#34D399' if pw_return > 0 else '#F87171'}; font-size: 1.4rem; font-family: JetBrains Mono, monospace; font-weight: 700;">{pw_return:+.1f}%</div>
|
||||
</div>
|
||||
<div style="color: #6B7280; font-size: 0.75rem; font-family: JetBrains Mono, monospace;">
|
||||
Bull {bull_prob*100:.0f}% x ${price_bull:,.0f} + Base {base_prob*100:.0f}% x ${price_base:,.0f} + Bear {bear_prob*100:.0f}% x ${price_bear:,.0f}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# --- Analyst Consensus ---
|
||||
st.markdown("---")
|
||||
st.markdown("#### Analyst Consensus")
|
||||
_render_analyst_consensus(quant_ticker_t2)
|
||||
|
||||
# --- FCFF / FCFE Analysis ---
|
||||
st.markdown("---")
|
||||
st.markdown("#### FCFF / FCFE Analysis")
|
||||
fcff_data = get_fcff_fcfe_valuation(quant_ticker_t2) if quant_ticker_t2 else {}
|
||||
if fcff_data:
|
||||
c1_ff, c2_ff, c3_ff, c4_ff = st.columns(4)
|
||||
with c1_ff:
|
||||
fcff_val = fcff_data.get("fcff")
|
||||
st.metric("FCFF", f"${fcff_val/1e9:.2f}B" if fcff_val and abs(fcff_val) >= 1e9 else (f"${fcff_val/1e6:.0f}M" if fcff_val else "N/A"))
|
||||
with c2_ff:
|
||||
fcfe_val = fcff_data.get("fcfe")
|
||||
st.metric("FCFE", f"${fcfe_val/1e9:.2f}B" if fcfe_val and abs(fcfe_val) >= 1e9 else (f"${fcfe_val/1e6:.0f}M" if fcfe_val else "N/A"))
|
||||
with c3_ff:
|
||||
_da_v = fcff_data.get("depreciation")
|
||||
st.metric("D&A", f"${_da_v/1e9:.2f}B" if _da_v and abs(_da_v) >= 1e9 else (f"${_da_v/1e6:.0f}M" if _da_v else "N/A"))
|
||||
with c4_ff:
|
||||
_cx_v = fcff_data.get("capex")
|
||||
st.metric("CapEx", f"${_cx_v/1e9:.2f}B" if _cx_v and abs(_cx_v) >= 1e9 else (f"${_cx_v/1e6:.0f}M" if _cx_v else "N/A"))
|
||||
|
||||
with st.expander("FCFF/FCFE Bridge Detail", expanded=False):
|
||||
def _fmt_b(v):
|
||||
if v is None: return "N/A"
|
||||
return f"${v/1e9:.2f}B" if abs(v) >= 1e9 else f"${v/1e6:.0f}M"
|
||||
_ebit = fcff_data.get('ebit') or 0
|
||||
_tr_pct = fcff_data.get('tax_rate') or 21
|
||||
_da = fcff_data.get('depreciation') or 0
|
||||
_cx = fcff_data.get('capex') or 0
|
||||
bridge_data = {
|
||||
"Component": ["EBIT", "x (1 - Tax Rate)", "= NOPAT", "+ D&A", "- CapEx", "= FCFF", "", "Net Income", "+ D&A", "- CapEx", "= FCFE"],
|
||||
"Value": [
|
||||
_fmt_b(fcff_data.get('ebit')),
|
||||
f"{_tr_pct:.1f}%",
|
||||
_fmt_b(_ebit * (1 - _tr_pct/100)) if fcff_data.get('ebit') else "N/A",
|
||||
_fmt_b(fcff_data.get('depreciation')),
|
||||
_fmt_b(fcff_data.get('capex')),
|
||||
_fmt_b(fcff_data.get('fcff')),
|
||||
"---",
|
||||
_fmt_b(fcff_data.get('net_income')),
|
||||
_fmt_b(fcff_data.get('depreciation')),
|
||||
_fmt_b(fcff_data.get('capex')),
|
||||
_fmt_b(fcff_data.get('fcfe')),
|
||||
]
|
||||
}
|
||||
st.dataframe(pd.DataFrame(bridge_data), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("FCFF/FCFE data not available for this ticker.")
|
||||
|
||||
# --- DCF Sensitivity Analysis ---
|
||||
st.markdown("---")
|
||||
st.markdown("#### DCF Sensitivity Analysis")
|
||||
st.caption("Intrinsic value per share across WACC and Terminal Growth Rate assumptions")
|
||||
if fcf and fcf > 0 and shares and shares > 0:
|
||||
sens_df = _render_sensitivity_table(fcf, total_debt, cash, shares)
|
||||
st.dataframe(sens_df, use_container_width=True)
|
||||
else:
|
||||
st.caption("Sensitivity table requires positive FCF data.")
|
||||
@@ -0,0 +1,82 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from config.constants import SECTORS
|
||||
from data.ratios import get_comps_data
|
||||
from ai.gemini_insights import get_industry_outlook
|
||||
|
||||
|
||||
def render_tab3(ticker):
|
||||
st.subheader("Top-Down Sector Analysis")
|
||||
st.markdown("Select an **industry** to load peer multiples (Forward P/E, EV/EBITDA, P/B). Green = lowest (undervalued), Red = highest. Optionally generate an **AI Industry Outlook**.")
|
||||
sector_options = list(SECTORS.keys())
|
||||
selected_industry = st.selectbox("Select industry", sector_options, key="sector_select")
|
||||
tickers_list = list(SECTORS.get(selected_industry, []))
|
||||
if not tickers_list:
|
||||
st.warning("No tickers defined for this industry.")
|
||||
else:
|
||||
with st.spinner("Fetching market data..."):
|
||||
df_comps = get_comps_data(tuple(tickers_list))
|
||||
if df_comps.empty:
|
||||
st.warning("Could not fetch comps from yfinance. One or more tickers may have failed; try again later.")
|
||||
else:
|
||||
df_display = df_comps.copy()
|
||||
for col in ["Forward P/E", "EV/EBITDA", "P/B"]:
|
||||
if col not in df_display.columns:
|
||||
continue
|
||||
df_display[col] = df_display[col].apply(
|
||||
lambda x: "N/A" if (x is None or (isinstance(x, float) and pd.isna(x))) else x
|
||||
)
|
||||
try:
|
||||
styled = df_comps.style
|
||||
for col in ["Forward P/E", "EV/EBITDA", "P/B"]:
|
||||
if col not in df_comps.columns:
|
||||
continue
|
||||
s = pd.to_numeric(df_comps[col], errors="coerce")
|
||||
valid = s.dropna()
|
||||
if len(valid) < 2:
|
||||
continue
|
||||
lo, hi = valid.min(), valid.max()
|
||||
if lo == hi:
|
||||
continue
|
||||
def color_fn(v, lo_val=lo, hi_val=hi):
|
||||
if pd.isna(v):
|
||||
return ""
|
||||
try:
|
||||
x = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if x <= lo_val:
|
||||
return "background-color: rgba(0, 200, 83, 0.35); color: #0d5c2e"
|
||||
if x >= hi_val:
|
||||
return "background-color: rgba(255, 82, 82, 0.35); color: #b71c1c"
|
||||
return ""
|
||||
styled = styled.map(color_fn, subset=[col])
|
||||
styled = styled.format(subset=["Forward P/E", "EV/EBITDA", "P/B"], formatter=lambda x: "N/A" if (pd.isna(x) or x is None) else f"{x:.2f}")
|
||||
st.dataframe(styled, use_container_width=True, hide_index=True)
|
||||
except Exception:
|
||||
st.dataframe(df_display, use_container_width=True, hide_index=True)
|
||||
st.caption("Lowest multiple in each column = green (relatively undervalued); highest = red.")
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("#### AI Industry Outlook")
|
||||
if st.button("Generate Industry Outlook", key="industry_outlook_btn"):
|
||||
if not tickers_list:
|
||||
st.error("Select an industry above first.")
|
||||
elif not st.session_state.get("google_api_key"):
|
||||
st.error("Enter your Google API Key in the sidebar.")
|
||||
else:
|
||||
try:
|
||||
with st.spinner("Generating industry outlook with Gemini..."):
|
||||
report = get_industry_outlook(
|
||||
st.session_state["google_api_key"],
|
||||
selected_industry,
|
||||
tickers_list,
|
||||
)
|
||||
st.success("Done.")
|
||||
st.markdown(report)
|
||||
except RuntimeError as e:
|
||||
st.error(str(e))
|
||||
except Exception as e:
|
||||
st.error("Failed to generate outlook. See details below.")
|
||||
with st.expander("Error details"):
|
||||
st.code(repr(e), language="text")
|
||||
@@ -0,0 +1,27 @@
|
||||
import streamlit as st
|
||||
from config.constants import COMPANY_TICKER_MAP
|
||||
from data.market import _fetch_news_rss
|
||||
|
||||
|
||||
def render_tab4(ticker):
|
||||
st.subheader("News Feed")
|
||||
st.caption(f"Latest news for **{ticker}**")
|
||||
try:
|
||||
import feedparser
|
||||
news_items = _fetch_news_rss(ticker, COMPANY_TICKER_MAP.get(ticker, ""))
|
||||
if news_items:
|
||||
for item in news_items:
|
||||
st.markdown(f"""
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px 16px; margin-bottom: 8px;">
|
||||
<a href="{item['url']}" target="_blank" style="color: #F3F4F6; text-decoration: none; font-weight: 600; font-size: 0.95rem;">
|
||||
{item['title']}
|
||||
</a>
|
||||
<div style="color: #6B7280; font-size: 0.75rem; margin-top: 4px;">
|
||||
{item['source']} \u00b7 {item['published'][:25] if item['published'] else ''}
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
else:
|
||||
st.info("No news found. Try a different ticker.")
|
||||
except ImportError:
|
||||
st.warning("Install `feedparser` to enable news feed: `pip install feedparser`")
|
||||
@@ -0,0 +1,58 @@
|
||||
import streamlit as st
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def render_tab5():
|
||||
st.subheader("Markets & Foreign Exchange")
|
||||
# FX Rates
|
||||
st.markdown("#### \U0001f4b1 FX Rates")
|
||||
fx_pairs = {"USD/KRW": "USDKRW=X", "GBP/USD": "GBPUSD=X", "EUR/USD": "EURUSD=X", "USD/JPY": "USDJPY=X"}
|
||||
fx_cols = st.columns(len(fx_pairs))
|
||||
for i, (label, sym) in enumerate(fx_pairs.items()):
|
||||
with fx_cols[i]:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
price = info.get("regularMarketPrice") or info.get("previousClose") or 0
|
||||
prev = info.get("regularMarketPreviousClose") or price
|
||||
chg = ((price - prev) / prev * 100) if prev else 0
|
||||
color = "#34D399" if chg >= 0 else "#F87171"
|
||||
st.markdown(f"""
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 16px; text-align: center;">
|
||||
<div style="color: #6B7280; font-size: 0.75rem; font-weight: 600;">{label}</div>
|
||||
<div style="color: #F3F4F6; font-size: 1.4rem; font-family: 'JetBrains Mono', monospace; font-weight: 700;">{price:,.2f}</div>
|
||||
<div style="color: {color}; font-size: 0.8rem;">{chg:+.2f}%</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
except Exception:
|
||||
st.markdown(f"<div style='background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 16px; text-align: center;'><div style='color: #6B7280;'>{label}</div><div style='color: #F87171;'>N/A</div></div>", unsafe_allow_html=True)
|
||||
|
||||
# Market Sector Heatmap
|
||||
st.markdown("---")
|
||||
st.markdown("#### \U0001f5fa\ufe0f Sector Performance")
|
||||
sector_tickers = {"Technology": "XLK", "Healthcare": "XLV", "Financials": "XLF", "Energy": "XLE", "Consumer": "XLY", "Industrial": "XLI", "Utilities": "XLU", "Materials": "XLB", "Real Estate": "XLRE", "Communication": "XLC"}
|
||||
sector_data = []
|
||||
for name, sym in sector_tickers.items():
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
price = info.get("regularMarketPrice") or 0
|
||||
prev = info.get("regularMarketPreviousClose") or price
|
||||
chg = ((price - prev) / prev * 100) if prev else 0
|
||||
sector_data.append({"sector": name, "change": chg})
|
||||
except Exception:
|
||||
sector_data.append({"sector": name, "change": 0})
|
||||
# Render as colored grid
|
||||
heatmap_cols = st.columns(5)
|
||||
for i, s in enumerate(sector_data):
|
||||
with heatmap_cols[i % 5]:
|
||||
bg = f"rgba(52, 211, 153, {min(abs(s['change'])/3, 0.6)})" if s['change'] >= 0 else f"rgba(248, 113, 113, {min(abs(s['change'])/3, 0.6)})"
|
||||
st.markdown(f"""
|
||||
<div style="background: {bg}; border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 12px; text-align: center; margin-bottom: 8px;">
|
||||
<div style="color: #F3F4F6; font-weight: 600; font-size: 0.85rem;">{s['sector']}</div>
|
||||
<div style="color: {'#34D399' if s['change'] >= 0 else '#F87171'}; font-family: 'JetBrains Mono', monospace; font-size: 1.1rem; font-weight: 700;">{s['change']:+.2f}%</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def render_tab6():
|
||||
st.subheader("Cryptocurrency Prices")
|
||||
crypto_list = [
|
||||
("Bitcoin", "BTC-USD"), ("Ethereum", "ETH-USD"), ("BNB", "BNB-USD"),
|
||||
("Solana", "SOL-USD"), ("XRP", "XRP-USD"), ("Cardano", "ADA-USD"),
|
||||
("Avalanche", "AVAX-USD"), ("Dogecoin", "DOGE-USD"), ("Polkadot", "DOT-USD"),
|
||||
("Chainlink", "LINK-USD"), ("Polygon", "MATIC-USD"), ("Litecoin", "LTC-USD"),
|
||||
]
|
||||
crypto_rows = []
|
||||
for name, sym in crypto_list:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
price = info.get("regularMarketPrice") or info.get("previousClose") or 0
|
||||
prev = info.get("regularMarketPreviousClose") or price
|
||||
chg = ((price - prev) / prev * 100) if prev else 0
|
||||
mcap = info.get("marketCap") or 0
|
||||
crypto_rows.append({
|
||||
"Coin": name,
|
||||
"Symbol": sym.replace("-USD", ""),
|
||||
"Price (USD)": f"${price:,.2f}",
|
||||
"24h Change": f"{chg:+.2f}%",
|
||||
"Market Cap": f"${mcap/1e9:.1f}B" if mcap >= 1e9 else (f"${mcap/1e6:.0f}M" if mcap else "N/A"),
|
||||
"_change": chg,
|
||||
})
|
||||
except Exception:
|
||||
crypto_rows.append({"Coin": name, "Symbol": sym.replace("-USD", ""), "Price (USD)": "N/A", "24h Change": "N/A", "Market Cap": "N/A", "_change": 0})
|
||||
if crypto_rows:
|
||||
df_crypto = pd.DataFrame(crypto_rows)
|
||||
def style_crypto(row):
|
||||
chg = row.get("_change", 0)
|
||||
color = "#34D399" if chg >= 0 else "#F87171"
|
||||
return [f"color: {color}" if col == "24h Change" else "" for col in row.index]
|
||||
display_df = df_crypto.drop(columns=["_change"])
|
||||
st.dataframe(display_df.style.apply(style_crypto, axis=1), use_container_width=True, hide_index=True)
|
||||
@@ -0,0 +1,77 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from config.constants import MARKET_OPTIONS
|
||||
from utils.ticker import get_global_ticker
|
||||
from data.market import get_technical_indicators, get_risk_analysis
|
||||
|
||||
|
||||
def render_tab7(ticker):
|
||||
st.subheader("Technical Setup & Risk Analysis")
|
||||
market_t7 = st.session_state.get("market") or MARKET_OPTIONS[0]
|
||||
quant_ticker_t7 = get_global_ticker(ticker, market_t7) if ticker else ""
|
||||
|
||||
st.markdown("#### Technical Indicators")
|
||||
tech = get_technical_indicators(quant_ticker_t7) if quant_ticker_t7 else {}
|
||||
if tech.get("current_price"):
|
||||
tc1, tc2, tc3, tc4 = st.columns(4)
|
||||
price_t7 = tech["current_price"]
|
||||
with tc1:
|
||||
rsi = tech.get("rsi_14")
|
||||
rsi_color = "#F87171" if rsi and rsi > 70 else ("#34D399" if rsi and rsi < 30 else "#FBBF24")
|
||||
rsi_label = "Overbought" if rsi and rsi > 70 else ("Oversold" if rsi and rsi < 30 else "Neutral")
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">RSI (14)</div><div class="stat-value" style="color:{rsi_color};">{rsi if rsi else "N/A"}</div><div style="color:{rsi_color};font-size:0.75rem;">{rsi_label}</div></div>', unsafe_allow_html=True)
|
||||
with tc2:
|
||||
sma50 = tech.get("sma_50")
|
||||
above_50 = price_t7 > sma50 if sma50 else None
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">SMA (50)</div><div class="stat-value">{"${:,.2f}".format(sma50) if sma50 else "N/A"}</div><div class="stat-delta {"delta-up" if above_50 else "delta-down"}">{"Above" if above_50 else "Below"} SMA50</div></div>', unsafe_allow_html=True)
|
||||
with tc3:
|
||||
sma200 = tech.get("sma_200")
|
||||
above_200 = price_t7 > sma200 if sma200 else None
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">SMA (200)</div><div class="stat-value">{"${:,.2f}".format(sma200) if sma200 else "N/A"}</div><div class="stat-delta {"delta-up" if above_200 else "delta-down"}">{"Above" if above_200 else "Below"} SMA200</div></div>', unsafe_allow_html=True)
|
||||
with tc4:
|
||||
h52 = tech.get("52w_high", 0)
|
||||
l52 = tech.get("52w_low", 0)
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">52W Range</div><div class="stat-value">${l52:,.2f} \u2014 ${h52:,.2f}</div><div style="color:#6B7280;font-size:0.75rem;">Current: ${price_t7:,.2f}</div></div>', unsafe_allow_html=True)
|
||||
|
||||
st.markdown("---")
|
||||
sr1, sr2 = st.columns(2)
|
||||
with sr1:
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">Support (20D Low)</div><div class="stat-value delta-up">${tech.get("support", 0):,.2f}</div></div>', unsafe_allow_html=True)
|
||||
with sr2:
|
||||
st.markdown(f'<div class="stat-card"><div class="stat-label">Resistance (20D High)</div><div class="stat-value delta-down">${tech.get("resistance", 0):,.2f}</div></div>', unsafe_allow_html=True)
|
||||
|
||||
sma50_v = tech.get("sma_50")
|
||||
sma200_v = tech.get("sma_200")
|
||||
if sma50_v and sma200_v:
|
||||
if sma50_v > sma200_v:
|
||||
st.markdown('<div style="background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);border-radius:8px;padding:12px;text-align:center;color:#34D399;font-weight:600;margin-top:12px;">Golden Cross: SMA50 > SMA200 \u2014 Bullish Signal</div>', unsafe_allow_html=True)
|
||||
else:
|
||||
st.markdown('<div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:12px;text-align:center;color:#F87171;font-weight:600;margin-top:12px;">Death Cross: SMA50 < SMA200 \u2014 Bearish Signal</div>', unsafe_allow_html=True)
|
||||
else:
|
||||
st.caption("Technical data not available. Enter a valid ticker.")
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("#### Risk Analysis Matrix")
|
||||
risks = get_risk_analysis(quant_ticker_t7) if quant_ticker_t7 else []
|
||||
if risks:
|
||||
risk_rows = []
|
||||
for r in risks:
|
||||
risk_rows.append({"Risk Factor": r["risk"], "Severity": r["severity"], "Est. EPS Impact": r["eps_impact"], "Description": r["description"]})
|
||||
df_risks = pd.DataFrame(risk_rows)
|
||||
def style_severity(val):
|
||||
if val == "High":
|
||||
return "background-color: rgba(248,113,113,0.2); color: #F87171; font-weight: 700"
|
||||
elif val == "Medium":
|
||||
return "background-color: rgba(251,191,36,0.2); color: #FBBF24; font-weight: 700"
|
||||
return "background-color: rgba(52,211,153,0.2); color: #34D399; font-weight: 700"
|
||||
styled_risks = df_risks.style.map(style_severity, subset=["Severity"])
|
||||
st.dataframe(styled_risks, use_container_width=True, hide_index=True)
|
||||
total_impact = sum(float(r["eps_impact"].replace("$", "").replace("-", "")) for r in risks)
|
||||
st.markdown(f"""
|
||||
<div style="background:rgba(248,113,113,0.05);border:1px solid rgba(248,113,113,0.2);border-radius:8px;padding:12px;text-align:center;">
|
||||
<span style="color:#9CA3AF;font-size:0.8rem;">Cumulative Worst-Case EPS Impact:</span>
|
||||
<span style="color:#F87171;font-family:'JetBrains Mono',monospace;font-size:1.1rem;font-weight:700;margin-left:8px;">-${total_impact:.2f}</span>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
else:
|
||||
st.caption("Risk analysis requires a valid ticker with financial data.")
|
||||
Reference in New Issue
Block a user