71803418e5
Shelly device management app with mDNS/subnet discovery, inventory, configuration, and mass operations for Gen1/Gen2+ devices. Includes .gitignore excluding runtime data (device DB, user config), AI conversation history, build artifacts, and common Python/OS patterns.
343 lines
7.0 KiB
Python
343 lines
7.0 KiB
Python
import streamlit as st
|
|
import yfinance as yf
|
|
import pandas as pd
|
|
import altair as alt
|
|
|
|
st.set_page_config(
|
|
page_title="Stock peer analysis dashboard",
|
|
page_icon=":chart_with_upwards_trend:",
|
|
layout="wide",
|
|
)
|
|
|
|
"""
|
|
# :material/query_stats: Stock peer analysis
|
|
|
|
Easily compare stocks against others in their peer group.
|
|
"""
|
|
|
|
"" # Add some space.
|
|
|
|
cols = st.columns([1, 3])
|
|
# Will declare right cell later to avoid showing it when no data.
|
|
|
|
STOCKS = [
|
|
"AAPL",
|
|
"ABBV",
|
|
"ACN",
|
|
"ADBE",
|
|
"ADP",
|
|
"AMD",
|
|
"AMGN",
|
|
"AMT",
|
|
"AMZN",
|
|
"APD",
|
|
"AVGO",
|
|
"AXP",
|
|
"BA",
|
|
"BK",
|
|
"BKNG",
|
|
"BMY",
|
|
"BRK.B",
|
|
"BSX",
|
|
"C",
|
|
"CAT",
|
|
"CI",
|
|
"CL",
|
|
"CMCSA",
|
|
"COST",
|
|
"CRM",
|
|
"CSCO",
|
|
"CVX",
|
|
"DE",
|
|
"DHR",
|
|
"DIS",
|
|
"DUK",
|
|
"ELV",
|
|
"EOG",
|
|
"EQR",
|
|
"FDX",
|
|
"GD",
|
|
"GE",
|
|
"GILD",
|
|
"GOOG",
|
|
"GOOGL",
|
|
"HD",
|
|
"HON",
|
|
"HUM",
|
|
"IBM",
|
|
"ICE",
|
|
"INTC",
|
|
"ISRG",
|
|
"JNJ",
|
|
"JPM",
|
|
"KO",
|
|
"LIN",
|
|
"LLY",
|
|
"LMT",
|
|
"LOW",
|
|
"MA",
|
|
"MCD",
|
|
"MDLZ",
|
|
"META",
|
|
"MMC",
|
|
"MO",
|
|
"MRK",
|
|
"MSFT",
|
|
"NEE",
|
|
"NFLX",
|
|
"NKE",
|
|
"NOW",
|
|
"NVDA",
|
|
"ORCL",
|
|
"PEP",
|
|
"PFE",
|
|
"PG",
|
|
"PLD",
|
|
"PM",
|
|
"PSA",
|
|
"REGN",
|
|
"RTX",
|
|
"SBUX",
|
|
"SCHW",
|
|
"SLB",
|
|
"SO",
|
|
"SPGI",
|
|
"T",
|
|
"TJX",
|
|
"TMO",
|
|
"TSLA",
|
|
"TXN",
|
|
"UNH",
|
|
"UNP",
|
|
"UPS",
|
|
"V",
|
|
"VZ",
|
|
"WFC",
|
|
"WM",
|
|
"WMT",
|
|
"XOM",
|
|
]
|
|
|
|
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
|
|
|
|
|
|
def stocks_to_str(stocks):
|
|
return ",".join(stocks)
|
|
|
|
|
|
if "tickers_input" not in st.session_state:
|
|
st.session_state.tickers_input = st.query_params.get(
|
|
"stocks", stocks_to_str(DEFAULT_STOCKS)
|
|
).split(",")
|
|
|
|
|
|
# Callback to update query param when input changes
|
|
def update_query_param():
|
|
if st.session_state.tickers_input:
|
|
st.query_params["stocks"] = stocks_to_str(st.session_state.tickers_input)
|
|
else:
|
|
st.query_params.pop("stocks", None)
|
|
|
|
|
|
top_left_cell = cols[0].container(
|
|
border=True, height="stretch", vertical_alignment="center"
|
|
)
|
|
|
|
with top_left_cell:
|
|
# Selectbox for stock tickers
|
|
tickers = st.multiselect(
|
|
"Stock tickers",
|
|
options=sorted(set(STOCKS) | set(st.session_state.tickers_input)),
|
|
default=st.session_state.tickers_input,
|
|
placeholder="Choose stocks to compare. Example: NVDA",
|
|
accept_new_options=True,
|
|
)
|
|
|
|
# Time horizon selector
|
|
horizon_map = {
|
|
"1 Month": "1mo",
|
|
"3 Months": "3mo",
|
|
"6 Months": "6mo",
|
|
"1 Year": "1y",
|
|
"5 Years": "5y",
|
|
"10 Years": "10y",
|
|
"20 Years": "20y",
|
|
}
|
|
|
|
with top_left_cell:
|
|
# Buttons for picking time horizon
|
|
horizon = st.pills(
|
|
"Time horizon",
|
|
options=list(horizon_map.keys()),
|
|
default="6 Months",
|
|
)
|
|
|
|
tickers = [t.upper() for t in tickers]
|
|
|
|
# Update query param when text input changes
|
|
if tickers:
|
|
st.query_params["stocks"] = stocks_to_str(tickers)
|
|
else:
|
|
# Clear the param if input is empty
|
|
st.query_params.pop("stocks", None)
|
|
|
|
if not tickers:
|
|
top_left_cell.info("Pick some stocks to compare", icon=":material/info:")
|
|
st.stop()
|
|
|
|
|
|
right_cell = cols[1].container(
|
|
border=True, height="stretch", vertical_alignment="center"
|
|
)
|
|
|
|
|
|
@st.cache_resource(show_spinner=False, ttl="6h")
|
|
def load_data(tickers, period):
|
|
tickers_obj = yf.Tickers(tickers)
|
|
data = tickers_obj.history(period=period)
|
|
if data is None:
|
|
raise RuntimeError("YFinance returned no data.")
|
|
return data["Close"]
|
|
|
|
|
|
# Load the data
|
|
try:
|
|
data = load_data(tickers, horizon_map[horizon])
|
|
except yf.exceptions.YFRateLimitError as e:
|
|
st.warning("YFinance is rate-limiting us :(\nTry again later.")
|
|
load_data.clear() # Remove the bad cache entry.
|
|
st.stop()
|
|
|
|
empty_columns = data.columns[data.isna().all()].tolist()
|
|
|
|
if empty_columns:
|
|
st.error(f"Error loading data for the tickers: {', '.join(empty_columns)}.")
|
|
st.stop()
|
|
|
|
# Normalize prices (start at 1)
|
|
normalized = data.div(data.iloc[0])
|
|
|
|
latest_norm_values = {normalized[ticker].iat[-1]: ticker for ticker in tickers}
|
|
max_norm_value = max(latest_norm_values.items())
|
|
min_norm_value = min(latest_norm_values.items())
|
|
|
|
bottom_left_cell = cols[0].container(
|
|
border=True, height="stretch", vertical_alignment="center"
|
|
)
|
|
|
|
with bottom_left_cell:
|
|
cols = st.columns(2)
|
|
cols[0].metric(
|
|
"Best stock",
|
|
max_norm_value[1],
|
|
delta=f"{round(max_norm_value[0] * 100)}%",
|
|
width="content",
|
|
)
|
|
cols[1].metric(
|
|
"Worst stock",
|
|
min_norm_value[1],
|
|
delta=f"{round(min_norm_value[0] * 100)}%",
|
|
width="content",
|
|
)
|
|
|
|
|
|
# Plot normalized prices
|
|
with right_cell:
|
|
st.altair_chart(
|
|
alt.Chart(
|
|
normalized.reset_index().melt(
|
|
id_vars=["Date"], var_name="Stock", value_name="Normalized price"
|
|
)
|
|
)
|
|
.mark_line()
|
|
.encode(
|
|
alt.X("Date:T"),
|
|
alt.Y("Normalized price:Q").scale(zero=False),
|
|
alt.Color("Stock:N"),
|
|
)
|
|
.properties(height=400)
|
|
)
|
|
|
|
""
|
|
""
|
|
|
|
# Plot individual stock vs peer average
|
|
"""
|
|
## Individual stocks vs peer average
|
|
|
|
For the analysis below, the "peer average" when analyzing stock X always
|
|
excludes X itself.
|
|
"""
|
|
|
|
if len(tickers) <= 1:
|
|
st.warning("Pick 2 or more tickers to compare them")
|
|
st.stop()
|
|
|
|
NUM_COLS = 4
|
|
cols = st.columns(NUM_COLS)
|
|
|
|
for i, ticker in enumerate(tickers):
|
|
# Calculate peer average (excluding current stock)
|
|
peers = normalized.drop(columns=[ticker])
|
|
peer_avg = peers.mean(axis=1)
|
|
|
|
# Create DataFrame with peer average.
|
|
plot_data = pd.DataFrame(
|
|
{
|
|
"Date": normalized.index,
|
|
ticker: normalized[ticker],
|
|
"Peer average": peer_avg,
|
|
}
|
|
).melt(id_vars=["Date"], var_name="Series", value_name="Price")
|
|
|
|
chart = (
|
|
alt.Chart(plot_data)
|
|
.mark_line()
|
|
.encode(
|
|
alt.X("Date:T"),
|
|
alt.Y("Price:Q").scale(zero=False),
|
|
alt.Color(
|
|
"Series:N",
|
|
scale=alt.Scale(domain=[ticker, "Peer average"], range=["red", "gray"]),
|
|
legend=alt.Legend(orient="bottom"),
|
|
),
|
|
alt.Tooltip(["Date", "Series", "Price"]),
|
|
)
|
|
.properties(title=f"{ticker} vs peer average", height=300)
|
|
)
|
|
|
|
cell = cols[(i * 2) % NUM_COLS].container(border=True)
|
|
cell.write("")
|
|
cell.altair_chart(chart)
|
|
|
|
# Create Delta chart
|
|
plot_data = pd.DataFrame(
|
|
{
|
|
"Date": normalized.index,
|
|
"Delta": normalized[ticker] - peer_avg,
|
|
}
|
|
)
|
|
|
|
chart = (
|
|
alt.Chart(plot_data)
|
|
.mark_area()
|
|
.encode(
|
|
alt.X("Date:T"),
|
|
alt.Y("Delta:Q").scale(zero=False),
|
|
)
|
|
.properties(title=f"{ticker} minus peer average", height=300)
|
|
)
|
|
|
|
cell = cols[(i * 2 + 1) % NUM_COLS].container(border=True)
|
|
cell.write("")
|
|
cell.altair_chart(chart)
|
|
|
|
""
|
|
""
|
|
|
|
"""
|
|
## Raw data
|
|
"""
|
|
|
|
data
|