Skip to content

Commit ce5d96f

Browse files
update(candlestick-basic): pygal — comprehensive quality review (#4395)
## Summary Updated **pygal** implementation for **candlestick-basic**. **Changes:** Comprehensive quality review — improved visual design, data quality, code patterns, and library-specific features. ## Test Plan - [x] Preview images uploaded to GCS staging - [x] Implementation file passes ruff format/check - [x] Metadata YAML updated with current versions - [ ] Automated review triggered --- Generated with [Claude Code](https://claude.com/claude-code) `/update` command --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 1af7274 commit ce5d96f

File tree

2 files changed

+257
-190
lines changed

2 files changed

+257
-190
lines changed
Lines changed: 102 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,151 @@
11
""" pyplots.ai
22
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
55
"""
66

7+
import re
8+
from datetime import datetime, timedelta
9+
10+
import cairosvg
711
import numpy as np
812
import pygal
913
from pygal.style import Style
1014

1115

12-
# Data - Stock OHLC data for 30 trading days
16+
# --- Data: 30 trading days of OHLC stock prices ---
1317
np.random.seed(42)
1418
n_days = 30
1519

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+
1729
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
1931
price_series = base_price * np.cumprod(1 + returns / 100)
2032

2133
ohlc_data = []
2234
for i, close in enumerate(price_series):
23-
# Generate realistic intraday range
2435
volatility = np.abs(np.random.randn()) * 2 + 0.5
2536
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"]
3238
high = max(open_price, close) + np.random.rand() * intraday_range
3339
low = min(open_price, close) - np.random.rand() * intraday_range
34-
3540
ohlc_data.append({"day": i + 1, "open": open_price, "high": high, "low": low, "close": close})
3641

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)]
4245

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 = [], []
4653

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)
5254
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)}
5570

56-
# Custom style for 4800x2700 output with larger fonts
5771
custom_style = Style(
5872
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),
6478
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,
6781
legend_font_size=44,
68-
value_font_size=36,
82+
value_font_size=34,
6983
)
7084

71-
# Create XY chart for candlesticks
85+
# --- Chart configuration ---
86+
WICK_W, BODY_W, MA_W = 20, 76, 6
87+
7288
chart = pygal.XY(
7389
style=custom_style,
7490
width=4800,
7591
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",
7894
y_title="Price ($)",
7995
show_dots=False,
8096
show_x_guides=False,
8197
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),
83100
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}",
89107
)
90108

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 ""
94111

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"})
98115

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"})
122118

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,
132147
)
133148

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

Comments
 (0)