aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-30 18:19:50 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-30 18:19:50 -0700
commitf6b21398b8d9d13fa707955852f4e73158d7db19 (patch)
tree7dd49e0f483b2bda9ff4b5db0f10df3a5eef06ca /components
parentfde921603425de36c6cbf583f1ec0e2f2ce034cb (diff)
Add score card, 52W range bar, short interest, options tab, CSV exports
Overview: - Score card: green/yellow/red signals for valuation, growth, profitability, leverage, momentum (vs 52W high), and short interest - 52W high/low visual range bar with current price marker and % context - Short interest metrics row: % of float, days to cover, shares short vs prior month - Replaced static 52W High/Low metrics with volume and avg volume Options tab (new): - Expiry selector across all available expirations - Put/call ratio by volume and open interest with bullish/bearish label - IV smile chart (calls + puts) with ATM marker - Open interest by strike (calls above, puts mirrored below axis) - Full chain table (calls/puts) in expandable section CSV exports: - Download button on each financial statement (income, balance, cash flow) - Download button on earnings history table Also fix top padding cut-off: block-container padding-top 1rem → 3.5rem Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components')
-rw-r--r--components/financials.py21
-rw-r--r--components/options.py200
-rw-r--r--components/overview.py231
-rw-r--r--components/valuation.py7
4 files changed, 437 insertions, 22 deletions
diff --git a/components/financials.py b/components/financials.py
index 828f256..9078770 100644
--- a/components/financials.py
+++ b/components/financials.py
@@ -116,6 +116,13 @@ def render_financials(ticker: str):
else:
display, colors = _build_statement(df)
st.dataframe(_style(display, colors), use_container_width=True)
+ st.download_button(
+ "Download CSV",
+ df.to_csv().encode(),
+ file_name=f"{ticker.upper()}_income_{'quarterly' if quarterly else 'annual'}.csv",
+ mime="text/csv",
+ key=f"dl_income_{ticker}_{quarterly}",
+ )
with tab_balance:
df = get_balance_sheet(ticker, quarterly=quarterly)
@@ -124,6 +131,13 @@ def render_financials(ticker: str):
else:
display, colors = _build_statement(df)
st.dataframe(_style(display, colors), use_container_width=True)
+ st.download_button(
+ "Download CSV",
+ df.to_csv().encode(),
+ file_name=f"{ticker.upper()}_balance_{'quarterly' if quarterly else 'annual'}.csv",
+ mime="text/csv",
+ key=f"dl_balance_{ticker}_{quarterly}",
+ )
with tab_cashflow:
df = get_cash_flow(ticker, quarterly=quarterly)
@@ -132,3 +146,10 @@ def render_financials(ticker: str):
else:
display, colors = _build_statement(df)
st.dataframe(_style(display, colors), use_container_width=True)
+ st.download_button(
+ "Download CSV",
+ df.to_csv().encode(),
+ file_name=f"{ticker.upper()}_cashflow_{'quarterly' if quarterly else 'annual'}.csv",
+ mime="text/csv",
+ key=f"dl_cashflow_{ticker}_{quarterly}",
+ )
diff --git a/components/options.py b/components/options.py
new file mode 100644
index 0000000..e9bf016
--- /dev/null
+++ b/components/options.py
@@ -0,0 +1,200 @@
+"""Options flow — put/call ratios, IV smile, open interest by strike."""
+import pandas as pd
+import plotly.graph_objects as go
+import streamlit as st
+from services.data_service import get_company_info, get_options_chain
+
+
+def render_options(ticker: str):
+ info = get_company_info(ticker)
+ current_price = info.get("currentPrice") or info.get("regularMarketPrice")
+
+ with st.spinner("Loading options data…"):
+ data = get_options_chain(ticker)
+
+ if not data or not data.get("chains"):
+ st.info("Options data unavailable for this ticker.")
+ return
+
+ expirations = data["expirations"]
+ chains = data["chains"]
+
+ # ── Expiry selector ──────────────────────────────────────────────────────
+ selected_expiry = st.selectbox(
+ "Expiration date",
+ options=expirations,
+ key=f"options_expiry_{ticker}",
+ )
+
+ chain_data = next((c for c in chains if c["expiry"] == selected_expiry), None)
+ if chain_data is None:
+ # Expiry beyond the pre-fetched set — fetch on demand
+ try:
+ import yfinance as yf
+ t = yf.Ticker(ticker.upper())
+ chain = t.option_chain(selected_expiry)
+ chain_data = {"expiry": selected_expiry, "calls": chain.calls, "puts": chain.puts}
+ except Exception:
+ st.info("Could not load chain for this expiry.")
+ return
+
+ calls: pd.DataFrame = chain_data["calls"].copy()
+ puts: pd.DataFrame = chain_data["puts"].copy()
+
+ # ── Summary metrics ──────────────────────────────────────────────────────
+ total_call_vol = float(calls["volume"].sum()) if "volume" in calls.columns else 0.0
+ total_put_vol = float(puts["volume"].sum()) if "volume" in puts.columns else 0.0
+ total_call_oi = float(calls["openInterest"].sum()) if "openInterest" in calls.columns else 0.0
+ total_put_oi = float(puts["openInterest"].sum()) if "openInterest" in puts.columns else 0.0
+
+ pc_vol = total_put_vol / total_call_vol if total_call_vol > 0 else None
+ pc_oi = total_put_oi / total_call_oi if total_call_oi > 0 else None
+
+ def _pc_delta(val):
+ if val is None:
+ return None
+ if val < 0.7:
+ return "Bullish"
+ if val < 1.0:
+ return "Neutral"
+ return "Bearish"
+
+ m1, m2, m3, m4 = st.columns(4)
+ m1.metric(
+ "P/C Ratio (Volume)",
+ f"{pc_vol:.2f}" if pc_vol is not None else "—",
+ delta=_pc_delta(pc_vol),
+ delta_color="inverse" if pc_vol and pc_vol >= 1.0 else "normal",
+ help="Put/Call volume ratio. >1 = more put activity (bearish bets).",
+ )
+ m2.metric(
+ "P/C Ratio (OI)",
+ f"{pc_oi:.2f}" if pc_oi is not None else "—",
+ delta=_pc_delta(pc_oi),
+ delta_color="inverse" if pc_oi and pc_oi >= 1.0 else "normal",
+ help="Put/Call open interest ratio.",
+ )
+ m3.metric("Total Call Volume", f"{int(total_call_vol):,}" if total_call_vol else "—")
+ m4.metric("Total Put Volume", f"{int(total_put_vol):,}" if total_put_vol else "—")
+
+ st.write("")
+
+ # Filter strikes ±30% of current price for cleaner charts
+ if current_price and not calls.empty:
+ lo, hi = current_price * 0.70, current_price * 1.30
+ calls_atm = calls[(calls["strike"] >= lo) & (calls["strike"] <= hi)]
+ puts_atm = puts[(puts["strike"] >= lo) & (puts["strike"] <= hi)]
+ else:
+ calls_atm = calls
+ puts_atm = puts
+
+ if calls_atm.empty and puts_atm.empty:
+ st.info("No near-the-money options found for this expiry.")
+ return
+
+ chart_col1, chart_col2 = st.columns(2)
+
+ # ── IV Smile ─────────────────────────────────────────────────────────────
+ with chart_col1:
+ if "impliedVolatility" in calls_atm.columns:
+ st.markdown("**Implied Volatility Smile**")
+ fig_iv = go.Figure()
+ fig_iv.add_trace(go.Scatter(
+ x=calls_atm["strike"],
+ y=calls_atm["impliedVolatility"] * 100,
+ name="Calls IV",
+ mode="lines+markers",
+ line=dict(color="#4F8EF7", width=2),
+ marker=dict(size=4),
+ ))
+ if not puts_atm.empty and "impliedVolatility" in puts_atm.columns:
+ fig_iv.add_trace(go.Scatter(
+ x=puts_atm["strike"],
+ y=puts_atm["impliedVolatility"] * 100,
+ name="Puts IV",
+ mode="lines+markers",
+ line=dict(color="#F7A24F", width=2),
+ marker=dict(size=4),
+ ))
+ if current_price:
+ fig_iv.add_vline(
+ x=current_price,
+ line_dash="dash",
+ line_color="rgba(255,255,255,0.35)",
+ annotation_text="ATM",
+ annotation_position="top",
+ )
+ fig_iv.update_layout(
+ yaxis_title="Implied Volatility (%)",
+ xaxis_title="Strike",
+ plot_bgcolor="rgba(0,0,0,0)",
+ paper_bgcolor="rgba(0,0,0,0)",
+ margin=dict(l=0, r=0, t=10, b=0),
+ height=300,
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ hovermode="x unified",
+ )
+ st.plotly_chart(fig_iv, use_container_width=True)
+
+ # ── Open Interest by strike ───────────────────────────────────────────────
+ with chart_col2:
+ if "openInterest" in calls_atm.columns:
+ st.markdown("**Open Interest by Strike**")
+ fig_oi = go.Figure()
+ fig_oi.add_trace(go.Bar(
+ x=calls_atm["strike"],
+ y=calls_atm["openInterest"].fillna(0),
+ name="Calls OI",
+ marker_color="rgba(79,142,247,0.75)",
+ ))
+ if not puts_atm.empty and "openInterest" in puts_atm.columns:
+ fig_oi.add_trace(go.Bar(
+ x=puts_atm["strike"],
+ y=-puts_atm["openInterest"].fillna(0),
+ name="Puts OI",
+ marker_color="rgba(247,162,79,0.75)",
+ ))
+ if current_price:
+ fig_oi.add_vline(
+ x=current_price,
+ line_dash="dash",
+ line_color="rgba(255,255,255,0.35)",
+ annotation_text="ATM",
+ annotation_position="top",
+ )
+ fig_oi.update_layout(
+ barmode="overlay",
+ yaxis_title="Open Interest (puts mirrored)",
+ xaxis_title="Strike",
+ plot_bgcolor="rgba(0,0,0,0)",
+ paper_bgcolor="rgba(0,0,0,0)",
+ margin=dict(l=0, r=0, t=10, b=0),
+ height=300,
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ )
+ st.plotly_chart(fig_oi, use_container_width=True)
+
+ # ── Raw chain table ───────────────────────────────────────────────────────
+ with st.expander("Full options chain"):
+ tab_calls, tab_puts = st.tabs(["Calls", "Puts"])
+ display_cols = ["strike", "lastPrice", "bid", "ask", "volume", "openInterest", "impliedVolatility"]
+
+ with tab_calls:
+ show_cols = [c for c in display_cols if c in calls.columns]
+ if show_cols:
+ df_show = calls[show_cols].copy()
+ if "impliedVolatility" in df_show.columns:
+ df_show["impliedVolatility"] = df_show["impliedVolatility"].apply(
+ lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—"
+ )
+ st.dataframe(df_show, use_container_width=True, hide_index=True)
+
+ with tab_puts:
+ show_cols = [c for c in display_cols if c in puts.columns]
+ if show_cols:
+ df_show = puts[show_cols].copy()
+ if "impliedVolatility" in df_show.columns:
+ df_show["impliedVolatility"] = df_show["impliedVolatility"].apply(
+ lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—"
+ )
+ st.dataframe(df_show, use_container_width=True, hide_index=True)
diff --git a/components/overview.py b/components/overview.py
index 7407753..53b8554 100644
--- a/components/overview.py
+++ b/components/overview.py
@@ -1,4 +1,4 @@
-"""Company overview — header, key stats, and price chart."""
+"""Company overview — score card, key stats, 52W range, short interest, price chart."""
import streamlit as st
import plotly.graph_objects as go
from services.data_service import get_company_info, get_price_history
@@ -8,23 +8,202 @@ 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"}
+# ── Score card ───────────────────────────────────────────────────────────────
+
+def _score_card(info: dict) -> None:
+ """Render a row of green/yellow/red signal badges."""
+ signals: list[tuple[str, str, str, str]] = [] # (label, color, value, description)
+
+ # Valuation — trailing P/E
+ pe = info.get("trailingPE")
+ if pe and pe > 0:
+ if pe < 15:
+ signals.append(("Valuation", "green", f"P/E {pe:.1f}x", "Attractively valued"))
+ elif pe < 30:
+ signals.append(("Valuation", "yellow", f"P/E {pe:.1f}x", "Fairly valued"))
+ else:
+ signals.append(("Valuation", "red", f"P/E {pe:.1f}x", "Richly valued"))
+ else:
+ signals.append(("Valuation", "neutral", "P/E N/A", "No trailing earnings"))
+
+ # Revenue growth (TTM YoY)
+ rev_growth = info.get("revenueGrowth")
+ if rev_growth is not None:
+ if rev_growth > 0.10:
+ signals.append(("Growth", "green", f"{rev_growth*100:+.0f}% rev", "Strong growth"))
+ elif rev_growth >= 0:
+ signals.append(("Growth", "yellow", f"{rev_growth*100:+.0f}% rev", "Slow growth"))
+ else:
+ signals.append(("Growth", "red", f"{rev_growth*100:+.0f}% rev", "Declining revenue"))
+
+ # Profitability — net margin
+ margin = info.get("profitMargins")
+ if margin is not None:
+ if margin > 0.15:
+ signals.append(("Profit", "green", f"{margin*100:.0f}% margin", "High margins"))
+ elif margin > 0.05:
+ signals.append(("Profit", "yellow", f"{margin*100:.0f}% margin", "Moderate margins"))
+ else:
+ signals.append(("Profit", "red", f"{margin*100:.0f}% margin", "Thin/negative margins"))
+
+ # Leverage — D/E (yfinance returns as %, e.g. 162 = 1.62x)
+ de = info.get("debtToEquity")
+ if de is not None:
+ de_x = de / 100
+ if de_x < 0.5:
+ signals.append(("Leverage", "green", f"D/E {de_x:.2f}x", "Low leverage"))
+ elif de_x < 2.0:
+ signals.append(("Leverage", "yellow", f"D/E {de_x:.2f}x", "Moderate leverage"))
+ else:
+ signals.append(("Leverage", "red", f"D/E {de_x:.2f}x", "High leverage"))
+
+ # Momentum — price vs 52W high
+ price = info.get("currentPrice") or info.get("regularMarketPrice")
+ high52 = info.get("fiftyTwoWeekHigh")
+ if price and high52 and high52 > 0:
+ from_high_pct = (price - high52) / high52 * 100
+ if from_high_pct > -10:
+ signals.append(("Momentum", "green", f"{from_high_pct:.0f}% from 52W↑", "Near highs"))
+ elif from_high_pct > -25:
+ signals.append(("Momentum", "yellow", f"{from_high_pct:.0f}% from 52W↑", "Mid-range"))
+ else:
+ signals.append(("Momentum", "red", f"{from_high_pct:.0f}% from 52W↑", "Far from highs"))
+
+ # Short interest
+ short_pct = info.get("shortPercentOfFloat")
+ if short_pct is not None:
+ if short_pct < 0.05:
+ signals.append(("Short Int.", "green", f"{short_pct*100:.1f}% float", "Low short interest"))
+ elif short_pct < 0.15:
+ signals.append(("Short Int.", "yellow", f"{short_pct*100:.1f}% float", "Moderate short interest"))
+ else:
+ signals.append(("Short Int.", "red", f"{short_pct*100:.1f}% float", "High short interest"))
+
+ if not signals:
+ return
+
+ color_map = {
+ "green": ("rgba(46,204,113,0.15)", "#7ce3a1"),
+ "yellow": ("rgba(243,156,18,0.15)", "#f0c040"),
+ "red": ("rgba(231,76,60,0.15)", "#ff8a8a"),
+ "neutral": ("rgba(255,255,255,0.05)", "#9aa0b0"),
+ }
+
+ cards_html = ""
+ for label, color, value, desc in signals:
+ bg, fg = color_map[color]
+ cards_html += (
+ f'<div style="background:{bg};border:1px solid {fg}44;border-radius:10px;'
+ f'padding:0.5rem 0.75rem;flex:1;min-width:110px;">'
+ f'<div style="font-size:0.68rem;font-weight:600;color:#9aa0b0;text-transform:uppercase;'
+ f'letter-spacing:0.05em;margin-bottom:0.15rem;">{label}</div>'
+ f'<div style="font-size:0.85rem;font-weight:700;color:{fg};">{value}</div>'
+ f'<div style="font-size:0.68rem;color:#9aa0b0;margin-top:0.1rem;">{desc}</div>'
+ f'</div>'
+ )
+
+ st.markdown(
+ f'<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap;">{cards_html}</div>',
+ unsafe_allow_html=True,
+ )
+
+
+# ── 52-week range bar ────────────────────────────────────────────────────────
+
+def _render_52w_bar(info: dict) -> None:
+ low = info.get("fiftyTwoWeekLow")
+ high = info.get("fiftyTwoWeekHigh")
+ price = info.get("currentPrice") or info.get("regularMarketPrice")
+
+ if not (low and high and price and high > low):
+ return
+
+ pct = max(0.0, min(100.0, (price - low) / (high - low) * 100))
+ from_low_pct = (price - low) / low * 100
+ to_high_pct = (high - price) / price * 100
+
+ st.markdown(
+ f"""
+ <div style="margin:0.4rem 0 0.9rem 0;">
+ <div style="display:flex;justify-content:space-between;font-size:0.72rem;color:#9aa0b0;margin-bottom:0.35rem;">
+ <span>52W Low: {fmt_currency(low)}</span>
+ <span style="color:#c6cfdd;font-weight:600;">
+ {fmt_currency(price)} &nbsp;·&nbsp; {pct:.0f}% of range
+ </span>
+ <span>52W High: {fmt_currency(high)}</span>
+ </div>
+ <div style="position:relative;height:7px;background:rgba(255,255,255,0.08);border-radius:4px;overflow:visible;">
+ <div style="position:absolute;left:0;width:{pct}%;height:100%;
+ background:linear-gradient(to right,#e74c3c,#f0b27a,#2ecc71);border-radius:4px;"></div>
+ <div style="position:absolute;left:calc({pct}% - 2px);top:-4px;width:4px;height:15px;
+ background:#fff;border-radius:2px;box-shadow:0 0 5px rgba(0,0,0,0.5);"></div>
+ </div>
+ <div style="display:flex;justify-content:space-between;font-size:0.68rem;color:#9aa0b0;margin-top:0.3rem;">
+ <span>+{from_low_pct:.1f}% above low</span>
+ <span>{to_high_pct:.1f}% below high</span>
+ </div>
+ </div>
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+# ── Short interest strip ─────────────────────────────────────────────────────
+
+def _render_short_interest(info: dict) -> None:
+ short_pct = info.get("shortPercentOfFloat")
+ short_ratio = info.get("shortRatio")
+ shares_short = info.get("sharesShort")
+ shares_short_prior = info.get("sharesShortPriorMonth")
+
+ if not any([short_pct, short_ratio, shares_short]):
+ return
+
+ st.markdown("**Short Interest**")
+ cols = st.columns(4)
+
+ cols[0].metric(
+ "Short % of Float",
+ f"{short_pct * 100:.2f}%" if short_pct is not None else "—",
+ )
+ cols[1].metric(
+ "Days to Cover",
+ f"{short_ratio:.1f}" if short_ratio is not None else "—",
+ help="Shares short ÷ avg daily volume. Higher = harder to unwind.",
+ )
+ cols[2].metric(
+ "Shares Short",
+ fmt_large(shares_short) if shares_short else "—",
+ )
+ if shares_short and shares_short_prior:
+ chg = (shares_short - shares_short_prior) / shares_short_prior * 100
+ cols[3].metric(
+ "vs Prior Month",
+ fmt_large(shares_short_prior),
+ delta=f"{chg:+.1f}%",
+ )
+ else:
+ cols[3].metric("Prior Month", fmt_large(shares_short_prior) if shares_short_prior else "—")
+
+
+# ── Main render ──────────────────────────────────────────────────────────────
+
def render_overview(ticker: str):
info = get_company_info(ticker)
if not info:
st.error(f"Could not load data for **{ticker}**. Check the ticker symbol.")
return
- # ── Company header ──────────────────────────────────────────────────────
name = info.get("longName") or info.get("shortName", ticker.upper())
price = info.get("currentPrice") or info.get("regularMarketPrice")
prev_close = info.get("regularMarketPreviousClose") or info.get("previousClose")
- price_change = None
- price_change_pct = None
+ price_change = price_change_pct = None
if price and prev_close:
price_change = price - prev_close
price_change_pct = price_change / prev_close
+ # ── Header ──────────────────────────────────────────────────────────────
col1, col2 = st.columns([3, 1])
with col1:
st.subheader(f"{name} ({ticker.upper()})")
@@ -32,7 +211,6 @@ def render_overview(ticker: str):
industry = info.get("industry", "")
if sector:
st.caption(f"{sector} · {industry}")
-
with col2:
delta_str = None
if price_change is not None and price_change_pct is not None:
@@ -43,19 +221,30 @@ def render_overview(ticker: str):
delta=delta_str,
)
+ # ── Score card ──────────────────────────────────────────────────────────
+ _score_card(info)
+
# ── Key stats strip ─────────────────────────────────────────────────────
stats_cols = st.columns(6)
stats = [
- ("Mkt Cap", fmt_large(info.get("marketCap"))),
- ("P/E (TTM)", fmt_ratio(info.get("trailingPE"))),
- ("EPS (TTM)", fmt_currency(info.get("trailingEps"))),
- ("52W High", fmt_currency(info.get("fiftyTwoWeekHigh"))),
- ("52W Low", fmt_currency(info.get("fiftyTwoWeekLow"))),
- ("Beta", fmt_ratio(info.get("beta"))),
+ ("Mkt Cap", fmt_large(info.get("marketCap"))),
+ ("P/E (TTM)", fmt_ratio(info.get("trailingPE"))),
+ ("EPS (TTM)", fmt_currency(info.get("trailingEps"))),
+ ("Volume", fmt_large(info.get("volume"))),
+ ("Avg Volume", fmt_large(info.get("averageVolume"))),
+ ("Beta", fmt_ratio(info.get("beta"))),
]
for col, (label, val) in zip(stats_cols, stats):
col.metric(label, val)
+ st.write("")
+
+ # ── 52-week range bar ────────────────────────────────────────────────────
+ _render_52w_bar(info)
+
+ # ── Short interest ───────────────────────────────────────────────────────
+ _render_short_interest(info)
+
st.divider()
# ── Price chart ─────────────────────────────────────────────────────────
@@ -74,17 +263,15 @@ def render_overview(ticker: str):
return
fig = go.Figure()
- fig.add_trace(
- go.Scatter(
- x=hist.index,
- y=hist["Close"],
- mode="lines",
- name="Close",
- line=dict(color="#4F8EF7", width=2),
- fill="tozeroy",
- fillcolor="rgba(79, 142, 247, 0.08)",
- )
- )
+ fig.add_trace(go.Scatter(
+ x=hist.index,
+ y=hist["Close"],
+ mode="lines",
+ name="Close",
+ line=dict(color="#4F8EF7", width=2),
+ fill="tozeroy",
+ fillcolor="rgba(79,142,247,0.08)",
+ ))
fig.update_layout(
margin=dict(l=0, r=0, t=10, b=0),
xaxis=dict(showgrid=False, zeroline=False),
diff --git a/components/valuation.py b/components/valuation.py
index 5536818..b6e9d46 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -567,6 +567,13 @@ def _render_earnings_history(ticker: str):
use_container_width=True,
hide_index=False,
)
+ st.download_button(
+ "Download CSV",
+ display.to_csv().encode(),
+ file_name=f"{ticker.upper()}_earnings_history.csv",
+ mime="text/csv",
+ key=f"dl_earnings_{ticker}",
+ )
# EPS chart — oldest to newest
df_chart = eh.sort_index()