Convert a Streamlit App¶
Upload a Streamlit app and ask Claude Code to recreate it using Panel!
Input¶
Upload the image below to Claude Code

Ask Claude Code to create a plan:
Please plan how to convert the Stock Peers Streamlit app at https://github.com/streamlit/demo-stockpeers/blob/main/streamlit_app.py into a Panel app.
The attached image shows a part of the app. Please study this carefully to understand the existing layout and styling.
Please also:
- use altair plots
- use panel-material-ui components for modern user experience
- use dark theme and make sure it looks and feels modern
- make sure this will responsively layout for different screen sizes
- implement this in a single app.py file for easy sharing.
- implement the tests in test_app.py file. Make sure they all pass

Ask Claude to implement the plan.
Installing the Dependencies
I had to help Claude install the dependencies
Result¶
The result is a solid foundation that can be further refined as needed.

Code
"""Stock Peers Analysis Panel App
A modern Panel application using Material UI components for analyzing stock performance
against peer averages over various time horizons.
"""
import panel as pn
import panel_material_ui as pmui
import param
import yfinance as yf
import pandas as pd
import altair as alt
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings('ignore')
# Panel configuration
pn.extension("vega", "tabulator", throttled=True)
pmui.Paper.param.margin.default = 10
# Configure Altair dark theme
def get_altair_theme():
return {
"config": {
"background": "#1a1d29",
"text": {"color": "#fafafa"},
"axis": {
"domainColor": "#666",
"gridColor": "#333",
"tickColor": "#666",
"labelColor": "#fafafa",
"titleColor": "#fafafa",
},
"view": {"strokeWidth": 0},
"title": {"color": "#fafafa"},
}
}
alt.themes.register("dark_theme", get_altair_theme)
alt.themes.enable("dark_theme")
# Stock constants
STOCKS = [
"AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META", "BRK-B", "JPM", "V",
"JNJ", "WMT", "PG", "MA", "HD", "CVX", "MRK", "ABBV", "PEP", "KO",
"AVGO", "COST", "LLY", "TMO", "ADBE", "CSCO", "ACN", "NKE", "ABT", "CRM",
"DIS", "VZ", "CMCSA", "NFLX", "INTC", "PFE", "DHR", "TXN", "AMD", "NEE",
"UNP", "PM", "RTX", "QCOM", "HON", "UPS", "LOW", "AMGN", "IBM", "SPGI",
"GE", "BA", "CAT", "CVS", "LMT", "GS", "DE", "BLK", "AXP", "MMM",
"MDLZ", "BMY", "ISRG", "GILD", "C", "SBUX", "AMT", "SYK", "TJX", "ZTS",
"BKNG", "MO", "BDX", "ADP", "PLD", "LRCX", "MMC", "CI", "VRTX", "TGT",
"MU", "REGN", "ADI", "CB", "EL", "SO", "DUK", "SCHW", "EQIX", "CL",
"SHW", "NSC", "AMAT", "HUM", "EOG"
]
DEFAULT_STOCKS = ["AAPL", "MSFT", "GOOGL", "NVDA", "AMZN", "TSLA", "META"]
HORIZON_MAP = {
"1 Months": "1mo",
"3 Months": "3mo",
"6 Months": "6mo",
"1 Year": "1y",
"5 Years": "5y",
"10 Years": "10y",
"20 Years": "max",
}
# Material UI theme configuration
THEME_CONFIG = {
"palette": {
"mode": "dark",
"primary": {"main": "#1976d2"},
"background": {
"default": "#0e1117",
"paper": "#1a1d29",
},
"text": {
"primary": "#fafafa",
"secondary": "#b0b0b0",
},
},
}
@pn.cache(ttl=21600) # 6 hours cache
def load_stock_data(tickers: str, period: str) -> pd.DataFrame:
"""
Load and normalize stock price data from yfinance.
Args:
tickers: Comma-separated ticker symbols
period: Time period (1mo, 3mo, 6mo, 1y, 5y, 10y, max)
Returns:
DataFrame with normalized prices (100 = starting price)
"""
if not tickers:
return pd.DataFrame()
ticker_list = [t.strip() for t in tickers.split(",") if t.strip()]
if not ticker_list:
return pd.DataFrame()
try:
# Download data
data = yf.download(ticker_list, period=period, progress=False)
if data.empty:
return pd.DataFrame()
# Handle single vs multiple tickers
if len(ticker_list) == 1:
prices = data[["Close"]].copy()
prices.columns = ticker_list
else:
prices = data["Close"].copy()
# Remove any tickers with all NaN values
prices = prices.dropna(axis=1, how="all")
if prices.empty:
return pd.DataFrame()
# Normalize to 100 at start
normalized = (prices / prices.iloc[0] * 100).round(2)
normalized.index.name = "Date"
return normalized.reset_index()
except Exception as e:
print(f"Error loading data: {e}")
return pd.DataFrame()
class StockPeersApp(pn.viewable.Viewer):
"""
Stock Peers Analysis Application.
Provides interactive analysis of stock performance against peer averages
with Material UI components and responsive layout.
"""
tickers = param.List(default=DEFAULT_STOCKS, doc="Selected stock tickers")
horizon = param.String(default="6 Months", doc="Selected time horizon")
def __init__(self, **params):
super().__init__(**params)
# Check for URL query parameters
if pn.state.location:
query_tickers = pn.state.location.query_params.get("stocks")
if query_tickers:
ticker_list = query_tickers.split(",")
valid_tickers = [t for t in ticker_list if t in STOCKS]
if valid_tickers:
self.tickers = valid_tickers
# Create input components
self.ticker_selector = pmui.MultiChoice(
label="Stock tickers",
options=sorted(STOCKS),
value=self.tickers,
placeholder="Choose stocks to compare...",
chip=True,
delete_button=True,
searchable=True,
color="primary",
margin=(0, 0, 20, 0),
)
self.horizon_selector = pmui.RadioButtonGroup(
label="Time horizon",
options=["1 Months", "3 Months", "6 Months", "1 Year", "5 Years", "10 Years", "20 Years"],
value=self.horizon,
orientation="horizontal",
color="primary",
margin=(0, 0, 20, 0),
)
# Link widgets to parameters
self.ticker_selector.link(self, value="tickers")
self.horizon_selector.link(self, value="horizon")
def _load_data(self) -> pd.DataFrame:
"""Load data for selected tickers and horizon."""
if not self.tickers:
return pd.DataFrame()
tickers_str = ",".join(self.tickers)
period = HORIZON_MAP[self.horizon]
return load_stock_data(tickers_str, period)
def _create_inputs(self) -> pmui.Column:
"""Create input controls column."""
return pmui.Column(
self.ticker_selector,
self.horizon_selector,
margin=10,
)
@param.depends("tickers", "horizon")
def _metrics_view(self):
"""Reactive wrapper for metrics display."""
data = self._load_data()
return self._create_metrics(data)
def _create_metrics(self, data: pd.DataFrame) -> pn.Column:
"""Create best/worst stock metrics display."""
if data.empty or len(data) < 2:
return pn.Column(
pmui.Typography("No data to display metrics", variant="body2", color="textSecondary"),
margin=10,
)
# Calculate returns
returns = {}
for col in data.columns:
if col != "Date":
start_val = data[col].iloc[0]
end_val = data[col].iloc[-1]
if pd.notna(start_val) and pd.notna(end_val) and start_val != 0:
returns[col] = ((end_val - start_val) / start_val) * 100
if not returns:
return pn.Column(
pmui.Typography("No valid data for metrics", variant="body2", color="textSecondary"),
margin=10,
)
best_stock = max(returns, key=returns.get)
worst_stock = min(returns, key=returns.get)
best_return = returns[best_stock]
worst_return = returns[worst_stock]
# Best stock metric card
best_metric = pmui.Paper(
pmui.Column(
pmui.Typography("Best Stock", variant="body2", color="textSecondary"),
pmui.Typography(best_stock, variant="h4", sx={"fontWeight": "bold"}),
pmui.Typography(
f"{best_return:+.2f}%",
variant="h5",
sx={"color": "#4caf50" if best_return >= 0 else "#f44336"},
),
),
elevation=2,
sx={"padding": "16px", "borderRadius": "8px", "flex": 1},
)
# Worst stock metric card
worst_metric = pmui.Paper(
pmui.Column(
pmui.Typography("Worst Stock", variant="body2", color="textSecondary"),
pmui.Typography(worst_stock, variant="h4", sx={"fontWeight": "bold"}),
pmui.Typography(
f"{worst_return:+.2f}%",
variant="h5",
sx={"color": "#4caf50" if worst_return >= 0 else "#f44336"},
),
),
elevation=2,
sx={"padding": "16px", "borderRadius": "8px", "flex": 1},
)
return pn.Row(best_metric, worst_metric, margin=10, sizing_mode="stretch_width")
def _create_main_chart(self, data: pd.DataFrame) -> pn.pane.Vega:
"""Create the main normalized price comparison chart."""
if data.empty:
# Return empty chart with message
empty_chart = alt.Chart(pd.DataFrame({"x": [0], "y": [0], "text": ["No data available"]})).mark_text(
size=16, color="#b0b0b0"
).encode(
x=alt.X("x:Q", scale=alt.Scale(domain=[0, 1]), axis=None),
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, 1]), axis=None),
text="text:N"
).properties(
width="container",
height=400,
)
return pn.pane.Vega(empty_chart, sizing_mode="stretch_width")
# Melt data for Altair
melted = data.melt(id_vars="Date", var_name="Stock", value_name="Normalized price")
melted = melted.dropna()
chart = alt.Chart(melted).mark_line(strokeWidth=2).encode(
x=alt.X("Date:T", title="Date"),
y=alt.Y("Normalized price:Q", title="Normalized Price", scale=alt.Scale(zero=False)),
color=alt.Color("Stock:N", scale=alt.Scale(scheme="category20"), legend=alt.Legend(title="Stock")),
tooltip=["Date:T", "Stock:N", alt.Tooltip("Normalized price:Q", format=".2f")]
).properties(
width="container",
height=400,
title="Normalized Price Comparison (Base 100)"
).configure_view(
strokeWidth=0
).configure_axis(
grid=True,
gridColor="#2a2a2a"
).interactive()
return pn.pane.Vega(chart, sizing_mode="stretch_width")
def _create_individual_charts(self, data: pd.DataFrame) -> pn.Column:
"""Create grid of individual stock vs peer average charts."""
if data.empty or len(data.columns) <= 2: # Need at least 2 stocks for peer comparison
return pn.Column(
pmui.Typography(
"Select at least 2 stocks to see peer comparisons",
variant="body1",
color="textSecondary",
sx={"textAlign": "center", "padding": "20px"}
),
sizing_mode="stretch_width"
)
chart_components = []
stocks = [col for col in data.columns if col != "Date"]
for stock in stocks:
# Calculate peer average (all stocks except current)
peer_stocks = [s for s in stocks if s != stock]
data[f"{stock}_peer_avg"] = data[peer_stocks].mean(axis=1)
# Stock vs Peer Average Chart
stock_data = pd.DataFrame({
"Date": data["Date"],
stock: data[stock],
"Peer Average": data[f"{stock}_peer_avg"]
}).melt(id_vars="Date", var_name="Series", value_name="Normalized price")
stock_data = stock_data.dropna()
stock_chart = alt.Chart(stock_data).mark_line(strokeWidth=2).encode(
x=alt.X("Date:T", title=None),
y=alt.Y("Normalized price:Q", title="Price", scale=alt.Scale(zero=False)),
color=alt.Color(
"Series:N",
scale=alt.Scale(
domain=[stock, "Peer Average"],
range=["#ef5350", "#78909c"]
),
legend=None
),
tooltip=["Date:T", "Series:N", alt.Tooltip("Normalized price:Q", format=".2f")]
).properties(
width="container",
height=250,
title=f"{stock} vs Peer Average"
).interactive()
stock_chart_pane = pn.pane.Vega(stock_chart, sizing_mode="stretch_width")
# Delta Chart (difference)
delta_data = pd.DataFrame({
"Date": data["Date"],
"Delta": data[stock] - data[f"{stock}_peer_avg"]
}).dropna()
delta_chart = alt.Chart(delta_data).mark_area(
line={"color": "#1976d2"},
color=alt.Gradient(
gradient="linear",
stops=[
alt.GradientStop(color="#1976d2", offset=0),
alt.GradientStop(color="#1976d220", offset=1)
],
x1=1, x2=1, y1=1, y2=0
)
).encode(
x=alt.X("Date:T", title=None),
y=alt.Y("Delta:Q", title="Delta"),
tooltip=["Date:T", alt.Tooltip("Delta:Q", format=".2f")]
).properties(
width="container",
height=250,
title=f"{stock} Delta"
).interactive()
delta_chart_pane = pn.pane.Vega(delta_chart, sizing_mode="stretch_width")
# Wrap charts in Paper components
stock_paper = pmui.Paper(stock_chart_pane, elevation=1, sx={"padding": "8px"})
delta_paper = pmui.Paper(delta_chart_pane, elevation=1, sx={"padding": "8px"})
# Add to grid with responsive breakpoints
chart_components.extend([stock_paper, delta_paper])
# Create responsive grid layout using FlexBox
# Each row contains 4 charts (2 stock pairs)
rows = []
for i in range(0, len(chart_components), 4):
row_charts = chart_components[i:i+4]
rows.append(pn.Row(*row_charts, sizing_mode="stretch_width"))
return pn.Column(*rows, sizing_mode="stretch_width") if rows else pn.Column(sizing_mode="stretch_width")
def _create_raw_data(self, data: pd.DataFrame) -> pn.widgets.Tabulator:
"""Create raw data table."""
if data.empty:
return pn.pane.Markdown("*No data available*")
# Format date column
display_data = data.copy()
if "Date" in display_data.columns:
display_data["Date"] = pd.to_datetime(display_data["Date"]).dt.strftime("%Y-%m-%d")
return pn.widgets.Tabulator(
display_data,
theme="midnight",
layout="fit_data_stretch",
frozen_columns=["Date"],
sizing_mode="stretch_width",
pagination="remote",
page_size=20,
)
@param.depends("tickers", "horizon")
def view(self):
"""Create the main view with all components."""
# Load data
data = self._load_data()
# Handle empty ticker case
if not self.tickers:
info_message = pmui.Alert(
"Please select at least one stock ticker to begin analysis.",
severity="info",
margin=20,
)
return pmui.Column(info_message, margin=15)
# Handle no data case
if data.empty:
error_message = pmui.Alert(
"Unable to load data for selected stocks. Please try different stocks or time horizon.",
severity="warning",
margin=20,
)
return pmui.Column(error_message, margin=15)
# Create all components
metrics = self._create_metrics(data)
main_chart = self._create_main_chart(data)
individual_charts = self._create_individual_charts(data)
raw_data_table = self._create_raw_data(data)
# Assemble main content
return pmui.Column(
pmui.Paper(main_chart, elevation=2, sx={"padding": "16px"}, margin=(0, 0, 20, 0)),
pmui.Typography("Individual stocks vs peer average", variant="h5", margin=(20, 0, 10, 0)),
individual_charts,
pmui.Typography("Raw data", variant="h5", margin=(30, 0, 10, 0)),
pmui.Paper(raw_data_table, elevation=2, sx={"padding": "16px"}),
margin=15,
sizing_mode="stretch_width",
)
def __panel__(self):
"""Return the panel representation."""
return self.view
@classmethod
def create_app(cls, **params) -> pmui.Page:
"""
Create the complete application with Page layout.
Returns:
pmui.Page: The complete application page
"""
app = cls(**params)
# Create sidebar content
sidebar = pmui.Column(
app._create_inputs(),
pmui.Divider(sx={"margin": "20px 0"}),
app._metrics_view,
sizing_mode="stretch_width",
)
# Create page
page = pmui.Page(
title="Stock peer analysis",
sidebar=[sidebar],
main=[app.view],
dark_theme=True,
theme_config=THEME_CONFIG,
)
return page
# Create and serve the app
if __name__ == "__main__" or __name__.startswith("bokeh"):
app = StockPeersApp.create_app()
app.servable()
It even created passing tests, requirements file, and detailed documentation:
