import os import json from datetime import datetime import numpy as np import pandas as pd import plotly.express as px import requests import streamlit as st import yfinance as yf st.set_page_config( page_title="AI Stock Intelligence Dashboard", page_icon="📈", layout="wide", initial_sidebar_state="expanded", ) DEFAULT_TICKERS = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"] DEFAULT_MODEL = "openai/gpt-4o-mini" OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_API_KEY = "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" CUSTOM_CSS = """ """ st.markdown(CUSTOM_CSS, unsafe_allow_html=True) def format_pct(value: float) -> str: if pd.isna(value): return "N/A" return f"{value:.2f}%" @st.cache_data(ttl=1800, show_spinner=False) def load_market_data(tickers: list[str], period: str = "3mo") -> pd.DataFrame: df = yf.download( tickers=tickers, period=period, interval="1d", group_by="ticker", auto_adjust=True, threads=True, progress=False, ) return df @st.cache_data(ttl=1800, show_spinner=False) def build_ticker_frame(raw: pd.DataFrame, tickers: list[str]) -> pd.DataFrame: rows = [] if raw.empty: return pd.DataFrame() multi = isinstance(raw.columns, pd.MultiIndex) for ticker in tickers: try: if multi: tdf = raw[ticker].copy() else: tdf = raw.copy() except Exception: continue if tdf.empty or "Close" not in tdf.columns: continue tdf = tdf.dropna(subset=["Close"]).copy() if tdf.empty: continue tdf["Ticker"] = ticker tdf["Daily Return"] = tdf["Close"].pct_change() tdf["Cumulative Return %"] = (tdf["Close"] / tdf["Close"].iloc[0] - 1) * 100 tdf["20D MA"] = tdf["Close"].rolling(20).mean() tdf["Volatility 20D %"] = tdf["Daily Return"].rolling(20).std() * np.sqrt(252) * 100 tdf = tdf.reset_index() rows.append(tdf) if not rows: return pd.DataFrame() out = pd.concat(rows, ignore_index=True) out.rename(columns={out.columns[0]: "Date"}, inplace=True) return out def build_summary_table(data: pd.DataFrame) -> pd.DataFrame: summary_rows = [] for ticker in sorted(data["Ticker"].unique()): tdf = data[data["Ticker"] == ticker].sort_values("Date").copy() if tdf.empty: continue start_close = tdf["Close"].iloc[0] end_close = tdf["Close"].iloc[-1] high = tdf["Close"].max() low = tdf["Close"].min() avg_volume = tdf["Volume"].mean() if "Volume" in tdf.columns else np.nan ret = (end_close / start_close - 1) * 100 vol = tdf["Daily Return"].std() * np.sqrt(252) * 100 latest_ma20 = tdf["20D MA"].iloc[-1] dist_to_ma20 = ((end_close / latest_ma20) - 1) * 100 if pd.notna(latest_ma20) and latest_ma20 != 0 else np.nan summary_rows.append( { "Ticker": ticker, "Start Price": round(float(start_close), 2), "Latest Price": round(float(end_close), 2), "3M Return %": round(float(ret), 2), "3M High": round(float(high), 2), "3M Low": round(float(low), 2), "Annualized Volatility %": round(float(vol), 2), "Distance vs 20D MA %": round(float(dist_to_ma20), 2) if pd.notna(dist_to_ma20) else np.nan, "Average Volume": int(avg_volume) if pd.notna(avg_volume) else None, } ) summary = pd.DataFrame(summary_rows) if not summary.empty: summary = summary.sort_values("3M Return %", ascending=False).reset_index(drop=True) return summary def create_ai_prompt(summary: pd.DataFrame, selected_tickers: list[str]) -> str: compact = summary.to_dict(orient="records") return f""" You are a buy-side style equity research assistant. Analyze the last 3 months of market performance for these tickers: {', '.join(selected_tickers)}. Use the structured performance summary below and write a concise but insight-dense analysis. Data summary: {json.dumps(compact, indent=2)} Return your answer in markdown with these sections: 1. Executive Summary 2. Relative Winners and Laggards 3. Volatility and Risk Notes 4. Technical Observations 5. What to Watch Next 6. Bottom Line Requirements: - Be analytical, specific, and readable for an investor audience. - Mention relative performance differences across the tickers. - Do not claim certainty or provide personalized investment advice. - If the data suggests mixed signals, say so. """.strip() def query_openrouter(api_key: str, model: str, prompt: str, referer: str = "http://localhost:8501", title: str = "AI Stock Intelligence Dashboard") -> str: headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": referer, "X-Title": title, } payload = { "model": model, "messages": [ {"role": "system", "content": "You are a rigorous financial analysis assistant."}, {"role": "user", "content": prompt}, ], "temperature": 0.4, } response = requests.post(OPENROUTER_URL, headers=headers, json=payload, timeout=60) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"] st.markdown( """
📈 AI Stock Intelligence Dashboard
Pull live 3-month market data for up to 5 stock tickers, visualize performance, and generate AI-driven analysis through OpenRouter.
""", unsafe_allow_html=True, ) with st.sidebar: st.header("Configuration") tickers_input = st.text_input( "Stock tickers", value=", ".join(DEFAULT_TICKERS), help="Enter up to 5 tickers separated by commas, e.g. AAPL, MSFT, NVDA, AMZN, GOOGL", ) tickers = [t.strip().upper() for t in tickers_input.split(",") if t.strip()] tickers = list(dict.fromkeys(tickers))[:5] st.markdown("---") st.subheader("AI Settings") api_key = OPENROUTER_API_KEY model = st.text_input( "OpenRouter model", value=os.getenv("OPENROUTER_MODEL", DEFAULT_MODEL), help="Example: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet, google/gemini-2.5-pro-preview", ) referer = st.text_input( "App URL / Referer", value=os.getenv("APP_REFERER", "http://localhost:8501"), help="Optional but useful for OpenRouter ranking and attribution headers.", ) app_title = st.text_input( "App title header", value="AI Stock Intelligence Dashboard", ) st.markdown("---") generate_ai = st.button("Generate AI Analysis", use_container_width=True, type="primary") if not tickers: st.warning("Please enter at least one stock ticker.") st.stop() with st.spinner("Loading live market data..."): raw_data = load_market_data(tickers) ticker_data = build_ticker_frame(raw_data, tickers) if ticker_data.empty: st.error("No market data was returned. Double-check the ticker symbols and try again.") st.stop() summary_df = build_summary_table(ticker_data) latest_date = ticker_data["Date"].max() leader = summary_df.iloc[0] if not summary_df.empty else None laggard = summary_df.iloc[-1] if not summary_df.empty else None avg_return = summary_df["3M Return %"].mean() if not summary_df.empty else np.nan avg_vol = summary_df["Annualized Volatility %"].mean() if not summary_df.empty else np.nan m1, m2, m3, m4 = st.columns(4) with m1: st.markdown('
', unsafe_allow_html=True) st.metric("Tracking", f"{len(summary_df)} tickers") st.caption(f"As of {pd.to_datetime(latest_date).strftime('%b %d, %Y')}") st.markdown("
", unsafe_allow_html=True) with m2: st.markdown('
', unsafe_allow_html=True) st.metric("Average 3M Return", format_pct(avg_return)) st.caption("Cross-ticker average") st.markdown("
", unsafe_allow_html=True) with m3: st.markdown('
', unsafe_allow_html=True) st.metric("Leader", leader["Ticker"] if leader is not None else "N/A", format_pct(leader["3M Return %"]) if leader is not None else "N/A") st.caption("Best 3-month performer") st.markdown("
", unsafe_allow_html=True) with m4: st.markdown('
', unsafe_allow_html=True) st.metric("Average Volatility", format_pct(avg_vol)) st.caption("Annualized estimate") st.markdown("
", unsafe_allow_html=True) tab1, tab2, tab3, tab4 = st.tabs(["Performance", "Technical View", "Data Table", "AI Analysis"]) with tab1: st.markdown('
Normalized Price Performance
', unsafe_allow_html=True) indexed = ticker_data.copy() indexed["Indexed Price"] = indexed.groupby("Ticker")["Close"].transform(lambda s: s / s.iloc[0] * 100) fig_perf = px.line( indexed, x="Date", y="Indexed Price", color="Ticker", markers=False, template="plotly_white", title="3-Month Relative Performance (Start = 100)", ) fig_perf.update_layout(height=480, legend_title_text="Ticker") st.plotly_chart(fig_perf, use_container_width=True) c1, c2 = st.columns([1.2, 1]) with c1: st.markdown('
3-Month Return Ranking
', unsafe_allow_html=True) rank_fig = px.bar( summary_df, x="Ticker", y="3M Return %", text="3M Return %", template="plotly_white", title="Return Comparison", ) rank_fig.update_traces(texttemplate="%{text:.2f}%", textposition="outside") rank_fig.update_layout(height=420) st.plotly_chart(rank_fig, use_container_width=True) with c2: st.markdown('
Snapshot
', unsafe_allow_html=True) st.dataframe( summary_df[["Ticker", "Latest Price", "3M Return %", "Annualized Volatility %", "Distance vs 20D MA %"]], use_container_width=True, hide_index=True, ) with tab2: st.markdown('
Price vs 20-Day Moving Average
', unsafe_allow_html=True) focus_ticker = st.selectbox("Focus ticker", options=summary_df["Ticker"].tolist(), index=0) focus_df = ticker_data[ticker_data["Ticker"] == focus_ticker].sort_values("Date") ma_frame = focus_df[["Date", "Close", "20D MA"]].melt(id_vars="Date", var_name="Series", value_name="Value") ma_fig = px.line( ma_frame, x="Date", y="Value", color="Series", template="plotly_white", title=f"{focus_ticker}: Price and 20-Day Moving Average", ) ma_fig.update_layout(height=450) st.plotly_chart(ma_fig, use_container_width=True) vol_fig = px.line( focus_df, x="Date", y="Volatility 20D %", template="plotly_white", title=f"{focus_ticker}: Rolling 20-Day Annualized Volatility", ) vol_fig.update_layout(height=360) st.plotly_chart(vol_fig, use_container_width=True) with tab3: st.markdown('
Detailed Summary Table
', unsafe_allow_html=True) styled_table = summary_df.style.format( { "Start Price": "{:.2f}", "Latest Price": "{:.2f}", "3M Return %": "{:.2f}", "3M High": "{:.2f}", "3M Low": "{:.2f}", "Annualized Volatility %": "{:.2f}", "Distance vs 20D MA %": "{:.2f}", } ) st.dataframe(styled_table, use_container_width=True, hide_index=True) csv = summary_df.to_csv(index=False).encode("utf-8") st.download_button( "Download summary CSV", data=csv, file_name=f"stock_summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", mime="text/csv", ) with tab4: st.markdown('
AI Narrative
', unsafe_allow_html=True) st.caption("Uses OpenRouter to convert the performance summary into an investor-style market readout.") prompt = create_ai_prompt(summary_df, tickers) with st.expander("Show prompt sent to the model"): st.code(prompt, language="markdown") if generate_ai: if not api_key: st.warning("Enter an OpenRouter API key in the sidebar to generate the analysis.") else: try: with st.spinner("Generating AI analysis..."): analysis_md = query_openrouter( api_key=api_key, model=model, prompt=prompt, referer=referer, title=app_title, ) st.success("Analysis generated successfully.") st.markdown(analysis_md) except requests.HTTPError as exc: status = exc.response.status_code if exc.response is not None else "unknown" body = exc.response.text if exc.response is not None else str(exc) st.error(f"OpenRouter request failed (status {status}).") st.code(body) except Exception as exc: st.error(f"Unexpected error: {exc}") else: st.info("Click **Generate AI Analysis** to create a narrative summary.") st.markdown("---") st.caption( "Data source: Yahoo Finance via yfinance. AI analysis is generated from summary statistics and should be used for research support, not personalized investment advice." )