Spaces:
Sleeping
Sleeping
| 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 = """ | |
| <style> | |
| .block-container { | |
| padding-top: 1.5rem; | |
| padding-bottom: 2rem; | |
| max-width: 1400px; | |
| } | |
| .hero-card { | |
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 35%, #2563eb 100%); | |
| border-radius: 24px; | |
| padding: 1.6rem 1.8rem; | |
| color: white; | |
| box-shadow: 0 20px 50px rgba(2, 6, 23, 0.25); | |
| margin-bottom: 1rem; | |
| } | |
| .hero-title { | |
| font-size: 2.15rem; | |
| font-weight: 700; | |
| margin-bottom: 0.3rem; | |
| } | |
| .hero-subtitle { | |
| font-size: 1rem; | |
| opacity: 0.92; | |
| } | |
| .metric-card { | |
| background: rgba(255,255,255,0.72); | |
| border: 1px solid rgba(148, 163, 184, 0.25); | |
| backdrop-filter: blur(10px); | |
| border-radius: 18px; | |
| padding: 1rem; | |
| box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); | |
| } | |
| .section-title { | |
| font-size: 1.1rem; | |
| font-weight: 700; | |
| margin: 0.5rem 0 0.75rem 0; | |
| } | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 8px; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| border-radius: 999px; | |
| padding: 10px 18px; | |
| background: #eef2ff; | |
| } | |
| </style> | |
| """ | |
| 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}%" | |
| 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 | |
| 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( | |
| """ | |
| <div class="hero-card"> | |
| <div class="hero-title">📈 AI Stock Intelligence Dashboard</div> | |
| <div class="hero-subtitle"> | |
| Pull live 3-month market data for up to 5 stock tickers, visualize performance, and generate AI-driven analysis through OpenRouter. | |
| </div> | |
| </div> | |
| """, | |
| 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('<div class="metric-card">', 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("</div>", unsafe_allow_html=True) | |
| with m2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Average 3M Return", format_pct(avg_return)) | |
| st.caption("Cross-ticker average") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with m3: | |
| st.markdown('<div class="metric-card">', 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("</div>", unsafe_allow_html=True) | |
| with m4: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Average Volatility", format_pct(avg_vol)) | |
| st.caption("Annualized estimate") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| tab1, tab2, tab3, tab4 = st.tabs(["Performance", "Technical View", "Data Table", "AI Analysis"]) | |
| with tab1: | |
| st.markdown('<div class="section-title">Normalized Price Performance</div>', 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('<div class="section-title">3-Month Return Ranking</div>', 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('<div class="section-title">Snapshot</div>', 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('<div class="section-title">Price vs 20-Day Moving Average</div>', 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('<div class="section-title">Detailed Summary Table</div>', 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('<div class="section-title">AI Narrative</div>', 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." | |
| ) | |