|
1 | 1 | """ pyplots.ai |
2 | 2 | candlestick-basic: Basic Candlestick Chart |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 87/100 | Updated: 2026-02-24 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import re |
| 8 | +from datetime import datetime, timedelta |
| 9 | + |
| 10 | +import cairosvg |
7 | 11 | import numpy as np |
8 | 12 | import pygal |
9 | 13 | from pygal.style import Style |
10 | 14 |
|
11 | 15 |
|
12 | | -# Data - Stock OHLC data for 30 trading days |
| 16 | +# --- Data: 30 trading days of OHLC stock prices --- |
13 | 17 | np.random.seed(42) |
14 | 18 | n_days = 30 |
15 | 19 |
|
16 | | -# Generate realistic OHLC data with trend and volatility |
| 20 | +start_date = datetime(2024, 1, 2) |
| 21 | +dates = [] |
| 22 | +cur = start_date |
| 23 | +for _ in range(n_days): |
| 24 | + while cur.weekday() >= 5: |
| 25 | + cur += timedelta(days=1) |
| 26 | + dates.append(cur) |
| 27 | + cur += timedelta(days=1) |
| 28 | + |
17 | 29 | base_price = 150.0 |
18 | | -returns = np.random.randn(n_days) * 2.5 # Daily returns in % |
| 30 | +returns = np.random.randn(n_days) * 2.5 |
19 | 31 | price_series = base_price * np.cumprod(1 + returns / 100) |
20 | 32 |
|
21 | 33 | ohlc_data = [] |
22 | 34 | for i, close in enumerate(price_series): |
23 | | - # Generate realistic intraday range |
24 | 35 | volatility = np.abs(np.random.randn()) * 2 + 0.5 |
25 | 36 | intraday_range = close * volatility / 100 |
26 | | - |
27 | | - if i == 0: |
28 | | - open_price = base_price |
29 | | - else: |
30 | | - open_price = ohlc_data[-1]["close"] |
31 | | - |
| 37 | + open_price = base_price if i == 0 else ohlc_data[-1]["close"] |
32 | 38 | high = max(open_price, close) + np.random.rand() * intraday_range |
33 | 39 | low = min(open_price, close) - np.random.rand() * intraday_range |
34 | | - |
35 | 40 | ohlc_data.append({"day": i + 1, "open": open_price, "high": high, "low": low, "close": close}) |
36 | 41 |
|
37 | | -# Calculate data range for chart |
38 | | -all_highs = [d["high"] for d in ohlc_data] |
39 | | -all_lows = [d["low"] for d in ohlc_data] |
40 | | -y_min = min(all_lows) - 2 |
41 | | -y_max = max(all_highs) + 2 |
| 42 | +# 5-day moving average for trend context |
| 43 | +closes = [d["close"] for d in ohlc_data] |
| 44 | +ma_points = [(i + 1, float(np.mean(closes[i - 4 : i + 1]))) for i in range(4, n_days)] |
42 | 45 |
|
43 | | -# Colors |
44 | | -bullish_color = "#22A06B" |
45 | | -bearish_color = "#EF4444" |
| 46 | +# Price extremes for storytelling markers |
| 47 | +peak = max(ohlc_data, key=lambda d: d["high"]) |
| 48 | +trough = min(ohlc_data, key=lambda d: d["low"]) |
| 49 | + |
| 50 | +# --- Group candlestick segments by direction (None = line break) --- |
| 51 | +bull_wicks, bear_wicks = [], [] |
| 52 | +bull_bodies, bear_bodies = [], [] |
46 | 53 |
|
47 | | -# Build color list - bodies first, then wicks (same order as series) |
48 | | -colors_list = [] |
49 | | -for candle in ohlc_data: |
50 | | - is_bullish = candle["close"] >= candle["open"] |
51 | | - colors_list.append(bullish_color if is_bullish else bearish_color) |
52 | 54 | for candle in ohlc_data: |
53 | | - is_bullish = candle["close"] >= candle["open"] |
54 | | - colors_list.append(bullish_color if is_bullish else bearish_color) |
| 55 | + x = candle["day"] |
| 56 | + wick = [(x, candle["low"]), (x, candle["high"]), None] |
| 57 | + body = [(x, candle["open"]), (x, candle["close"]), None] |
| 58 | + if candle["close"] >= candle["open"]: |
| 59 | + bull_wicks.extend(wick) |
| 60 | + bull_bodies.extend(body) |
| 61 | + else: |
| 62 | + bear_wicks.extend(wick) |
| 63 | + bear_bodies.extend(body) |
| 64 | + |
| 65 | +# --- Style: fully colorblind-safe palette --- |
| 66 | +BULL, BEAR = "#2271B3", "#D66B27" |
| 67 | +PEAK_CLR, LOW_CLR = "#7B4FA0", "#2A7B7B" # Purple & teal (colorblind-safe) |
| 68 | +MA_CLR = "#555555" |
| 69 | +date_map = {i + 1: dates[i] for i in range(n_days)} |
55 | 70 |
|
56 | | -# Custom style for 4800x2700 output with larger fonts |
57 | 71 | custom_style = Style( |
58 | 72 | background="white", |
59 | | - plot_background="white", |
60 | | - foreground="#333333", |
61 | | - foreground_strong="#333333", |
62 | | - foreground_subtle="#666666", |
63 | | - colors=tuple(colors_list), |
| 73 | + plot_background="#f4f4f0", |
| 74 | + foreground="#2a2a2a", |
| 75 | + foreground_strong="#1a1a1a", |
| 76 | + foreground_subtle="#dedede", |
| 77 | + colors=(BULL, BEAR, MA_CLR, BULL, BEAR, PEAK_CLR, LOW_CLR), |
64 | 78 | title_font_size=72, |
65 | | - label_font_size=48, |
66 | | - major_label_font_size=44, |
| 79 | + label_font_size=44, |
| 80 | + major_label_font_size=40, |
67 | 81 | legend_font_size=44, |
68 | | - value_font_size=36, |
| 82 | + value_font_size=34, |
69 | 83 | ) |
70 | 84 |
|
71 | | -# Create XY chart for candlesticks |
| 85 | +# --- Chart configuration --- |
| 86 | +WICK_W, BODY_W, MA_W = 20, 76, 6 |
| 87 | + |
72 | 88 | chart = pygal.XY( |
73 | 89 | style=custom_style, |
74 | 90 | width=4800, |
75 | 91 | height=2700, |
76 | | - title="candlestick-basic · pygal · pyplots.ai", |
77 | | - x_title="Trading Day", |
| 92 | + title="candlestick-basic \u00b7 pygal \u00b7 pyplots.ai", |
| 93 | + x_title="Date", |
78 | 94 | y_title="Price ($)", |
79 | 95 | show_dots=False, |
80 | 96 | show_x_guides=False, |
81 | 97 | show_y_guides=True, |
82 | | - range=(y_min, y_max), |
| 98 | + allow_interruptions=True, |
| 99 | + range=(min(d["low"] for d in ohlc_data) - 2, max(d["high"] for d in ohlc_data) + 3), |
83 | 100 | xrange=(0, n_days + 1), |
84 | | - show_legend=True, |
85 | | - legend_at_bottom=True, |
86 | | - legend_box_size=32, |
87 | | - margin=60, |
88 | | - spacing=40, |
| 101 | + legend_box_size=30, |
| 102 | + margin=40, |
| 103 | + spacing=25, |
| 104 | + tooltip_border_radius=8, |
| 105 | + truncate_legend=-1, |
| 106 | + value_formatter=lambda x: f"${x:.2f}", |
89 | 107 | ) |
90 | 108 |
|
91 | | -# Stroke widths - increased for better visibility on large canvas |
92 | | -wick_width = 16 |
93 | | -body_width = 70 |
| 109 | +chart.x_labels = [1, 5, 10, 15, 20, 25, 30] |
| 110 | +chart.x_value_formatter = lambda x: date_map[int(round(x))].strftime("%b %d") if int(round(x)) in date_map else "" |
94 | 111 |
|
95 | | -# Track legend state |
96 | | -bullish_legend_done = False |
97 | | -bearish_legend_done = False |
| 112 | +# Series 0-1: Wicks (background, hidden from legend) |
| 113 | +chart.add(None, bull_wicks, stroke=True, show_dots=False, stroke_style={"width": WICK_W, "linecap": "butt"}) |
| 114 | +chart.add(None, bear_wicks, stroke=True, show_dots=False, stroke_style={"width": WICK_W, "linecap": "butt"}) |
98 | 115 |
|
99 | | -# Add each candlestick body as a separate series |
100 | | -for candle in ohlc_data: |
101 | | - day = candle["day"] |
102 | | - is_bullish = candle["close"] >= candle["open"] |
103 | | - |
104 | | - # Determine legend label |
105 | | - if is_bullish and not bullish_legend_done: |
106 | | - label = "Bullish (Up)" |
107 | | - bullish_legend_done = True |
108 | | - elif not is_bullish and not bearish_legend_done: |
109 | | - label = "Bearish (Down)" |
110 | | - bearish_legend_done = True |
111 | | - else: |
112 | | - label = None |
113 | | - |
114 | | - # Body (open to close) |
115 | | - chart.add( |
116 | | - label, |
117 | | - [(day, candle["open"]), (day, candle["close"])], |
118 | | - stroke=True, |
119 | | - show_dots=False, |
120 | | - stroke_style={"width": body_width, "linecap": "butt"}, |
121 | | - ) |
| 116 | +# Series 2: Moving average trend line |
| 117 | +chart.add("5-Day MA", ma_points, stroke=True, show_dots=False, stroke_style={"width": MA_W, "linecap": "round"}) |
122 | 118 |
|
123 | | -# Add each wick as a separate series (no legends) |
124 | | -for candle in ohlc_data: |
125 | | - day = candle["day"] |
126 | | - chart.add( |
127 | | - None, |
128 | | - [(day, candle["low"]), (day, candle["high"])], |
129 | | - stroke=True, |
130 | | - show_dots=False, |
131 | | - stroke_style={"width": wick_width, "linecap": "butt"}, |
| 119 | +# Series 3-4: Candlestick bodies (foreground) |
| 120 | +chart.add("Bullish (Up)", bull_bodies, stroke=True, show_dots=False, stroke_style={"width": BODY_W, "linecap": "butt"}) |
| 121 | +chart.add( |
| 122 | + "Bearish (Down)", bear_bodies, stroke=True, show_dots=False, stroke_style={"width": BODY_W, "linecap": "butt"} |
| 123 | +) |
| 124 | + |
| 125 | +# Series 5-6: Price extreme markers (colorblind-safe purple & teal) |
| 126 | +chart.add(f"Peak ${peak['high']:.2f}", [(peak["day"], peak["high"])], stroke=False, show_dots=True, dots_size=16) |
| 127 | +chart.add(f"Low ${trough['low']:.2f}", [(trough["day"], trough["low"])], stroke=False, show_dots=True, dots_size=16) |
| 128 | + |
| 129 | +# --- Render: inline stroke styles for cairosvg compatibility --- |
| 130 | +# cairosvg ignores CSS class-based stroke properties; inline them on every path per series |
| 131 | +svg = chart.render(is_unicode=True) |
| 132 | +series_strokes = { |
| 133 | + 0: (WICK_W, "butt"), |
| 134 | + 1: (WICK_W, "butt"), |
| 135 | + 2: (MA_W, "round"), |
| 136 | + 3: (BODY_W, "butt"), |
| 137 | + 4: (BODY_W, "butt"), |
| 138 | +} |
| 139 | +for sid, (width, cap) in series_strokes.items(): |
| 140 | + style_attr = f' style="stroke-width:{width};stroke-linecap:{cap}"' |
| 141 | + svg = re.sub( |
| 142 | + rf'(class="series serie-{sid} color-{sid}"[^>]*>.*?</g>)', |
| 143 | + lambda m, s=style_attr: m.group(0).replace('class="line reactive nofill"', 'class="line reactive nofill"' + s), |
| 144 | + svg, |
| 145 | + count=1, |
| 146 | + flags=re.DOTALL, |
132 | 147 | ) |
133 | 148 |
|
134 | | -# Save outputs |
135 | | -chart.render_to_png("plot.png") |
136 | | -chart.render_to_file("plot.html") |
| 149 | +cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to="plot.png") |
| 150 | +with open("plot.html", "w") as f: |
| 151 | + f.write(chart.render(is_unicode=True)) |
0 commit comments