Skip to content

Commit fcc9533

Browse files
feat(letsplot): implement indicator-ema (#7347)
## Implementation: `indicator-ema` - python/letsplot Implements the **python/letsplot** version of `indicator-ema`. **File:** `plots/indicator-ema/implementations/python/letsplot.py` **Parent Issue:** #3652 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26077017260)* --------- 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 1129e58 commit fcc9533

2 files changed

Lines changed: 234 additions & 156 deletions

File tree

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
indicator-ema: Exponential Moving Average (EMA) Indicator Chart
3-
Library: letsplot 4.8.2 | Python 3.13.11
4-
Quality: 92/100 | Created: 2026-01-11
3+
Library: letsplot 4.9.0 | Python 3.13.13
4+
Quality: 92/100 | Updated: 2026-05-19
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
911
from lets_plot import * # noqa: F403
@@ -12,69 +14,101 @@
1214

1315
LetsPlot.setup_html() # noqa: F405
1416

15-
# Data - Generate realistic stock price data with EMA indicators
16-
np.random.seed(42)
17+
# Theme tokens
18+
THEME = os.getenv("ANYPLOT_THEME", "light")
19+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
20+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
21+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
22+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
23+
RULE = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"
1724

18-
# Create 120 trading days
19-
dates = pd.date_range("2024-01-02", periods=120, freq="B")
25+
# Okabe-Ito colors for Close Price, EMA 12, EMA 26
26+
COLORS = {"Close Price": "#009E73", "EMA 12": "#D55E00", "EMA 26": "#0072B2"}
2027

21-
# Generate price data with trend and noise
28+
# Data
29+
np.random.seed(42)
30+
dates = pd.date_range("2024-01-02", periods=120, freq="B")
2231
returns = np.random.normal(0.001, 0.015, 120)
2332
price = 100 * np.cumprod(1 + returns)
2433

25-
# Calculate EMA using pandas ewm (exponential weighted mean)
2634
price_series = pd.Series(price)
2735
ema_12 = price_series.ewm(span=12, adjust=False).mean().values
2836
ema_26 = price_series.ewm(span=26, adjust=False).mean().values
2937

30-
# Create DataFrame
31-
df = pd.DataFrame({"date": dates, "date_num": range(len(dates)), "close": price, "ema_12": ema_12, "ema_26": ema_26})
38+
df = pd.DataFrame({"date_num": range(120), "close": price, "ema_12": ema_12, "ema_26": ema_26})
39+
40+
# Detect EMA-12/EMA-26 crossovers for signal annotations
41+
diff = ema_12 - ema_26
42+
sign_changes = np.where(np.diff(np.sign(diff)) != 0)[0]
43+
bullish_crosses = [int(i) for i in sign_changes if diff[i + 1] > 0]
44+
bearish_crosses = [int(i) for i in sign_changes if diff[i + 1] < 0]
3245

33-
# Reshape for plotting with lets-plot (long format)
34-
df_price = df[["date_num", "close"]].copy()
46+
df_price = df[["date_num", "close"]].rename(columns={"close": "value"}).copy()
3547
df_price["series"] = "Close Price"
36-
df_price = df_price.rename(columns={"close": "value"})
3748

38-
df_ema12 = df[["date_num", "ema_12"]].copy()
49+
df_ema12 = df[["date_num", "ema_12"]].rename(columns={"ema_12": "value"}).copy()
3950
df_ema12["series"] = "EMA 12"
40-
df_ema12 = df_ema12.rename(columns={"ema_12": "value"})
4151

42-
df_ema26 = df[["date_num", "ema_26"]].copy()
52+
df_ema26 = df[["date_num", "ema_26"]].rename(columns={"ema_26": "value"}).copy()
4353
df_ema26["series"] = "EMA 26"
44-
df_ema26 = df_ema26.rename(columns={"ema_26": "value"})
4554

46-
df_long = pd.concat([df_price, df_ema12, df_ema26], ignore_index=True)
55+
df_ema_only = pd.concat([df_ema12, df_ema26], ignore_index=True)
4756

48-
# Create x-axis labels (show every 20th date)
4957
date_labels = {i: dates[i].strftime("%b %d") for i in range(0, 120, 20)}
5058

51-
# Plot
59+
# Theme — Y-axis-only major grid (style guide: line charts use Y grid only)
60+
anyplot_theme = theme( # noqa: F405
61+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG), # noqa: F405
62+
panel_background=element_rect(fill=PAGE_BG), # noqa: F405
63+
panel_grid_major_y=element_line(color=RULE, size=0.5), # noqa: F405
64+
panel_grid_major_x=element_blank(), # noqa: F405
65+
panel_grid_minor=element_blank(), # noqa: F405
66+
axis_title=element_text(size=20, color=INK), # noqa: F405
67+
axis_text=element_text(size=16, color=INK_SOFT), # noqa: F405
68+
axis_line=element_line(color=INK_SOFT), # noqa: F405
69+
plot_title=element_text(size=24, color=INK), # noqa: F405
70+
plot_subtitle=element_text(size=16, color=INK_SOFT), # noqa: F405
71+
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT), # noqa: F405
72+
legend_text=element_text(size=16, color=INK_SOFT), # noqa: F405
73+
legend_title=element_text(size=18, color=INK), # noqa: F405
74+
legend_position="right",
75+
)
76+
77+
# Plot — crossover vlines first so lines render on top; interactive tooltips (letsplot-exclusive)
5278
plot = (
53-
ggplot(df_long, aes(x="date_num", y="value", color="series")) # noqa: F405
54-
+ geom_line(aes(size="series")) # noqa: F405
55-
+ scale_color_manual(values=["#306998", "#FFD43B", "#DC2626"], name="Series") # noqa: F405
56-
+ scale_size_manual(values=[2.5, 1.5, 1.5], name="Series") # noqa: F405
79+
ggplot(mapping=aes(x="date_num", y="value", color="series")) # noqa: F405
80+
+ geom_line( # noqa: F405
81+
data=df_price,
82+
size=2.5,
83+
alpha=0.85,
84+
tooltips=layer_tooltips().title("Close Price").line("$@value"), # noqa: F405
85+
)
86+
+ geom_line( # noqa: F405
87+
data=df_ema_only,
88+
size=1.5,
89+
tooltips=layer_tooltips().title("@series").line("$@value"), # noqa: F405
90+
)
91+
+ scale_color_manual(values=COLORS, name="Series") # noqa: F405
5792
+ scale_x_continuous( # noqa: F405
5893
breaks=list(date_labels.keys()), labels=list(date_labels.values())
5994
)
6095
+ labs( # noqa: F405
61-
x="Date", y="Price (USD)", title="indicator-ema · letsplot · pyplots.ai"
96+
x="Date",
97+
y="Price (USD)",
98+
title="indicator-ema · python · letsplot · anyplot.ai",
99+
subtitle="EMA-12 × EMA-26 crossovers — dashed lines mark bullish ↑ and bearish ↓ signals",
62100
)
63101
+ theme_minimal() # noqa: F405
64-
+ theme( # noqa: F405
65-
axis_title=element_text(size=20), # noqa: F405
66-
axis_text=element_text(size=16), # noqa: F405
67-
plot_title=element_text(size=24), # noqa: F405
68-
legend_text=element_text(size=16), # noqa: F405
69-
legend_title=element_text(size=18), # noqa: F405
70-
legend_position="right",
71-
panel_grid=element_line(color="#CCCCCC", size=0.5, linetype="dashed"), # noqa: F405
72-
)
102+
+ anyplot_theme
73103
+ ggsize(1600, 900) # noqa: F405
74104
)
75105

76-
# Save PNG (scale 3x to get 4800 x 2700 px)
77-
export_ggsave(plot, "plot.png", path=".", scale=3)
106+
# Annotate crossover signals after base plot is built
107+
for x in bullish_crosses:
108+
plot = plot + geom_vline(xintercept=x, color="#009E73", linetype="dashed", size=0.8, alpha=0.4) # noqa: F405
109+
for x in bearish_crosses:
110+
plot = plot + geom_vline(xintercept=x, color="#D55E00", linetype="dashed", size=0.8, alpha=0.4) # noqa: F405
78111

79-
# Save HTML for interactive version
80-
export_ggsave(plot, "plot.html", path=".")
112+
# Save
113+
export_ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)
114+
export_ggsave(plot, f"plot-{THEME}.html", path=".")

0 commit comments

Comments
 (0)