diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/financials.py | 21 | ||||
| -rw-r--r-- | components/options.py | 200 | ||||
| -rw-r--r-- | components/overview.py | 231 | ||||
| -rw-r--r-- | components/valuation.py | 7 |
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)} · {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() |
