mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-13 10:28:05 +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,88 @@
|
||||
"""
|
||||
Analyst estimates — earnings & revenue forecasts, consensus targets.
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_analyst_estimates(ticker: str) -> dict:
|
||||
"""Fetch analyst earnings and revenue estimates from yfinance."""
|
||||
if not yf or not ticker:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker)
|
||||
result = {}
|
||||
|
||||
# Earnings estimates
|
||||
ee = getattr(t, "earnings_estimate", None)
|
||||
if ee is not None and not ee.empty:
|
||||
result["earnings_estimate"] = ee
|
||||
|
||||
# Revenue estimates
|
||||
re = getattr(t, "revenue_estimate", None)
|
||||
if re is not None and not re.empty:
|
||||
result["revenue_estimate"] = re
|
||||
|
||||
# EPS trend
|
||||
et = getattr(t, "eps_trend", None)
|
||||
if et is not None and not et.empty:
|
||||
result["eps_trend"] = et
|
||||
|
||||
# Earnings history
|
||||
eh = getattr(t, "earnings_history", None)
|
||||
if eh is not None and not eh.empty:
|
||||
result["earnings_history"] = eh
|
||||
|
||||
# Growth estimates
|
||||
ge = getattr(t, "growth_estimates", None)
|
||||
if ge is not None and not ge.empty:
|
||||
result["growth_estimates"] = ge
|
||||
|
||||
# Price targets
|
||||
info = t.info or {}
|
||||
result["targets"] = {
|
||||
"current": info.get("currentPrice") or info.get("regularMarketPrice"),
|
||||
"mean": info.get("targetMeanPrice"),
|
||||
"high": info.get("targetHighPrice"),
|
||||
"low": info.get("targetLowPrice"),
|
||||
"median": info.get("targetMedianPrice"),
|
||||
"recommendation": info.get("recommendationKey", "N/A"),
|
||||
"num_analysts": info.get("numberOfAnalystOpinions"),
|
||||
}
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_earnings_dates(ticker: str) -> pd.DataFrame:
|
||||
"""Fetch historical and upcoming earnings dates with surprise data."""
|
||||
if not yf or not ticker:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
t = yf.Ticker(ticker)
|
||||
dates = t.earnings_dates
|
||||
if dates is not None and not dates.empty:
|
||||
return dates.head(12)
|
||||
except Exception:
|
||||
pass
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def format_estimate_table(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Format estimate DataFrame for display with proper number formatting."""
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame()
|
||||
display = df.copy()
|
||||
for col in display.columns:
|
||||
display[col] = display[col].apply(
|
||||
lambda v: f"{v:,.2f}" if isinstance(v, (int, float)) and v == v else "N/A"
|
||||
)
|
||||
return display
|
||||
+2
-1
@@ -90,10 +90,11 @@ def _get_ticker_bar_data() -> list:
|
||||
def _fetch_news_rss(ticker_sym: str, company_name: str = "") -> list:
|
||||
"""Fetch news from Google News RSS. Returns list of {title, source, url, published}."""
|
||||
import feedparser
|
||||
from urllib.parse import quote_plus
|
||||
items = []
|
||||
query = ticker_sym if not company_name else company_name
|
||||
try:
|
||||
feed = feedparser.parse(f"https://news.google.com/rss/search?q={query}+stock&hl=en-US&gl=US&ceid=US:en")
|
||||
feed = feedparser.parse(f"https://news.google.com/rss/search?q={quote_plus(query)}+stock&hl=en-US&gl=US&ceid=US:en")
|
||||
for entry in (feed.entries or [])[:15]:
|
||||
items.append({
|
||||
"title": entry.get("title", ""),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Portfolio data layer — fetch current prices, earnings calendar, dividends, sector info, news for portfolio holdings.
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
@st.cache_data(ttl=120)
|
||||
def get_portfolio_prices(tickers: tuple) -> dict:
|
||||
"""Fetch current price, previous close, and day change for each ticker.
|
||||
Returns {ticker: {"price": float, "prev_close": float, "change_pct": float}}
|
||||
"""
|
||||
if not yf or not tickers:
|
||||
return {}
|
||||
result = {}
|
||||
for sym in tickers:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
price = info.get("regularMarketPrice") or info.get("currentPrice")
|
||||
prev = info.get("regularMarketPreviousClose") or info.get("previousClose")
|
||||
if price and prev and prev != 0:
|
||||
change = (price - prev) / prev * 100
|
||||
else:
|
||||
change = 0.0
|
||||
result[sym] = {"price": price, "prev_close": prev, "change_pct": round(change, 2)}
|
||||
except Exception:
|
||||
result[sym] = {"price": None, "prev_close": None, "change_pct": 0.0}
|
||||
return result
|
||||
|
||||
|
||||
@st.cache_data(ttl=300)
|
||||
def get_sector_allocation(tickers: tuple) -> dict:
|
||||
"""Return {ticker: sector} for pie chart. Uses yfinance .info."""
|
||||
if not yf or not tickers:
|
||||
return {}
|
||||
result = {}
|
||||
for sym in tickers:
|
||||
try:
|
||||
info = yf.Ticker(sym).info or {}
|
||||
result[sym] = info.get("sector") or info.get("sectorDisp") or "Other"
|
||||
except Exception:
|
||||
result[sym] = "Other"
|
||||
return result
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_earnings_calendar(tickers: tuple) -> list:
|
||||
"""Return list of upcoming earnings: [{"ticker", "name", "date", "days_until"}]."""
|
||||
if not yf or not tickers:
|
||||
return []
|
||||
events = []
|
||||
now = datetime.now()
|
||||
for sym in tickers:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
cal = t.calendar
|
||||
if cal is None:
|
||||
continue
|
||||
# yfinance returns dict or DataFrame
|
||||
if isinstance(cal, pd.DataFrame):
|
||||
if "Earnings Date" in cal.index:
|
||||
dates = cal.loc["Earnings Date"]
|
||||
ed = pd.Timestamp(dates.iloc[0]) if len(dates) > 0 else None
|
||||
else:
|
||||
continue
|
||||
elif isinstance(cal, dict):
|
||||
ed_val = cal.get("Earnings Date")
|
||||
if isinstance(ed_val, list) and ed_val:
|
||||
ed = pd.Timestamp(ed_val[0])
|
||||
elif ed_val:
|
||||
ed = pd.Timestamp(ed_val)
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
if ed and ed >= pd.Timestamp(now):
|
||||
delta = (ed - pd.Timestamp(now)).days
|
||||
name = (t.info or {}).get("shortName", sym)
|
||||
events.append({
|
||||
"ticker": sym, "name": name,
|
||||
"date": ed.strftime("%m/%d"), "days_until": delta,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
events.sort(key=lambda x: x["days_until"])
|
||||
return events
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_dividend_schedule(tickers: tuple) -> list:
|
||||
"""Return upcoming dividend info: [{"ticker", "name", "ex_date", "amount", "yield_pct"}]."""
|
||||
if not yf or not tickers:
|
||||
return []
|
||||
divs = []
|
||||
for sym in tickers:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
div_rate = info.get("dividendRate")
|
||||
div_yield = info.get("dividendYield")
|
||||
ex_date = info.get("exDividendDate")
|
||||
if not div_rate and not div_yield:
|
||||
continue
|
||||
name = info.get("shortName", sym)
|
||||
ex_str = ""
|
||||
if ex_date:
|
||||
try:
|
||||
ex_dt = datetime.fromtimestamp(ex_date) if isinstance(ex_date, (int, float)) else ex_date
|
||||
ex_str = ex_dt.strftime("%m/%d/%Y") if hasattr(ex_dt, "strftime") else str(ex_date)
|
||||
except Exception:
|
||||
ex_str = str(ex_date)
|
||||
divs.append({
|
||||
"ticker": sym, "name": name, "ex_date": ex_str,
|
||||
"amount": f"${div_rate:.2f}" if div_rate else "N/A",
|
||||
"yield_pct": f"{div_yield * 100:.2f}%" if div_yield else "N/A",
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return divs
|
||||
|
||||
|
||||
@st.cache_data(ttl=300)
|
||||
def get_portfolio_news(tickers: tuple, max_per_ticker: int = 3) -> list:
|
||||
"""Fetch recent news for portfolio tickers via yfinance."""
|
||||
if not yf or not tickers:
|
||||
return []
|
||||
all_news = []
|
||||
for sym in tickers:
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
news_list = t.news or []
|
||||
for n in news_list[:max_per_ticker]:
|
||||
all_news.append({
|
||||
"ticker": sym,
|
||||
"title": n.get("title", ""),
|
||||
"publisher": n.get("publisher", ""),
|
||||
"link": n.get("link", ""),
|
||||
"published": n.get("providerPublishTime", 0),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
all_news.sort(key=lambda x: x.get("published", 0), reverse=True)
|
||||
return all_news[:20]
|
||||
|
||||
|
||||
@st.cache_data(ttl=300)
|
||||
def get_sparkline_data(ticker: str, period: str = "1y") -> list:
|
||||
"""Return list of close prices for sparkline chart."""
|
||||
if not yf or not ticker:
|
||||
return []
|
||||
try:
|
||||
hist = yf.Ticker(ticker).history(period=period)
|
||||
if hist is not None and not hist.empty and "Close" in hist.columns:
|
||||
return hist["Close"].tolist()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Valuation metrics — PER, PBR, PSR, P/OCF, EV/EBITDA with historical & industry comparison.
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
try:
|
||||
from yahooquery import Ticker as YQTicker
|
||||
except ImportError:
|
||||
YQTicker = None
|
||||
|
||||
|
||||
@st.cache_data(ttl=300)
|
||||
def get_valuation_multiples(ticker: str) -> dict:
|
||||
"""Fetch current valuation multiples for a ticker."""
|
||||
if not yf or not ticker:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker)
|
||||
info = t.info or {}
|
||||
price = info.get("regularMarketPrice") or info.get("currentPrice") or 0
|
||||
mcap = info.get("marketCap") or 0
|
||||
# P/OCF calculation
|
||||
ocf = info.get("operatingCashflow")
|
||||
shares = info.get("sharesOutstanding") or 1
|
||||
p_ocf = (price / (ocf / shares)) if ocf and shares and ocf > 0 else None
|
||||
|
||||
return {
|
||||
"PER": info.get("trailingPE"),
|
||||
"Forward PER": info.get("forwardPE"),
|
||||
"PBR": info.get("priceToBook"),
|
||||
"PSR": info.get("priceToSalesTrailing12Months"),
|
||||
"P/OCF": round(p_ocf, 2) if p_ocf else None,
|
||||
"EV/EBITDA": info.get("enterpriseToEbitda"),
|
||||
"EV/Revenue": info.get("enterpriseToRevenue"),
|
||||
"PEG": info.get("pegRatio"),
|
||||
"Dividend Yield": info.get("dividendYield"),
|
||||
"Price": price,
|
||||
"Market Cap": mcap,
|
||||
"52W High": info.get("fiftyTwoWeekHigh"),
|
||||
"52W Low": info.get("fiftyTwoWeekLow"),
|
||||
"Beta": info.get("beta"),
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_historical_multiples(ticker: str) -> dict:
|
||||
"""Calculate 5Y average PE, PB, PS from historical data."""
|
||||
if not yf or not ticker:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker)
|
||||
info = t.info or {}
|
||||
hist = t.history(period="5y", interval="1mo")
|
||||
if hist is None or hist.empty:
|
||||
return {}
|
||||
eps = info.get("trailingEps")
|
||||
bvps = info.get("bookValue")
|
||||
|
||||
result = {}
|
||||
if eps and eps > 0:
|
||||
pe_series = hist["Close"] / eps
|
||||
result["5Y Avg PER"] = round(pe_series.mean(), 2)
|
||||
result["5Y High PER"] = round(pe_series.max(), 2)
|
||||
result["5Y Low PER"] = round(pe_series.min(), 2)
|
||||
if bvps and bvps > 0:
|
||||
pb_series = hist["Close"] / bvps
|
||||
result["5Y Avg PBR"] = round(pb_series.mean(), 2)
|
||||
return result
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=600)
|
||||
def get_industry_avg_multiples(ticker: str) -> dict:
|
||||
"""Get industry peer average multiples for comparison."""
|
||||
if not yf or not ticker:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker)
|
||||
info = t.info or {}
|
||||
industry = info.get("industry", "")
|
||||
sector = info.get("sector", "")
|
||||
if not industry:
|
||||
return {"industry": "N/A"}
|
||||
|
||||
# Use sector-based peer mapping
|
||||
from config.constants import SECTORS
|
||||
peers = []
|
||||
sector_lower = sector.lower() if sector else ""
|
||||
for sec_name, tickers_list in SECTORS.items():
|
||||
if any(k in sector_lower for k in sec_name.lower().split()):
|
||||
peers = [p for p in tickers_list if p != ticker.upper()][:4]
|
||||
break
|
||||
if not peers:
|
||||
return {"industry": industry}
|
||||
|
||||
pe_vals, pb_vals, ps_vals = [], [], []
|
||||
for p in peers:
|
||||
try:
|
||||
pi = yf.Ticker(p).info or {}
|
||||
if pi.get("trailingPE") and pi["trailingPE"] > 0:
|
||||
pe_vals.append(pi["trailingPE"])
|
||||
if pi.get("priceToBook") and pi["priceToBook"] > 0:
|
||||
pb_vals.append(pi["priceToBook"])
|
||||
if pi.get("priceToSalesTrailing12Months"):
|
||||
ps_vals.append(pi["priceToSalesTrailing12Months"])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result = {"industry": industry, "peers": peers}
|
||||
if pe_vals:
|
||||
result["Industry Avg PER"] = round(sum(pe_vals) / len(pe_vals), 2)
|
||||
if pb_vals:
|
||||
result["Industry Avg PBR"] = round(sum(pb_vals) / len(pb_vals), 2)
|
||||
if ps_vals:
|
||||
result["Industry Avg PSR"] = round(sum(ps_vals) / len(ps_vals), 2)
|
||||
return result
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=300)
|
||||
def get_pe_history_chart_data(ticker: str) -> pd.DataFrame:
|
||||
"""Return monthly close prices for 5Y PE chart overlay."""
|
||||
if not yf or not ticker:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
hist = yf.Ticker(ticker).history(period="5y", interval="1mo")
|
||||
if hist is not None and not hist.empty:
|
||||
return hist[["Close"]].reset_index()
|
||||
except Exception:
|
||||
pass
|
||||
return pd.DataFrame()
|
||||
Reference in New Issue
Block a user