Skip to content

Commit 3ba2f4a

Browse files
feat(letsplot): implement candlestick-volume (#6882)
## Implementation: `candlestick-volume` - python/letsplot Implements the **python/letsplot** version of `candlestick-volume`. **File:** `plots/candlestick-volume/implementations/python/letsplot.py` **Parent Issue:** #3068 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25953910171)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 0fa0e8f commit 3ba2f4a

2 files changed

Lines changed: 221 additions & 184 deletions

File tree

Lines changed: 59 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,52 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
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
3+
Library: letsplot 4.9.0 | Python 3.13.13
4+
Quality: 90/100 | Updated: 2026-05-16
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
911
from lets_plot import *
1012

1113

1214
LetsPlot.setup_html()
1315

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-
16+
THEME = os.getenv("ANYPLOT_THEME", "light")
17+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
18+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
19+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
20+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
2321

2422
# Data - 60 trading days of synthetic stock data
2523
np.random.seed(42)
2624
n_days = 60
2725
dates = pd.date_range("2024-01-02", periods=n_days, freq="B")
2826

29-
# Generate realistic price movement with trend and volatility
30-
returns = np.random.normal(0.001, 0.02, n_days)
27+
# Generate realistic price movement with more dramatic reversals
28+
returns = np.random.normal(0.001, 0.025, n_days)
29+
returns[15] = -0.08 # Dramatic drop
30+
returns[35] = 0.06 # Strong bounce
31+
returns[45] = -0.05 # Another reversal
3132
close_prices = 150 * np.cumprod(1 + returns)
3233

3334
# Generate OHLC from close prices
3435
open_prices = np.roll(close_prices, 1)
3536
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)))
37+
high_prices = np.maximum(open_prices, close_prices) * (1 + np.abs(np.random.normal(0, 0.012, n_days)))
38+
low_prices = np.minimum(open_prices, close_prices) * (1 - np.abs(np.random.normal(0, 0.012, n_days)))
3839

39-
# Generate volume with some correlation to price movement
40+
# Generate volume with correlation to price movement
4041
base_volume = 5_000_000
4142
volatility = np.abs(close_prices - open_prices) / open_prices
4243
volume = base_volume * (1 + volatility * 10 + np.random.uniform(-0.3, 0.3, n_days))
4344
volume = volume.astype(int)
4445

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)]
46+
# Determine up/down days for coloring
47+
direction = ["Up" if c >= o else "Down" for c, o in zip(close_prices, open_prices)]
4748

48-
# Create date labels for x-axis (show every 10th trading day)
49+
# Create date labels for x-axis (show every 10 trading days)
4950
date_labels = [d.strftime("%b %d") for d in dates]
5051
date_breaks = list(range(0, n_days, 10))
5152
date_tick_labels = [date_labels[i] for i in date_breaks]
@@ -64,69 +65,67 @@ def format_volume(val):
6465
}
6566
)
6667

67-
# Colorblind-safe colors (blue for up, orange for down)
68-
color_up = "#0077BB"
69-
color_down = "#EE7733"
68+
# Colorblind-safe colors (first series Okabe-Ito, second is orange)
69+
color_up = "#009E73"
70+
color_down = "#D55E00"
71+
72+
# Create volume breaks and labels (inline formatting)
73+
vol_min, vol_max = df["volume"].min(), df["volume"].max()
74+
vol_breaks = [int(vol_min), int((vol_min + vol_max) / 2), int(vol_max)]
75+
vol_labels = [f"{v / 1_000_000:.1f}M" if v >= 1_000_000 else f"{v / 1_000:.0f}K" for v in vol_breaks]
76+
77+
# Theme-adaptive styling
78+
anyplot_theme = theme(
79+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
80+
panel_background=element_rect(fill=PAGE_BG),
81+
panel_grid_major=element_line(color=INK_SOFT, size=0.25),
82+
panel_grid_minor=element_blank(),
83+
axis_title=element_text(color=INK, size=20),
84+
axis_text=element_text(color=INK_SOFT, size=16),
85+
axis_line=element_line(color=INK_SOFT, size=0.3),
86+
plot_title=element_text(color=INK, size=24),
87+
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
88+
legend_text=element_text(color=INK_SOFT, size=16),
89+
legend_title=element_text(color=INK, size=18),
90+
)
7091

7192
# Create candlestick chart (main pane)
7293
candle_plot = (
7394
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)
95+
# Wicks (high-low lines) - thicker for visibility
96+
+ geom_segment(aes(x="date_idx", xend="date_idx", y="low", yend="high", color="direction"), size=1.5)
7697
# 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")
98+
+ geom_segment(aes(x="date_idx", xend="date_idx", y="open", yend="close", color="direction"), size=6.0)
99+
+ scale_color_manual(values={"Up": color_up, "Down": color_down}, name="Direction")
79100
+ 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()
101+
+ labs(title="Stock Trading · candlestick-volume · letsplot · anyplot.ai", y="Price ($)", x="")
102+
+ anyplot_theme
84103
+ theme(
85-
plot_title=element_text(size=24),
86-
axis_title_y=element_text(size=20),
87-
axis_text_y=element_text(size=16),
88104
axis_text_x=element_blank(),
89-
legend_position=[0.5, 0.98],
105+
legend_position=[0.5, 0.95],
90106
legend_justification=[0.5, 1.0],
91107
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],
108+
plot_margin=[40, 20, 2, 10],
97109
)
98110
+ ggsize(1600, 630)
99111
)
100112

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-
106113
# Volume chart (lower pane)
107114
volume_plot = (
108115
ggplot(df)
109116
+ 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")
117+
+ scale_fill_manual(values={"Up": color_up, "Down": color_down}, name="Direction")
111118
+ scale_x_continuous(breaks=date_breaks, labels=date_tick_labels)
112119
+ scale_y_continuous(breaks=vol_breaks, labels=vol_labels)
113120
+ 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-
)
121+
+ anyplot_theme
122+
+ theme(legend_position="none", plot_margin=[2, 20, 10, 10])
124123
+ ggsize(1600, 270)
125124
)
126125

127-
# Use gggrid for dual-pane layout (replaces deprecated GGBunch)
126+
# Use gggrid for dual-pane layout with tighter spacing
128127
combined = gggrid([candle_plot, volume_plot], ncol=1, heights=[0.7, 0.3])
129128

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=".")
129+
# Save outputs
130+
ggsave(combined, f"plot-{THEME}.png", scale=3, path=".")
131+
ggsave(combined, f"plot-{THEME}.html", path=".")

0 commit comments

Comments
 (0)