|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import pandas as pd |
9 | 11 | from lets_plot import * # noqa: F403 |
|
12 | 14 |
|
13 | 15 | LetsPlot.setup_html() # noqa: F405 |
14 | 16 |
|
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)" |
17 | 24 |
|
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"} |
20 | 27 |
|
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") |
22 | 31 | returns = np.random.normal(0.001, 0.015, 120) |
23 | 32 | price = 100 * np.cumprod(1 + returns) |
24 | 33 |
|
25 | | -# Calculate EMA using pandas ewm (exponential weighted mean) |
26 | 34 | price_series = pd.Series(price) |
27 | 35 | ema_12 = price_series.ewm(span=12, adjust=False).mean().values |
28 | 36 | ema_26 = price_series.ewm(span=26, adjust=False).mean().values |
29 | 37 |
|
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] |
32 | 45 |
|
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() |
35 | 47 | df_price["series"] = "Close Price" |
36 | | -df_price = df_price.rename(columns={"close": "value"}) |
37 | 48 |
|
38 | | -df_ema12 = df[["date_num", "ema_12"]].copy() |
| 49 | +df_ema12 = df[["date_num", "ema_12"]].rename(columns={"ema_12": "value"}).copy() |
39 | 50 | df_ema12["series"] = "EMA 12" |
40 | | -df_ema12 = df_ema12.rename(columns={"ema_12": "value"}) |
41 | 51 |
|
42 | | -df_ema26 = df[["date_num", "ema_26"]].copy() |
| 52 | +df_ema26 = df[["date_num", "ema_26"]].rename(columns={"ema_26": "value"}).copy() |
43 | 53 | df_ema26["series"] = "EMA 26" |
44 | | -df_ema26 = df_ema26.rename(columns={"ema_26": "value"}) |
45 | 54 |
|
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) |
47 | 56 |
|
48 | | -# Create x-axis labels (show every 20th date) |
49 | 57 | date_labels = {i: dates[i].strftime("%b %d") for i in range(0, 120, 20)} |
50 | 58 |
|
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) |
52 | 78 | 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 |
57 | 92 | + scale_x_continuous( # noqa: F405 |
58 | 93 | breaks=list(date_labels.keys()), labels=list(date_labels.values()) |
59 | 94 | ) |
60 | 95 | + 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", |
62 | 100 | ) |
63 | 101 | + 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 |
73 | 103 | + ggsize(1600, 900) # noqa: F405 |
74 | 104 | ) |
75 | 105 |
|
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 |
78 | 111 |
|
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