diff --git a/docs/assets/images/examples/stock-peer-analysis-panel-summary.png b/docs/assets/images/examples/stock-peer-analysis-panel-summary.png new file mode 100644 index 0000000..48cc275 Binary files /dev/null and b/docs/assets/images/examples/stock-peer-analysis-panel-summary.png differ diff --git a/docs/assets/images/examples/stock-peer-analysis-panel.gif b/docs/assets/images/examples/stock-peer-analysis-panel.gif new file mode 100644 index 0000000..e7288a9 Binary files /dev/null and b/docs/assets/images/examples/stock-peer-analysis-panel.gif differ diff --git a/docs/assets/images/examples/stock-peer-analysis-streamlit-claude.png b/docs/assets/images/examples/stock-peer-analysis-streamlit-claude.png new file mode 100644 index 0000000..5cba9cb Binary files /dev/null and b/docs/assets/images/examples/stock-peer-analysis-streamlit-claude.png differ diff --git a/docs/assets/images/examples/stock-peer-analysis-streamlit.png b/docs/assets/images/examples/stock-peer-analysis-streamlit.png new file mode 100644 index 0000000..fd93789 Binary files /dev/null and b/docs/assets/images/examples/stock-peer-analysis-streamlit.png differ diff --git a/docs/examples/convert-streamlit-app.md b/docs/examples/convert-streamlit-app.md new file mode 100644 index 0000000..1243381 --- /dev/null +++ b/docs/examples/convert-streamlit-app.md @@ -0,0 +1,557 @@ +# Convert a Streamlit App + +Upload a Streamlit app and ask Claude Code to recreate it using Panel! + +![Claude Logo](../assets/images/claude-logo.svg) + +## Input + +Upload the image below to Claude Code + +![Streamlit Stock Peers Analysis](../assets/images/examples/stock-peer-analysis-streamlit.png) + +Ask Claude Code to create a plan: + +```text +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 +``` + +![Claude Code Prompt](../assets/images/examples/stock-peer-analysis-streamlit-claude.png) + +Ask Claude to implement the plan. + +!!! Note "Installing the Dependencies" + I had to help Claude install the dependencies + + ```bash + pip install panel panel-material-ui watchfiles altair yfinance + ``` + +## Result + +The result is a solid foundation that can be further refined as needed. + +![Stock Peers Analysis Panel App](../assets/images/examples/stock-peer-analysis-panel.gif) + +
Code + +```python +"""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: + +![Claude Summary](../assets/images/examples/stock-peer-analysis-panel-summary.png) + +## Credits + +- [Streamlit Demo Stock Peers](https://github.com/streamlit/demo-stockpeers/tree/main) diff --git a/docs/examples/generate-plot-from-picture.md b/docs/examples/generate-plot-from-picture.md index b28cff6..22fb602 100644 --- a/docs/examples/generate-plot-from-picture.md +++ b/docs/examples/generate-plot-from-picture.md @@ -24,7 +24,7 @@ Please implement the plan. ## Result -Not perfect, but a really good starting point. +The result is a solid foundation that can be further refined as needed. ![Distribution of Electricity Plot - Output](../assets/images/examples/distribution-of-electricity-output.png) diff --git a/mkdocs.yml b/mkdocs.yml index 61a39cb..05f106d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,6 +108,7 @@ nav: - examples/index.md - Plot from Picture: examples/generate-plot-from-picture.md - Dashboard from Picture: examples/generate-dashboard-from-picture.md + - Convert Streamlit App: examples/convert-streamlit-app.md - How-To Guides: - Installation: - uv (recommended): how-to/install-uv.md diff --git a/pyproject.toml b/pyproject.toml index 50c77c9..15c8be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,10 @@ filterwarnings = [ ] testpaths = ["tests"] +[tool.codespell] +ignore-words-list = "acn" +skip = "*.js,*.css,*.svg,*.json" + [tool.mypy] python_version = '3.11' no_implicit_optional = true diff --git a/src/holoviz_mcp/config/resources/skills/panel-material-ui.md b/src/holoviz_mcp/config/resources/skills/panel-material-ui.md index 6ea68b1..19240c7 100644 --- a/src/holoviz_mcp/config/resources/skills/panel-material-ui.md +++ b/src/holoviz_mcp/config/resources/skills/panel-material-ui.md @@ -135,7 +135,6 @@ elif pn.state.served: DO always create test in separate test files and DO run test via pytest: - ```python import ... @@ -165,12 +164,14 @@ def test_characters_reactivity(): - DO use Material UI `sx` parameter for all css styling over `styles` and `stylesheets` - DO use panel-material-ui components instead of panel components for projects already using panel-material-ui and for new projects - DON'T configure the `design`, i.e. DO NOT `pn.extension(design='material')`. +- DO prefer professional Material UI icons over emojies ## Component Instructions ### Page - DO provide the title to the `Page.title` argument. DON'T provide it in the `Page.main` area. +- DO make sure components in the `sidebar` stretch width. - DO provide an optional image, description, navigation menu to the `Page.sidebar` argument. Normally DON't put them in the `header` or `main` areas. - DO provide the input widgets as children to the `Page.sidebar` argument - DO not add advanced or high components to the `Page.header` as it is only 100px high by default. Normally only buttons, indicators, text and navigation links go into the header. diff --git a/src/holoviz_mcp/config/resources/skills/panel.md b/src/holoviz_mcp/config/resources/skills/panel.md index e7c33a3..84c805c 100644 --- a/src/holoviz_mcp/config/resources/skills/panel.md +++ b/src/holoviz_mcp/config/resources/skills/panel.md @@ -339,6 +339,7 @@ template = pn.template.FastListTemplate( ``` - In the `sidebar`, DO use the order: 1) optional logo, 2) description, 3) input widgets, 4) documentation +- In the `sidebar`, DO make sure components stretch width. - Do set `main_layout=None` for a modern styling. ### Responsive Design