stockView / src /streamlit_app.py
hfariborzi's picture
Update src/streamlit_app.py
00001fe verified
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}%"
@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(
"""
<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."
)