|
| 1 | +""" pyplots.ai |
| 2 | +candlestick-volume: Stock Candlestick Chart with Volume |
| 3 | +Library: letsplot 4.8.2 | Python 3.13.11 |
| 4 | +Quality: 91/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from lets_plot import * |
| 10 | + |
| 11 | + |
| 12 | +LetsPlot.setup_html() |
| 13 | + |
| 14 | + |
| 15 | +# Format volume as human-readable (e.g., 5.0M instead of 5000000) |
| 16 | +def format_volume(val): |
| 17 | + if val >= 1_000_000: |
| 18 | + return f"{val / 1_000_000:.1f}M" |
| 19 | + elif val >= 1_000: |
| 20 | + return f"{val / 1_000:.0f}K" |
| 21 | + return str(int(val)) |
| 22 | + |
| 23 | + |
| 24 | +# Data - 60 trading days of synthetic stock data |
| 25 | +np.random.seed(42) |
| 26 | +n_days = 60 |
| 27 | +dates = pd.date_range("2024-01-02", periods=n_days, freq="B") |
| 28 | + |
| 29 | +# Generate realistic price movement with trend and volatility |
| 30 | +returns = np.random.normal(0.001, 0.02, n_days) |
| 31 | +close_prices = 150 * np.cumprod(1 + returns) |
| 32 | + |
| 33 | +# Generate OHLC from close prices |
| 34 | +open_prices = np.roll(close_prices, 1) |
| 35 | +open_prices[0] = 150 |
| 36 | +high_prices = np.maximum(open_prices, close_prices) * (1 + np.abs(np.random.normal(0, 0.01, n_days))) |
| 37 | +low_prices = np.minimum(open_prices, close_prices) * (1 - np.abs(np.random.normal(0, 0.01, n_days))) |
| 38 | + |
| 39 | +# Generate volume with some correlation to price movement |
| 40 | +base_volume = 5_000_000 |
| 41 | +volatility = np.abs(close_prices - open_prices) / open_prices |
| 42 | +volume = base_volume * (1 + volatility * 10 + np.random.uniform(-0.3, 0.3, n_days)) |
| 43 | +volume = volume.astype(int) |
| 44 | + |
| 45 | +# Determine up/down days for coloring (shorter labels to avoid truncation) |
| 46 | +direction = ["Up Day" if c >= o else "Down Day" for c, o in zip(close_prices, open_prices)] |
| 47 | + |
| 48 | +# Create date labels for x-axis (show every 10th trading day) |
| 49 | +date_labels = [d.strftime("%b %d") for d in dates] |
| 50 | +date_breaks = list(range(0, n_days, 10)) |
| 51 | +date_tick_labels = [date_labels[i] for i in date_breaks] |
| 52 | + |
| 53 | +df = pd.DataFrame( |
| 54 | + { |
| 55 | + "date": dates, |
| 56 | + "date_idx": range(n_days), |
| 57 | + "date_label": date_labels, |
| 58 | + "open": open_prices, |
| 59 | + "high": high_prices, |
| 60 | + "low": low_prices, |
| 61 | + "close": close_prices, |
| 62 | + "volume": volume, |
| 63 | + "direction": direction, |
| 64 | + } |
| 65 | +) |
| 66 | + |
| 67 | +# Colorblind-safe colors (blue for up, orange for down) |
| 68 | +color_up = "#0077BB" |
| 69 | +color_down = "#EE7733" |
| 70 | + |
| 71 | +# Create candlestick chart (main pane) |
| 72 | +candle_plot = ( |
| 73 | + ggplot(df) |
| 74 | + # Wicks (high-low lines) |
| 75 | + + geom_segment(aes(x="date_idx", xend="date_idx", y="low", yend="high", color="direction"), size=1.0) |
| 76 | + # Bodies (open-close rectangles) |
| 77 | + + geom_segment(aes(x="date_idx", xend="date_idx", y="open", yend="close", color="direction"), size=5.0) |
| 78 | + + scale_color_manual(values={"Up Day": color_up, "Down Day": color_down}, name="Direction") |
| 79 | + + scale_x_continuous(breaks=date_breaks, labels=date_tick_labels) |
| 80 | + + labs(title="candlestick-volume · letsplot · pyplots.ai", y="Price ($)", x="") |
| 81 | + # Enable crosshair cursor for precise reading |
| 82 | + + coord_cartesian() |
| 83 | + + theme_minimal() |
| 84 | + + theme( |
| 85 | + plot_title=element_text(size=24), |
| 86 | + axis_title_y=element_text(size=20), |
| 87 | + axis_text_y=element_text(size=16), |
| 88 | + axis_text_x=element_blank(), |
| 89 | + legend_position=[0.5, 0.98], |
| 90 | + legend_justification=[0.5, 1.0], |
| 91 | + legend_direction="horizontal", |
| 92 | + legend_title=element_text(size=18), |
| 93 | + legend_text=element_text(size=16), |
| 94 | + panel_grid_major=element_line(color="#E5E7EB", size=0.5), |
| 95 | + panel_grid_minor=element_blank(), |
| 96 | + plot_margin=[40, 20, 5, 10], |
| 97 | + ) |
| 98 | + + ggsize(1600, 630) |
| 99 | +) |
| 100 | + |
| 101 | +# Create volume breaks and labels for human-readable format |
| 102 | +vol_min, vol_max = df["volume"].min(), df["volume"].max() |
| 103 | +vol_breaks = [int(vol_min), int((vol_min + vol_max) / 2), int(vol_max)] |
| 104 | +vol_labels = [format_volume(v) for v in vol_breaks] |
| 105 | + |
| 106 | +# Volume chart (lower pane) |
| 107 | +volume_plot = ( |
| 108 | + ggplot(df) |
| 109 | + + geom_bar(aes(x="date_idx", y="volume", fill="direction"), stat="identity", width=0.8) |
| 110 | + + scale_fill_manual(values={"Up Day": color_up, "Down Day": color_down}, name="Direction") |
| 111 | + + scale_x_continuous(breaks=date_breaks, labels=date_tick_labels) |
| 112 | + + scale_y_continuous(breaks=vol_breaks, labels=vol_labels) |
| 113 | + + labs(x="Date (2024)", y="Volume (shares)") |
| 114 | + # Enable crosshair cursor for precise reading (interactive HTML) |
| 115 | + + coord_cartesian() |
| 116 | + + theme_minimal() |
| 117 | + + theme( |
| 118 | + axis_title=element_text(size=20), |
| 119 | + axis_text=element_text(size=16), |
| 120 | + legend_position="none", |
| 121 | + panel_grid_major=element_line(color="#E5E7EB", size=0.5), |
| 122 | + panel_grid_minor=element_blank(), |
| 123 | + ) |
| 124 | + + ggsize(1600, 270) |
| 125 | +) |
| 126 | + |
| 127 | +# Use gggrid for dual-pane layout (replaces deprecated GGBunch) |
| 128 | +combined = gggrid([candle_plot, volume_plot], ncol=1, heights=[0.7, 0.3]) |
| 129 | + |
| 130 | +# Save outputs (path='' ensures files are saved in current directory) |
| 131 | +ggsave(combined, "plot.png", scale=3, path=".") |
| 132 | +ggsave(combined, "plot.html", path=".") |
0 commit comments