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:
shawnkim1997
2026-03-19 22:11:07 +00:00
co-authored by Claude Sonnet 4.6
parent 7ce5661569
commit d337c63976
41 changed files with 4409 additions and 2942 deletions
View File
+142
View File
@@ -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
+126
View File
@@ -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 2060 s to fetch 10-K; Gemini then streams in ~510 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 ~510 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 ~510 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"])
+95
View File
@@ -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)을 선택하세요.")
+209
View File
@@ -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 89: Excellent** · 47: Average · 03: 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.812.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.")
+239
View File
@@ -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.")
+82
View File
@@ -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")
+27
View File
@@ -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`")
+58
View File
@@ -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)
+43
View File
@@ -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)
+77
View File
@@ -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.")