mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-17 04:18:08 +00:00
feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend. Frontend (Next.js 14): - 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings - Terminal Noir dark theme with custom Tailwind config - TradingView Lightweight Charts for candlestick/volume - Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF - Financial Statements table with YoY growth badges and margin rows - SEC EDGAR inline filing viewer with section tabs - News split-view with iframe article embedding - Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages - Earnings beat/miss visualization - AI Copilot chat panel with Gemini integration Backend (FastAPI): - 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx - Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators - yfinance + yahooquery data sources with fallback pattern - SQLite caching layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -0,0 +1,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.")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user