aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-31 23:01:05 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-31 23:01:05 -0700
commit96b27f1d00ae8110273de973053c3d6bfc4f3662 (patch)
tree67ca02423a206f1320741a246bd79cc23cdd36e1
parentcb2d8f5ebf417e1f01c1ed9345801d4b2216d9f2 (diff)
Add relative performance chart and refine top movers
-rw-r--r--AGENTS.md39
-rw-r--r--components/insiders.py2
-rw-r--r--components/overview.py170
-rw-r--r--components/top_movers.py140
4 files changed, 301 insertions, 50 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..061abd4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,39 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+`app.py` is the Streamlit entrypoint and wires the sidebar, top-of-page sections, and main tabs together. UI sections live in `components/` (`overview.py`, `valuation.py`, `top_movers.py`, etc.). Data access and finance logic live in `services/`, with `data_service.py` handling most `yfinance` work and `valuation_service.py` containing DCF and EV/EBITDA calculations. Shared formatting helpers are in `utils/`. Static assets such as the logo live in `assets/`.
+
+There is currently no dedicated `tests/` directory. If you add tests, place them under `tests/` and mirror the source layout where practical.
+
+## Build, Test, and Development Commands
+Use the local virtual environment before running anything:
+
+```bash
+source .venv/bin/activate
+pip install -r requirements.txt
+streamlit run app.py
+```
+
+- `pip install -r requirements.txt`: installs Streamlit, Plotly, `yfinance`, and supporting libraries.
+- `streamlit run app.py`: starts the dashboard locally at `http://localhost:8501`.
+- `python -m py_compile app.py components/*.py services/*.py utils/*.py`: quick syntax check across the codebase.
+
+## Coding Style & Naming Conventions
+Follow existing Python style: 4-space indentation, descriptive snake_case for functions and variables, and short module-level docstrings. Keep render functions named `render_<section>()` inside `components/`. Prefer small helper functions for repeated UI patterns. Match the repo’s current approach: light comments, defensive fallbacks around API data, and `@st.cache_data` for expensive fetches.
+
+No formatter or linter is configured in the repo today, so keep changes stylistically consistent with neighboring files.
+
+## Testing Guidelines
+There is no automated test suite yet. For UI or data-flow changes, run the app locally and verify the affected tab or section with a few tickers. At minimum, run targeted syntax checks with `python -m py_compile <file>`. If you add nontrivial finance logic, include unit tests in `tests/` and cover both normal and missing-data cases.
+
+## Commit & Pull Request Guidelines
+Use short, imperative commit messages like `Add top movers section` or `Fix EBITDA consistency bug`. Keep each commit focused on one user-visible change or bug fix.
+
+For pull requests, include:
+- a clear summary of what changed and why
+- any data-source or API-key impact
+- screenshots or short screen recordings for UI changes
+- manual verification notes (example tickers tested, tabs checked)
+
+## Security & Configuration Tips
+Store API keys in `.env`; do not commit secrets. Prism can run partially without `FMP_API_KEY` and `FINNHUB_API_KEY`, so document any new required configuration in `README.md`.
diff --git a/components/insiders.py b/components/insiders.py
index 354ffef..07bc3e3 100644
--- a/components/insiders.py
+++ b/components/insiders.py
@@ -5,7 +5,7 @@ import streamlit as st
from datetime import datetime, timedelta
from services.data_service import get_insider_transactions
-from utils.formatters import fmt_currency, fmt_large
+from utils.formatters import fmt_large
def _classify(text: str) -> str:
diff --git a/components/overview.py b/components/overview.py
index 53b8554..1bb65c2 100644
--- a/components/overview.py
+++ b/components/overview.py
@@ -6,6 +6,41 @@ from utils.formatters import fmt_large, fmt_currency, fmt_pct, fmt_ratio
PERIODS = {"1 Month": "1mo", "3 Months": "3mo", "6 Months": "6mo", "1 Year": "1y", "5 Years": "5y"}
+SECTOR_ETF_MAP = {
+ "Technology": "XLK",
+ "Communication Services": "XLC",
+ "Consumer Cyclical": "XLY",
+ "Consumer Defensive": "XLP",
+ "Financial Services": "XLF",
+ "Healthcare": "XLV",
+ "Industrials": "XLI",
+ "Energy": "XLE",
+ "Utilities": "XLU",
+ "Real Estate": "XLRE",
+ "Basic Materials": "XLB",
+}
+INDUSTRY_PEER_MAP = {
+ "consumer electronics": ["SONY", "DELL", "HPQ"],
+ "software - infrastructure": ["MSFT", "ORCL", "CRM"],
+ "semiconductors": ["NVDA", "AMD", "AVGO"],
+ "internet content & information": ["GOOGL", "META", "RDDT"],
+ "banks - diversified": ["JPM", "BAC", "WFC"],
+ "credit services": ["V", "MA", "AXP"],
+ "insurance - diversified": ["BRK-B", "AIG", "ALL"],
+ "reit - industrial": ["PLD", "PSA", "EXR"],
+}
+SECTOR_PEER_MAP = {
+ "Technology": ["AAPL", "MSFT", "NVDA"],
+ "Communication Services": ["GOOGL", "META", "NFLX"],
+ "Consumer Cyclical": ["AMZN", "TSLA", "HD"],
+ "Consumer Defensive": ["WMT", "COST", "PG"],
+ "Financial Services": ["JPM", "BAC", "GS"],
+ "Healthcare": ["LLY", "UNH", "JNJ"],
+ "Industrials": ["GE", "CAT", "RTX"],
+ "Energy": ["XOM", "CVX", "COP"],
+ "Utilities": ["NEE", "DUK", "SO"],
+ "Real Estate": ["PLD", "AMT", "EQIX"],
+}
# ── Score card ───────────────────────────────────────────────────────────────
@@ -186,6 +221,112 @@ def _render_short_interest(info: dict) -> None:
cols[3].metric("Prior Month", fmt_large(shares_short_prior) if shares_short_prior else "—")
+def _suggest_relative_comparisons(ticker: str, info: dict) -> list[tuple[str, str]]:
+ comparisons: list[tuple[str, str]] = [("S&P 500", "^GSPC")]
+
+ sector = str(info.get("sector") or "").strip()
+ industry = str(info.get("industry") or "").strip().lower()
+
+ sector_etf = SECTOR_ETF_MAP.get(sector)
+ if sector_etf:
+ comparisons.append((f"{sector} ETF", sector_etf))
+
+ peer_candidates = INDUSTRY_PEER_MAP.get(industry) or SECTOR_PEER_MAP.get(sector) or []
+ for peer in peer_candidates:
+ peer_up = peer.upper()
+ if peer_up != ticker.upper():
+ comparisons.append((peer_up, peer_up))
+
+ deduped: list[tuple[str, str]] = []
+ seen = set()
+ for label, symbol in comparisons:
+ if symbol not in seen:
+ deduped.append((label, symbol))
+ seen.add(symbol)
+ return deduped[:5]
+
+
+def _build_relative_series(symbol: str, period: str):
+ hist = get_price_history(symbol, period=period)
+ if hist.empty or "Close" not in hist.columns:
+ return None
+
+ closes = hist["Close"].dropna()
+ if closes.empty:
+ return None
+
+ base = float(closes.iloc[0])
+ if base <= 0:
+ return None
+
+ return (closes / base - 1.0) * 100.0
+
+
+def _render_relative_chart(ticker: str, info: dict, period: str):
+ options = _suggest_relative_comparisons(ticker, info)
+ option_map = {label: symbol for label, symbol in options}
+ default_labels = ["S&P 500"] if "S&P 500" in option_map else [label for label, _ in options[:1]]
+
+ selected_labels = st.multiselect(
+ "Compare against",
+ options=list(option_map.keys()),
+ default=default_labels,
+ key=f"overview_relative_comparisons_{ticker.upper()}",
+ help="Performance is rebased to 0% at the start of the selected period.",
+ )
+
+ fig = go.Figure()
+ subject_series = _build_relative_series(ticker, period)
+ if subject_series is None:
+ st.warning("No price history available.")
+ return
+
+ fig.add_trace(go.Scatter(
+ x=subject_series.index,
+ y=subject_series.values,
+ mode="lines",
+ name=ticker.upper(),
+ line=dict(color="#4F8EF7", width=2.5),
+ ))
+
+ palette = ["#7ce3a1", "#F7A24F", "#c084fc", "#ff8a8a", "#9ad1ff"]
+ plotted = 1
+ for idx, label in enumerate(selected_labels):
+ symbol = option_map[label]
+ series = _build_relative_series(symbol, period)
+ if series is None:
+ continue
+ fig.add_trace(go.Scatter(
+ x=series.index,
+ y=series.values,
+ mode="lines",
+ name=label,
+ line=dict(color=palette[idx % len(palette)], width=1.8),
+ ))
+ plotted += 1
+
+ if plotted == 1:
+ st.caption("No comparison series were available for the selected period.")
+
+ fig.update_layout(
+ margin=dict(l=0, r=0, t=10, b=0),
+ xaxis=dict(showgrid=False, zeroline=False),
+ yaxis=dict(
+ showgrid=True,
+ gridcolor="rgba(255,255,255,0.05)",
+ zeroline=True,
+ zerolinecolor="rgba(255,255,255,0.12)",
+ ticksuffix="%",
+ ),
+ plot_bgcolor="rgba(0,0,0,0)",
+ paper_bgcolor="rgba(0,0,0,0)",
+ hovermode="x unified",
+ height=320,
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
+ )
+ st.plotly_chart(fig, use_container_width=True)
+
+
# ── Main render ──────────────────────────────────────────────────────────────
def render_overview(ticker: str):
@@ -248,15 +389,30 @@ def render_overview(ticker: str):
st.divider()
# ── Price chart ─────────────────────────────────────────────────────────
- period_label = st.radio(
- "Period",
- options=list(PERIODS.keys()),
- index=3,
- horizontal=True,
- label_visibility="collapsed",
- )
+ control_col1, control_col2 = st.columns([3, 1.4])
+ with control_col1:
+ period_label = st.radio(
+ "Period",
+ options=list(PERIODS.keys()),
+ index=3,
+ horizontal=True,
+ label_visibility="collapsed",
+ key=f"overview_period_{ticker.upper()}",
+ )
+ with control_col2:
+ chart_mode = st.radio(
+ "Chart mode",
+ options=["Price", "Relative"],
+ horizontal=True,
+ label_visibility="collapsed",
+ key=f"overview_chart_mode_{ticker.upper()}",
+ )
period = PERIODS[period_label]
+ if chart_mode == "Relative":
+ _render_relative_chart(ticker, info, period)
+ return
+
hist = get_price_history(ticker, period=period)
if hist.empty:
st.warning("No price history available.")
diff --git a/components/top_movers.py b/components/top_movers.py
index ac76504..ea72fc6 100644
--- a/components/top_movers.py
+++ b/components/top_movers.py
@@ -1,10 +1,69 @@
"""Top Movers component — day gainers, losers, most active."""
+from html import escape
+
import streamlit as st
import yfinance as yf
+DEFAULT_VISIBLE_MOVERS = 3
+MAX_MOVERS = 8
+
+
+def _inject_styles():
+ st.markdown(
+ """
+ <style>
+ .prism-mover-list {
+ display: grid;
+ gap: 0.12rem;
+ }
+ .prism-mover-row {
+ display: grid;
+ grid-template-columns: minmax(72px, 0.8fr) minmax(0, 2.6fr) minmax(90px, 1fr) minmax(110px, 1.1fr);
+ gap: 0.85rem;
+ align-items: center;
+ padding: 0.18rem 0;
+ }
+ .prism-mover-symbol {
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.1;
+ }
+ .prism-mover-name {
+ color: #9aa0b0;
+ font-size: 0.84rem;
+ line-height: 1.15;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .prism-mover-price {
+ font-size: 0.98rem;
+ line-height: 1.1;
+ }
+ .prism-mover-change {
+ font-size: 0.98rem;
+ font-weight: 600;
+ line-height: 1.1;
+ }
+ .prism-mover-change-meta {
+ font-size: 0.74rem;
+ color: #9aa0b0;
+ margin-left: 0.2rem;
+ }
+ @media (max-width: 900px) {
+ .prism-mover-row {
+ grid-template-columns: minmax(68px, 0.9fr) minmax(0, 2.2fr) minmax(82px, 1fr) minmax(96px, 1fr);
+ gap: 0.55rem;
+ }
+ }
+ </style>
+ """,
+ unsafe_allow_html=True,
+ )
+
@st.cache_data(ttl=180)
-def _fetch_movers(screen: str, count: int = 8) -> list[dict]:
+def _fetch_movers(screen: str, count: int = MAX_MOVERS) -> list[dict]:
try:
result = yf.screen(screen, count=count)
return result.get("quotes", [])
@@ -12,13 +71,6 @@ def _fetch_movers(screen: str, count: int = 8) -> list[dict]:
return []
-def _fmt_pct(val) -> str:
- try:
- return f"{float(val):+.2f}%"
- except Exception:
- return "—"
-
-
def _fmt_price(val) -> str:
try:
return f"${float(val):,.2f}"
@@ -26,9 +78,9 @@ def _fmt_price(val) -> str:
return "—"
-def _mover_row(q: dict):
- symbol = q.get("symbol", "")
- name = q.get("shortName") or q.get("longName") or symbol
+def _mover_row_html(q: dict) -> str:
+ symbol = escape(str(q.get("symbol", "")))
+ name = escape(str(q.get("shortName") or q.get("longName") or symbol))
price = q.get("regularMarketPrice")
chg_pct = q.get("regularMarketChangePercent")
chg_abs = q.get("regularMarketChange")
@@ -46,23 +98,42 @@ def _mover_row(q: dict):
abs_str = f"({'+' if float(chg_abs) >= 0 else ''}{float(chg_abs):.2f})"
except Exception:
abs_str = ""
+ abs_str = escape(abs_str)
+
+ return (
+ "<div class='prism-mover-row'>"
+ f"<div class='prism-mover-symbol'>{symbol}</div>"
+ f"<div class='prism-mover-name'>{name}</div>"
+ f"<div class='prism-mover-price'>{_fmt_price(price)}</div>"
+ "<div>"
+ f"<span class='prism-mover-change' style='color:{color}'>{pct_str}</span>"
+ f"<span class='prism-mover-change-meta'>{abs_str}</span>"
+ "</div>"
+ "</div>"
+ )
+
+
+def _render_mover_tab(screen: str, state_key: str):
+ quotes = _fetch_movers(screen)
+ if not quotes:
+ st.caption("No data available.")
+ return
+
+ expanded = st.session_state.get(state_key, False)
+ visible_count = len(quotes) if expanded else min(DEFAULT_VISIBLE_MOVERS, len(quotes))
+
+ rows_html = "".join(_mover_row_html(q) for q in quotes[:visible_count])
+ st.markdown(f"<div class='prism-mover-list'>{rows_html}</div>", unsafe_allow_html=True)
- col_sym, col_name, col_price, col_chg = st.columns([1, 3, 1.5, 1.5])
- with col_sym:
- st.markdown(f"**{symbol}**")
- with col_name:
- st.caption(name[:40])
- with col_price:
- st.markdown(_fmt_price(price))
- with col_chg:
- st.markdown(
- f"<span style='color:{color};font-weight:600'>{pct_str}</span>"
- f"<span style='font-size:0.75rem;color:#9aa0b0'> {abs_str}</span>",
- unsafe_allow_html=True,
- )
+ if len(quotes) > DEFAULT_VISIBLE_MOVERS:
+ button_label = "Show Less" if expanded else f"Show More ({len(quotes) - DEFAULT_VISIBLE_MOVERS} more)"
+ if st.button(button_label, key=f"{state_key}_button", use_container_width=True):
+ st.session_state[state_key] = not expanded
+@st.fragment
def render_top_movers():
+ _inject_styles()
st.markdown("#### 🔥 Top Movers")
tab_gainers, tab_losers, tab_active = st.tabs([
@@ -76,25 +147,10 @@ def render_top_movers():
}
with tab_gainers:
- quotes = _fetch_movers(screens["gainers"])
- if quotes:
- for q in quotes:
- _mover_row(q)
- else:
- st.caption("No data available.")
+ _render_mover_tab(screens["gainers"], "top_movers_gainers_expanded")
with tab_losers:
- quotes = _fetch_movers(screens["losers"])
- if quotes:
- for q in quotes:
- _mover_row(q)
- else:
- st.caption("No data available.")
+ _render_mover_tab(screens["losers"], "top_movers_losers_expanded")
with tab_active:
- quotes = _fetch_movers(screens["active"])
- if quotes:
- for q in quotes:
- _mover_row(q)
- else:
- st.caption("No data available.")
+ _render_mover_tab(screens["active"], "top_movers_active_expanded")