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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
+75
View File
@@ -0,0 +1,75 @@
"""
AI Financial Summary — Gemini-powered analyst report.
Split from tab8 to keep files under 300 lines.
"""
import streamlit as st
def _df_to_summary_text(df, label: str, max_cols: int = 5) -> str:
if df is None or df.empty:
return f"[{label}: No data available]\n"
cols = df.columns[:max_cols]
sliced = df[cols]
lines = [f"=== {label} ===", "Period: " + " | ".join(str(c) for c in cols)]
for idx in sliced.index:
vals = " | ".join(
f"{v:,.0f}" if isinstance(v, (int, float)) and v == v else "N/A"
for v in sliced.loc[idx]
)
lines.append(f" {idx}: {vals}")
return "\n".join(lines) + "\n"
def _build_prompt(ticker, inc, bal, cf):
return f"""You are a **Senior Equity Analyst at Franklin Templeton** with 15+ years of experience. Write a comprehensive financial summary for **{ticker}**.
{inc}
{bal}
{cf}
Produce a professional report with: 1. Executive Summary, 2. Revenue & Profitability Analysis,
3. Balance Sheet Health, 4. Cash Flow Quality, 5. Key Ratios & Red Flags,
6. Investment Thesis (Bull vs Bear), 7. Analyst's Bottom Line.
Use actual numbers. Under 1,200 words. Markdown formatting."""
def render_ai_summary(ticker, fin_df, bal_df, cf_df):
"""Render the AI Financial Summary section."""
st.markdown("---")
st.markdown("### AI Financial Summary (Powered by Gemini)")
google_api_key = (st.session_state.get("google_api_key") or "").strip()
if not google_api_key:
st.info("Enter your Google API Key in the sidebar to enable AI summaries.")
return
report_key = f"ai_summary_report_{ticker}"
if st.button("Generate Senior Analyst Report", key=f"btn_ai_{ticker}", type="primary"):
with st.spinner("Generating analyst report..."):
try:
if fin_df is None or fin_df.empty:
st.error("No financial data available.")
return
inc = _df_to_summary_text(fin_df, "Income Statement")
bal = _df_to_summary_text(bal_df, "Balance Sheet")
cf = _df_to_summary_text(cf_df, "Cash Flow Statement")
import google.generativeai as genai
from config.constants import GEMINI_MODEL
genai.configure(api_key=google_api_key)
model = genai.GenerativeModel(GEMINI_MODEL)
r = model.generate_content(
_build_prompt(ticker, inc, bal, cf),
generation_config={"temperature": 0.3, "max_output_tokens": 4096},
)
text = (r.text or "").strip()
if text:
st.session_state[report_key] = text
else:
st.error("Empty response from Gemini.")
except Exception as e:
err = str(e).lower()
if "429" in err or "resource" in err:
st.error("Rate limit reached. Wait and retry.")
else:
st.error(f"Error: {e}")
if st.session_state.get(report_key):
st.markdown(st.session_state[report_key])
st.caption("AI-generated. Verify independently before making investment decisions.")
+69
View File
@@ -0,0 +1,69 @@
"""
Portfolio sidebar widgets — earnings calendar, dividends, news.
Split from tab9_portfolio.py to stay under 300-line limit.
"""
import streamlit as st
from data.portfolio import (
get_earnings_calendar, get_dividend_schedule, get_portfolio_news,
)
def render_earnings(tickers: tuple):
"""Earnings calendar widget."""
st.markdown("#### Earnings Calendar")
events = get_earnings_calendar(tickers)
if not events:
st.caption("No upcoming earnings found.")
return
for ev in events[:8]:
d = ev["days_until"]
color = "#F87171" if d <= 3 else "#FBBF24" if d <= 7 else "#6B7280"
st.markdown(
f'<div style="display:flex;align-items:center;gap:10px;padding:6px 0;'
f'border-bottom:1px solid rgba(255,255,255,0.06);">'
f'<span style="background:{color};color:#fff;padding:2px 8px;border-radius:4px;'
f'font-size:0.75rem;font-weight:700;min-width:40px;text-align:center;">D-{d}</span>'
f'<div><span style="color:#F3F4F6;font-weight:600;">{ev["name"]}</span>'
f'<br><span style="color:#6B7280;font-size:0.75rem;">{ev["ticker"]} · {ev["date"]}</span></div>'
f'</div>', unsafe_allow_html=True
)
def render_dividends(tickers: tuple):
"""Dividend schedule widget."""
st.markdown("#### Dividend Schedule")
divs = get_dividend_schedule(tickers)
if not divs:
st.caption("No dividend data found.")
return
for d in divs[:8]:
st.markdown(
f'<div style="padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.06);">'
f'<span style="color:#F3F4F6;font-weight:600;">{d["name"]}</span>'
f' <span style="color:#6B7280;font-size:0.8rem;">{d["ticker"]}</span><br>'
f'<span style="color:#34D399;font-size:0.85rem;">Yield: {d["yield_pct"]}</span>'
f' · <span style="color:#9CA3AF;font-size:0.8rem;">Ex-Date: {d["ex_date"]}</span>'
f' · <span style="color:#9CA3AF;font-size:0.8rem;">Annual: {d["amount"]}</span>'
f'</div>', unsafe_allow_html=True
)
def render_news(tickers: tuple):
"""Recent news for portfolio stocks."""
st.markdown("#### Portfolio News")
news = get_portfolio_news(tickers, max_per_ticker=2)
if not news:
st.caption("No recent news.")
return
from datetime import datetime
for n in news[:10]:
ts = n.get("published", 0)
date_str = datetime.fromtimestamp(ts).strftime("%m/%d") if ts else ""
link = n.get("link", "#")
st.markdown(
f'<div style="padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.06);">'
f'<span style="color:#60A5FA;font-weight:600;font-size:0.8rem;">{n["ticker"]}</span>'
f' <span style="color:#6B7280;font-size:0.75rem;">{n.get("publisher","")} · {date_str}</span><br>'
f'<a href="{link}" target="_blank" style="color:#F3F4F6;text-decoration:none;font-size:0.85rem;">'
f'{n["title"]}</a></div>', unsafe_allow_html=True
)
+66
View File
@@ -0,0 +1,66 @@
"""
KPI section — AI-powered company-specific KPI analysis via Gemini.
Generates key performance indicators relevant to the company's industry.
"""
import streamlit as st
def render_kpi_section(ticker: str, sector: str, industry: str):
"""Render AI-generated KPI analysis for a company."""
st.markdown("---")
st.markdown("#### Company KPI Analysis (AI-Powered)")
st.caption(
"AI identifies and analyzes the most important KPIs for this company's "
"business model and industry."
)
google_api_key = (st.session_state.get("google_api_key") or "").strip()
if not google_api_key:
st.info("Enter your Google API Key in the sidebar to enable KPI analysis.")
return
kpi_key = f"kpi_analysis_{ticker}"
if st.button("Generate KPI Analysis", key=f"btn_kpi_{ticker}"):
with st.spinner("Analyzing company KPIs with Gemini..."):
try:
import google.generativeai as genai
from config.constants import GEMINI_MODEL
genai.configure(api_key=google_api_key)
model = genai.GenerativeModel(GEMINI_MODEL)
prompt = f"""You are a senior equity research analyst. For **{ticker}** (Sector: {sector}, Industry: {industry}), identify and analyze the **top 5 most critical KPIs** that investors should track.
For each KPI:
1. **KPI Name** — what it measures and why it matters for this specific company
2. **Current Context** — what investors should know about this metric's recent trajectory
3. **Industry Benchmark** — how to interpret good vs bad values
Format as a clean markdown list. Focus on KPIs that are:
- Specific to this company's business model (not generic financial ratios)
- Forward-looking indicators of growth or risk
- Examples: For NVIDIA → CUDA developer adoption, data center revenue mix, AI training chip market share
- Examples: For Starbucks → same-store sales growth, store count, average ticket size
Also include a brief section: "**Key Risks to Watch**" with 2-3 forward-looking risk indicators.
Keep under 500 words. Be specific and actionable."""
r = model.generate_content(
prompt,
generation_config={"temperature": 0.3, "max_output_tokens": 2048},
)
text = (r.text or "").strip()
if text:
st.session_state[kpi_key] = text
else:
st.error("Empty response from Gemini.")
except Exception as e:
err = str(e).lower()
if "429" in err or "resource" in err:
st.error("Rate limit. Please wait and retry.")
else:
st.error(f"Error: {e}")
if st.session_state.get(kpi_key):
st.markdown(st.session_state[kpi_key])
+176
View File
@@ -0,0 +1,176 @@
"""
Tab 10 — Company Valuation
PER, PBR, PSR, P/OCF cards with 5Y history, industry comparison, historical chart.
"""
import streamlit as st
from config.constants import MARKET_OPTIONS
from utils.ticker import get_global_ticker
from data.valuation_metrics import (
get_valuation_multiples, get_historical_multiples,
get_industry_avg_multiples, get_pe_history_chart_data,
)
from data.fundamentals import get_sector_industry
try:
import plotly.graph_objects as go
except ImportError:
go = None
def _metric_card(label, value, sub_items: dict, col):
"""Render a single valuation metric card."""
with col:
val_str = f"{value:.2f}" if isinstance(value, (int, float)) and value == value else "N/A"
st.markdown(
f'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);'
f'border-radius:10px;padding:16px 20px;min-height:160px;">'
f'<div style="color:#9CA3AF;font-size:0.8rem;font-weight:600;">{label}</div>'
f'<div style="color:#F3F4F6;font-size:2rem;font-weight:700;margin:4px 0;">{val_str}</div>',
unsafe_allow_html=True,
)
for k, v in sub_items.items():
v_str = f"{v:.2f}" if isinstance(v, (int, float)) and v == v else "N/A"
color = "#9CA3AF"
st.markdown(
f'<div style="color:{color};font-size:0.75rem;padding:1px 0;">'
f'{k}: <b>{v_str}</b></div>', unsafe_allow_html=True,
)
st.markdown('</div>', unsafe_allow_html=True)
def _render_pe_chart(ticker: str, quant_ticker: str):
"""Render 5Y price chart with PE overlay."""
if not go:
return
df = get_pe_history_chart_data(quant_ticker)
if df.empty:
return
st.markdown("#### Historical Price (5Y)")
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df["Date"], y=df["Close"], mode="lines",
name="Price", line=dict(color="#34D399", width=2),
))
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font_color="#E5E7EB", height=300,
margin=dict(t=20, b=30, l=50, r=20),
xaxis=dict(gridcolor="rgba(255,255,255,0.05)"),
yaxis=dict(gridcolor="rgba(255,255,255,0.05)", title="Price ($)"),
showlegend=False,
)
st.plotly_chart(fig, use_container_width=True)
def _render_comparison_bar(label, current, avg_5y, industry_avg):
"""Render a horizontal comparison bar for a metric."""
vals = {"Current": current, "5Y Avg": avg_5y, "Industry": industry_avg}
items = []
for k, v in vals.items():
if v and isinstance(v, (int, float)) and v == v:
items.append(f'<span style="color:#9CA3AF;font-size:0.8rem;">{k}: </span>'
f'<span style="color:#F3F4F6;font-weight:600;">{v:.2f}</span>')
if items:
st.markdown(
f'<div style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.06);">'
f'<span style="color:#60A5FA;font-weight:600;min-width:80px;display:inline-block;">{label}</span>'
f'{" · ".join(items)}</div>', unsafe_allow_html=True,
)
def render_tab10(ticker):
"""Render the Company Valuation tab."""
market = st.session_state.get("market") or MARKET_OPTIONS[0]
quant_ticker = get_global_ticker(ticker, market) if ticker else ""
st.subheader("Company Valuation")
if not ticker:
st.info("Select a company from the sidebar to view valuation metrics.")
return
si = get_sector_industry(quant_ticker)
sector = si.get("sector", "N/A")
industry = si.get("industry", "N/A")
st.caption(f"**{ticker}** · {sector} · {industry}")
with st.spinner("Fetching valuation data..."):
multiples = get_valuation_multiples(quant_ticker)
hist = get_historical_multiples(quant_ticker)
ind_avg = get_industry_avg_multiples(quant_ticker)
if not multiples:
st.warning("Could not fetch valuation data for this ticker.")
return
# ── Metric Cards: PER, PBR, PSR, P/OCF ──
c1, c2, c3, c4 = st.columns(4)
_metric_card("PER (Trailing)", multiples.get("PER"), {
"Forward": multiples.get("Forward PER"),
"5Y Avg": hist.get("5Y Avg PER"),
"Industry": ind_avg.get("Industry Avg PER"),
}, c1)
_metric_card("PBR", multiples.get("PBR"), {
"5Y Avg": hist.get("5Y Avg PBR"),
"Industry": ind_avg.get("Industry Avg PBR"),
}, c2)
_metric_card("PSR", multiples.get("PSR"), {
"Industry": ind_avg.get("Industry Avg PSR"),
"EV/Revenue": multiples.get("EV/Revenue"),
}, c3)
_metric_card("P/OCF", multiples.get("P/OCF"), {
"EV/EBITDA": multiples.get("EV/EBITDA"),
"PEG Ratio": multiples.get("PEG"),
}, c4)
st.markdown("---")
# ── Comparison Table ──
left, right = st.columns([3, 2])
with left:
st.markdown("#### Valuation Comparison")
_render_comparison_bar("PER", multiples.get("PER"),
hist.get("5Y Avg PER"), ind_avg.get("Industry Avg PER"))
_render_comparison_bar("PBR", multiples.get("PBR"),
hist.get("5Y Avg PBR"), ind_avg.get("Industry Avg PBR"))
_render_comparison_bar("PSR", multiples.get("PSR"), None,
ind_avg.get("Industry Avg PSR"))
_render_comparison_bar("EV/EBITDA", multiples.get("EV/EBITDA"), None, None)
# Premium/Discount indicator
pe_cur = multiples.get("PER")
pe_5y = hist.get("5Y Avg PER")
if pe_cur and pe_5y and pe_5y > 0:
prem = (pe_cur - pe_5y) / pe_5y * 100
color = "#F87171" if prem > 0 else "#34D399"
word = "premium" if prem > 0 else "discount"
st.markdown(
f'<div style="margin-top:12px;padding:10px;background:rgba(255,255,255,0.03);'
f'border-radius:8px;"><span style="color:{color};font-weight:700;">'
f'{abs(prem):.1f}% {word}</span>'
f' <span style="color:#9CA3AF;">vs 5Y average PER</span></div>',
unsafe_allow_html=True,
)
with right:
_render_pe_chart(ticker, quant_ticker)
# ── Additional Metrics Table ──
st.markdown("---")
st.markdown("#### Additional Metrics")
m1, m2, m3, m4 = st.columns(4)
with m1:
beta = multiples.get("Beta")
st.metric("Beta", f"{beta:.2f}" if beta else "N/A")
with m2:
dy = multiples.get("Dividend Yield")
st.metric("Div Yield", f"{dy*100:.2f}%" if dy else "N/A")
with m3:
h52 = multiples.get("52W High")
st.metric("52W High", f"${h52:,.2f}" if h52 else "N/A")
with m4:
l52 = multiples.get("52W Low")
st.metric("52W Low", f"${l52:,.2f}" if l52 else "N/A")
# ── KPI Analysis ──
from views.tab10_kpi import render_kpi_section
render_kpi_section(ticker, sector, industry)
+143
View File
@@ -0,0 +1,143 @@
"""
Tab 11 — Earnings Estimates & Outlook
Analyst consensus estimates, revenue/EPS forecasts, earnings history, price targets.
"""
import streamlit as st
import pandas as pd
from config.constants import MARKET_OPTIONS
from utils.ticker import get_global_ticker
from data.estimates import get_analyst_estimates, get_earnings_dates, format_estimate_table
from data.fundamentals import get_sector_industry
try:
import plotly.graph_objects as go
except ImportError:
go = None
def _render_price_targets(targets: dict):
"""Render analyst price target visualization."""
if not targets or not targets.get("mean"):
return
st.markdown("#### Analyst Price Targets")
cur = targets.get("current") or 0
mean = targets.get("mean") or 0
high = targets.get("high") or 0
low = targets.get("low") or 0
median = targets.get("median") or 0
rec = targets.get("recommendation", "N/A")
n = targets.get("num_analysts") or 0
# Recommendation badge color
rec_colors = {
"strongBuy": "#34D399", "buy": "#34D399", "overweight": "#34D399",
"hold": "#FBBF24", "neutral": "#FBBF24",
"sell": "#F87171", "underperform": "#F87171", "strongSell": "#F87171",
}
rec_color = rec_colors.get(rec, "#9CA3AF")
upside = ((mean - cur) / cur * 100) if cur > 0 else 0
c1, c2, c3, c4 = st.columns(4)
with c1:
st.metric("Current Price", f"${cur:,.2f}" if cur else "N/A")
with c2:
st.metric("Target (Mean)", f"${mean:,.2f}" if mean else "N/A",
delta=f"{upside:+.1f}%")
with c3:
st.metric("Target (Median)", f"${median:,.2f}" if median else "N/A")
with c4:
st.markdown(
f'<div style="padding:8px;text-align:center;">'
f'<div style="color:#9CA3AF;font-size:0.8rem;">Recommendation</div>'
f'<div style="color:{rec_color};font-size:1.4rem;font-weight:700;'
f'text-transform:uppercase;">{rec}</div>'
f'<div style="color:#6B7280;font-size:0.75rem;">{n} analysts</div></div>',
unsafe_allow_html=True,
)
# Price target range bar
if low and high and cur:
st.markdown(
f'<div style="padding:12px;background:rgba(255,255,255,0.03);border-radius:8px;margin:8px 0;">'
f'<div style="display:flex;justify-content:space-between;margin-bottom:6px;">'
f'<span style="color:#F87171;font-size:0.8rem;">Low: ${low:,.2f}</span>'
f'<span style="color:#FBBF24;font-size:0.8rem;">Mean: ${mean:,.2f}</span>'
f'<span style="color:#34D399;font-size:0.8rem;">High: ${high:,.2f}</span></div>'
f'<div style="background:#374151;border-radius:4px;height:8px;position:relative;">'
f'<div style="background:linear-gradient(90deg,#F87171,#FBBF24,#34D399);'
f'border-radius:4px;height:100%;width:100%;"></div></div></div>',
unsafe_allow_html=True,
)
def _render_estimates_table(label: str, df):
"""Render an estimates DataFrame as a styled table."""
if df is None or (hasattr(df, 'empty') and df.empty):
return
st.markdown(f"#### {label}")
display = format_estimate_table(df) if isinstance(df, pd.DataFrame) else df
st.dataframe(display, use_container_width=True)
def _render_earnings_history(dates_df: pd.DataFrame):
"""Render earnings history with surprise data."""
if dates_df is None or dates_df.empty:
st.caption("No earnings history available.")
return
st.markdown("#### Earnings History & Surprises")
display = dates_df.copy()
for col in display.columns:
if display[col].dtype in ('float64', 'float32'):
display[col] = display[col].apply(
lambda v: f"{v:.4f}" if pd.notna(v) and abs(v) < 10
else (f"{v:,.2f}" if pd.notna(v) else "N/A")
)
st.dataframe(display, use_container_width=True)
def _render_growth_estimates(ge):
"""Render growth estimates comparison table."""
if ge is None or (hasattr(ge, 'empty') and ge.empty):
return
st.markdown("#### Growth Estimates")
st.dataframe(ge, use_container_width=True)
def render_tab11(ticker):
"""Render the Estimates & Outlook tab."""
market = st.session_state.get("market") or MARKET_OPTIONS[0]
quant_ticker = get_global_ticker(ticker, market) if ticker else ""
st.subheader("Earnings Estimates & Outlook")
if not ticker:
st.info("Select a company from the sidebar to view estimates.")
return
si = get_sector_industry(quant_ticker)
st.caption(f"**{ticker}** · {si.get('sector', 'N/A')} · {si.get('industry', 'N/A')}")
with st.spinner("Fetching analyst estimates..."):
estimates = get_analyst_estimates(quant_ticker)
earnings_dates = get_earnings_dates(quant_ticker)
if not estimates:
st.warning("No analyst estimates available for this ticker.")
return
# ── Price Targets ──
_render_price_targets(estimates.get("targets", {}))
st.markdown("---")
# ── Estimates Tables ──
left, right = st.columns(2)
with left:
_render_estimates_table("Revenue Estimates", estimates.get("revenue_estimate"))
_render_estimates_table("EPS Trend", estimates.get("eps_trend"))
with right:
_render_estimates_table("Earnings Estimates", estimates.get("earnings_estimate"))
_render_growth_estimates(estimates.get("growth_estimates"))
st.markdown("---")
# ── Earnings History ──
_render_earnings_history(earnings_dates)
+194
View File
@@ -0,0 +1,194 @@
"""
Tab 8 — Standardized Financial Statements
Sub-tabs for Highlights, Income Statement, Balance Sheet, Cash Flow with
YoY growth rates, conditional color coding (green/red), and AI summary.
"""
import streamlit as st
import pandas as pd
from config.constants import MARKET_OPTIONS
from utils.ticker import get_global_ticker
from data.financials import _get_annual_financials_balance_cashflow
from data.fundamentals import get_sector_industry
from views.financial_ai_summary import render_ai_summary
# ───────── Formatting ─────────
def _fmt_val(val) -> str:
"""Format raw number. Parentheses for negatives."""
if val is None:
return "N/A"
try:
f = float(val)
if f != f:
return "N/A"
neg = f < 0
af = abs(f)
if af >= 1e9:
s = f"{af / 1e9:,.1f}"
elif af >= 1e6:
s = f"{af / 1e6:,.1f}"
elif af >= 1e3:
s = f"{af / 1e3:,.1f}"
else:
s = f"{af:,.0f}"
return f"({s})" if neg else s
except (ValueError, TypeError):
return "N/A"
def _calc_yoy(df: pd.DataFrame) -> pd.DataFrame:
"""Calculate YoY growth rates between consecutive columns."""
if df is None or df.empty or len(df.columns) < 2:
return pd.DataFrame()
yoy = pd.DataFrame(index=df.index)
for i in range(len(df.columns) - 1):
curr_col, prev_col = df.columns[i], df.columns[i + 1]
vals = []
for idx in df.index:
try:
c, p = float(df.loc[idx, curr_col]), float(df.loc[idx, prev_col])
vals.append((c - p) / abs(p) * 100 if p != 0 and c == c and p == p else None)
except (ValueError, TypeError):
vals.append(None)
yoy[f"{str(curr_col)[:10]} YoY"] = vals
return yoy
def _build_display_df(raw_df: pd.DataFrame) -> pd.DataFrame:
"""Build display DF with interleaved YoY growth rows."""
if raw_df is None or raw_df.empty:
return pd.DataFrame()
cols = raw_df.columns[:5]
df = raw_df[cols].copy()
yoy = _calc_yoy(df)
rows = []
for idx in df.index:
row = {"Item": idx}
for c in cols:
row[str(c)[:10]] = _fmt_val(df.loc[idx, c])
rows.append(row)
yoy_row = {"Item": " YoY Growth (%)"}
has_yoy = False
for yc in yoy.columns:
period = yc.replace(" YoY", "")
val = yoy.loc[idx, yc] if idx in yoy.index else None
if val is not None and val == val:
yoy_row[period] = f"{val:+.2f}%"
has_yoy = True
else:
yoy_row[period] = ""
if has_yoy:
rows.append(yoy_row)
result = pd.DataFrame(rows)
if "Item" in result.columns:
result = result.set_index("Item")
return result
def _render_styled_table(df: pd.DataFrame):
"""Render financial statement as HTML with color-coded YoY rows."""
if df is None or df.empty:
st.warning("No data available.")
return
html = '<table style="width:100%;border-collapse:collapse;font-size:0.85rem;">'
html += '<tr style="border-bottom:2px solid rgba(255,255,255,0.1);">'
html += '<th style="text-align:left;padding:8px;color:#9CA3AF;min-width:200px;">Item</th>'
for c in df.columns:
html += f'<th style="text-align:right;padding:8px;color:#9CA3AF;">{c}</th>'
html += '</tr>'
for idx in df.index:
is_yoy = "YoY" in str(idx)
bg = "rgba(255,255,255,0.02)" if not is_yoy else "transparent"
fs = "0.85rem" if not is_yoy else "0.75rem"
bdr = "border-bottom:1px solid rgba(255,255,255,0.04);" if not is_yoy else ""
lc = "#F3F4F6" if not is_yoy else "#6B7280"
fw = "600" if not is_yoy else "400"
html += f'<tr style="background:{bg};{bdr}">'
html += f'<td style="padding:{"6" if not is_yoy else "4"}px 8px;color:{lc};font-size:{fs};font-weight:{fw};">{idx}</td>'
for c in df.columns:
val = str(df.loc[idx, c])
if is_yoy and "%" in val:
try:
num = float(val.replace("%", "").replace("+", ""))
cbg = "rgba(52,211,153,0.12)" if num > 0 else ("rgba(248,113,113,0.12)" if num < 0 else "transparent")
cc = "#34D399" if num > 0 else ("#F87171" if num < 0 else "#9CA3AF")
except ValueError:
cbg, cc = "transparent", "#9CA3AF"
html += f'<td style="text-align:right;padding:4px 8px;background:{cbg};color:{cc};font-size:{fs};border-radius:4px;">{val}</td>'
elif not is_yoy and "(" in val:
html += f'<td style="text-align:right;padding:6px 8px;color:#F87171;font-size:{fs};">{val}</td>'
else:
html += f'<td style="text-align:right;padding:{"6" if not is_yoy else "4"}px 8px;color:{lc};font-size:{fs};">{val}</td>'
html += '</tr>'
html += '</table>'
st.markdown(html, unsafe_allow_html=True)
# ───────── Highlights ─────────
def _render_highlights(fin_df, bal_df, cf_df):
"""Quick financial highlights."""
def _g(df, name):
if df is None or df.empty or name not in df.index:
return None
try:
return float(df.iloc[df.index.get_loc(name), 0])
except Exception:
return None
rev, ni, gp = _g(fin_df, "Total Revenue"), _g(fin_df, "Net Income"), _g(fin_df, "Gross Profit")
ta, tl = _g(bal_df, "Total Assets"), _g(bal_df, "Total Liabilities")
eq, ocf = _g(bal_df, "Total Stockholder Equity"), _g(cf_df, "Operating Cash Flow")
gm = (gp / rev * 100) if rev and gp and rev > 0 else None
nm = (ni / rev * 100) if rev and ni and rev > 0 else None
roe = (ni / eq * 100) if ni and eq and eq > 0 else None
de = (tl / eq) if tl and eq and eq > 0 else None
c1, c2, c3, c4 = st.columns(4)
with c1:
st.metric("Revenue", f"${rev/1e9:,.1f}B" if rev else "N/A")
st.metric("Net Income", f"${ni/1e9:,.1f}B" if ni else "N/A")
with c2:
st.metric("Gross Margin", f"{gm:.1f}%" if gm else "N/A")
st.metric("Net Margin", f"{nm:.1f}%" if nm else "N/A")
with c3:
st.metric("ROE", f"{roe:.1f}%" if roe else "N/A")
st.metric("D/E Ratio", f"{de:.2f}" if de else "N/A")
with c4:
st.metric("Total Assets", f"${ta/1e9:,.1f}B" if ta else "N/A")
st.metric("Op. Cash Flow", f"${ocf/1e9:,.1f}B" if ocf else "N/A")
# ───────── Main ─────────
def render_tab8(ticker):
"""Render the Standardized Financial Statement tab."""
market = st.session_state.get("market") or MARKET_OPTIONS[0]
qt = get_global_ticker(ticker, market) if ticker else ""
st.subheader("Standardized Financial Statements")
if not ticker:
st.info("Select a company from the sidebar.")
return
si = get_sector_industry(qt)
st.caption(f"**{ticker}** · {si.get('sector','N/A')} · {si.get('industry','N/A')}")
with st.spinner("Loading financial statements..."):
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow(qt)
if fin_df is None and bal_df is None and cf_df is None:
st.error("Could not retrieve financial data.")
return
t_hl, t_is, t_bs, t_cf, t_ai = st.tabs([
"Highlights", "Income Statement", "Balance Sheet", "Cash Flow", "AI Summary",
])
with t_hl:
_render_highlights(fin_df, bal_df, cf_df)
with t_is:
_render_styled_table(_build_display_df(fin_df))
with t_bs:
_render_styled_table(_build_display_df(bal_df))
with t_cf:
_render_styled_table(_build_display_df(cf_df))
with t_ai:
render_ai_summary(ticker, fin_df, bal_df, cf_df)
+198
View File
@@ -0,0 +1,198 @@
"""
Tab 9 — Portfolio Management
Full-featured portfolio dashboard: holdings table, sector pie chart,
earnings calendar, dividends, key events, news, AI OCR import.
"""
import streamlit as st
import pandas as pd
from data.portfolio import get_portfolio_prices, get_sector_allocation
from views.portfolio_widgets import render_earnings, render_dividends, render_news
try:
import plotly.express as px
except ImportError:
px = None
# ───────── Session State Init ─────────
def _init_portfolio():
if "portfolio_holdings" not in st.session_state:
st.session_state["portfolio_holdings"] = []
# ───────── Manual Entry Form ─────────
def _render_add_form():
"""Manual stock entry form."""
st.markdown("#### Add Holding")
cols = st.columns([2, 1, 1, 1])
with cols[0]:
ticker = st.text_input("Ticker", placeholder="AAPL", key="pf_add_ticker")
with cols[1]:
name = st.text_input("Name", placeholder="Apple Inc", key="pf_add_name")
with cols[2]:
shares = st.number_input("Shares", min_value=0.0, step=0.01, key="pf_add_shares")
with cols[3]:
avg_cost = st.number_input("Avg Cost ($)", min_value=0.0, step=0.01, key="pf_add_avg")
if st.button("Add to Portfolio", key="pf_add_btn"):
if ticker.strip():
st.session_state["portfolio_holdings"].append({
"ticker": ticker.strip().upper(),
"name": name.strip() or ticker.strip().upper(),
"shares": shares, "avg_cost": avg_cost,
})
st.rerun()
# ───────── AI OCR Import ─────────
def _render_ai_import():
"""AI-powered screenshot import for Trading 212 / IBKR."""
st.markdown("#### Import from Brokerage Screenshot")
google_api_key = (st.session_state.get("google_api_key") or "").strip()
if not google_api_key:
st.info("Enter your Google API Key in the sidebar to enable AI portfolio import.")
return
broker = st.selectbox("Broker", ["Trading 212", "IBKR", "Other"], key="pf_broker")
uploaded = st.file_uploader(
"Upload screenshot", type=["png", "jpg", "jpeg", "webp"],
key="pf_screenshot", help="Upload a screenshot of your portfolio holdings"
)
if uploaded and st.button("Extract Holdings with AI", key="pf_extract_btn", type="primary"):
with st.spinner("Gemini is analyzing your screenshot..."):
from ai.gemini_portfolio import extract_portfolio_from_image
holdings = extract_portfolio_from_image(google_api_key, uploaded.read(), broker)
if holdings:
for h in holdings:
st.session_state["portfolio_holdings"].append({
"ticker": h["ticker"], "name": h.get("name", h["ticker"]),
"shares": h.get("shares") or 0,
"avg_cost": h.get("avg_cost") or h.get("current_price") or 0,
})
st.success(f"Extracted {len(holdings)} holdings!")
st.rerun()
else:
st.warning("No holdings found. Try a clearer screenshot.")
# ───────── Portfolio Overview Cards ─────────
def _render_overview(holdings: list, prices: dict):
"""Total asset value, daily P&L, total P&L cards."""
total_value, total_cost, daily_pnl = 0.0, 0.0, 0.0
for h in holdings:
p = prices.get(h["ticker"], {})
cur = p.get("price") or h.get("avg_cost") or 0
prev = p.get("prev_close") or cur
shares = h.get("shares", 0)
total_value += cur * shares
total_cost += h.get("avg_cost", 0) * shares
daily_pnl += (cur - prev) * shares
total_pnl = total_value - total_cost
pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 0
c1, c2, c3 = st.columns(3)
with c1:
st.metric("Total Portfolio Value", f"${total_value:,.2f}")
with c2:
st.metric("Daily P&L", f"${daily_pnl:,.2f}", delta=f"{daily_pnl:+,.2f}")
with c3:
st.metric("Total P&L", f"${total_pnl:,.2f}", delta=f"{pnl_pct:+.2f}%")
# ───────── Holdings Table ─────────
def _render_holdings_table(holdings: list, prices: dict):
"""Display holdings with current price, change, P&L."""
if not holdings:
return
st.markdown("#### Holdings")
rows = []
for i, h in enumerate(holdings):
sym = h["ticker"]
p = prices.get(sym, {})
cur = p.get("price")
chg = p.get("change_pct", 0)
shares, avg = h.get("shares", 0), h.get("avg_cost", 0)
mkt = (cur or 0) * shares
pnl = (cur - avg) * shares if cur and avg else 0
pnl_p = ((cur - avg) / avg * 100) if cur and avg and avg > 0 else 0
rows.append({
"": i, "Ticker": sym, "Name": h.get("name", sym),
"Shares": f"{shares:,.2f}" if shares != int(shares) else f"{int(shares):,}",
"Avg Cost": f"${avg:,.2f}" if avg else "N/A",
"Current": f"${cur:,.2f}" if cur else "N/A",
"Day Chg": f"{chg:+.2f}%", "P&L": f"${pnl:+,.2f}",
"P&L %": f"{pnl_p:+.2f}%", "Mkt Value": f"${mkt:,.2f}",
})
df = pd.DataFrame(rows).set_index("")
st.dataframe(df, use_container_width=True, height=min(40 * len(rows) + 38, 500))
with st.expander("Remove Holdings"):
for i, h in enumerate(holdings):
if st.button(f"Remove {h['ticker']}", key=f"pf_del_{i}"):
st.session_state["portfolio_holdings"].pop(i)
st.rerun()
# ───────── Sector Pie Chart ─────────
def _render_sector_pie(holdings: list):
"""Sector allocation donut chart."""
if not holdings or not px:
return
tickers = tuple(h["ticker"] for h in holdings)
sectors = get_sector_allocation(tickers)
agg = {}
for h in holdings:
sec = sectors.get(h["ticker"], "Other")
agg[sec] = agg.get(sec, 0) + h.get("shares", 0) * h.get("avg_cost", 0)
if not agg:
return
fig = px.pie(
names=list(agg.keys()), values=list(agg.values()),
title="Sector Allocation", hole=0.4,
color_discrete_sequence=px.colors.qualitative.Set3,
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font_color="#E5E7EB", height=350, margin=dict(t=40, b=20, l=20, r=20),
)
st.plotly_chart(fig, use_container_width=True)
# ───────── Main Render ─────────
def render_tab9():
"""Render the Portfolio Management tab."""
_init_portfolio()
st.subheader("Portfolio Management")
holdings = st.session_state["portfolio_holdings"]
with st.expander("Add / Import Holdings", expanded=not bool(holdings)):
t1, t2 = st.tabs(["Manual Entry", "AI Screenshot Import"])
with t1:
_render_add_form()
with t2:
_render_ai_import()
if not holdings:
st.info("Add holdings above to see your portfolio dashboard.")
return
tickers = tuple(h["ticker"] for h in holdings)
with st.spinner("Fetching live prices..."):
prices = get_portfolio_prices(tickers)
_render_overview(holdings, prices)
st.markdown("---")
left, right = st.columns([3, 2])
with left:
_render_holdings_table(holdings, prices)
_render_sector_pie(holdings)
with right:
render_earnings(tickers)
st.markdown("---")
render_dividends(tickers)
st.markdown("---")
render_news(tickers)