From 96b27f1d00ae8110273de973053c3d6bfc4f3662 Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 31 Mar 2026 23:01:05 -0700 Subject: Add relative performance chart and refine top movers --- AGENTS.md | 39 +++++++++++ components/insiders.py | 2 +- components/overview.py | 170 +++++++++++++++++++++++++++++++++++++++++++++-- components/top_movers.py | 140 ++++++++++++++++++++++++++------------ 4 files changed, 301 insertions(+), 50 deletions(-) create mode 100644 AGENTS.md 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_
()` 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 `. 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( + """ + + """, + 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 ( + "
" + f"
{symbol}
" + f"
{name}
" + f"
{_fmt_price(price)}
" + "
" + f"{pct_str}" + f"{abs_str}" + "
" + "
" + ) + + +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"
{rows_html}
", 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"{pct_str}" - f" {abs_str}", - 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") -- cgit v1.3-2-g0d8e