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."
)