Point and Figure Chart — Matplotlib

A Point and Figure (P&F) chart is a price-action focused visualization that uses columns of X's (rising prices) and O's (falling prices) to display significant price movements while filtering out time and minor fluctuations. Unlike traditional time-based charts, P&F charts only plot a new symbol when price moves by a defined box size, and only start a new column when price reverses by a specified number of boxes (typically 3). This makes it ideal for identifying clear trends, support/resistance levels, and generating trading signals.

Point and Figure Chart rendered with Matplotlib

Python source (Matplotlib)

""" anyplot.ai
point-and-figure-basic: Point and Figure Chart
Library: matplotlib 3.10.9 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-20
"""

import os
import sys


# Remove the script's directory from sys.path to prevent shadowing the installed matplotlib package
if sys.path and sys.path[0] and "implementations" in sys.path[0]:
    sys.path.pop(0)

import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# imprint semantic anchors: green for rising (X), red for falling (O)
X_COLOR = "#009E73"
O_COLOR = "#AE3030"

# Data: synthetic stock price with upward drift
np.random.seed(42)
n_days = 300
close = 100.0 * np.cumprod(1 + np.random.normal(0.001, 0.02, n_days))

# P&F parameters
box_size = 2.0
reversal = 3

# Build P&F columns
columns = []
current_col = None
current_price = None

for price in close:
    if current_col is None:
        current_col = {
            "type": "X",
            "start": np.floor(price / box_size) * box_size,
            "end": np.floor(price / box_size) * box_size,
        }
        current_price = current_col["end"]
        continue

    if current_col["type"] == "X":
        new_high = np.floor(price / box_size) * box_size
        if new_high > current_col["end"]:
            current_col["end"] = new_high
            current_price = new_high
        elif price <= current_price - reversal * box_size:
            columns.append(current_col.copy())
            current_col = {
                "type": "O",
                "start": current_col["end"] - box_size,
                "end": np.ceil(price / box_size) * box_size,
            }
            current_price = current_col["end"]
    else:
        new_low = np.ceil(price / box_size) * box_size
        if new_low < current_col["end"]:
            current_col["end"] = new_low
            current_price = new_low
        elif price >= current_price + reversal * box_size:
            columns.append(current_col.copy())
            current_col = {
                "type": "X",
                "start": current_col["end"] + box_size,
                "end": np.floor(price / box_size) * box_size,
            }
            current_price = current_col["end"]

if current_col is not None:
    columns.append(current_col)

n_cols = len(columns)
all_prices = [p for col in columns for p in [col["start"], col["end"]]]
y_min = min(all_prices) - 2 * box_size
y_max = max(all_prices) + 2 * box_size

# Find the most significant buy signal: X column exceeding prior X column peak by most boxes
prev_x_high = None
best_breakout = None
for i, col in enumerate(columns):
    if col["type"] == "X":
        col_high = max(col["start"], col["end"])
        if prev_x_high is not None and col_high > prev_x_high:
            excess = col_high - prev_x_high
            if best_breakout is None or excess > best_breakout[3]:
                best_breakout = (i, prev_x_high, col_high, excess)
        prev_x_high = col_high

# Trend line anchors
# Support: from overall lowest low, rising 45° (one box per column to the right)
all_lows = [(i, min(col["start"], col["end"])) for i, col in enumerate(columns)]
lowest_col_idx, lowest_price = min(all_lows, key=lambda x: x[1])

# Resistance: from top of first column, falling 45° (one box per column to the right)
first_col_high = max(columns[0]["start"], columns[0]["end"])

# Plot
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# Draw X and O symbols using patheffects for crisp separation between dense symbols
symbol_stroke = [pe.withStroke(linewidth=2, foreground=PAGE_BG)]
for col_idx, col in enumerate(columns):
    lo = min(col["start"], col["end"])
    hi = max(col["start"], col["end"])
    char = "X" if col["type"] == "X" else "O"
    color = X_COLOR if col["type"] == "X" else O_COLOR
    for box_price in np.arange(lo, hi + box_size / 2, box_size):
        ax.text(
            col_idx,
            box_price,
            char,
            fontsize=8,
            fontweight="bold",
            ha="center",
            va="center",
            color=color,
            path_effects=symbol_stroke,
        )

# Support trend line: 45° upward from the overall lowest low
support_x = np.array([0, n_cols - 1])
support_y = np.array(
    [lowest_price - lowest_col_idx * box_size, lowest_price + (n_cols - 1 - lowest_col_idx) * box_size]
)
ax.plot(support_x, support_y, color=X_COLOR, linewidth=0.8, linestyle="--", alpha=0.5, zorder=0)

# Resistance trend line: 45° downward from the top of the first column
resistance_x = np.array([0, n_cols - 1])
resistance_y = np.array([first_col_high, first_col_high - (n_cols - 1) * box_size])
ax.plot(resistance_x, resistance_y, color=O_COLOR, linewidth=0.8, linestyle="--", alpha=0.5, zorder=0)

# Axes configuration
ax.set_xlim(-0.5, n_cols - 0.5)
ax.set_ylim(y_min, y_max)

# Major ticks at $10 intervals (labeled + gridlines); minor ticks at $2 box intervals (tick marks only)
major_ticks = np.arange(np.floor(y_min / 10) * 10, np.ceil(y_max / 10) * 10 + 10, 10)
minor_ticks = np.arange(
    np.floor(y_min / box_size) * box_size, np.ceil(y_max / box_size) * box_size + box_size, box_size
)
ax.set_yticks(major_ticks)
ax.set_yticks(minor_ticks, minor=True)
ax.tick_params(axis="y", which="major", labelsize=8, colors=INK_SOFT, length=4)
ax.tick_params(axis="y", which="minor", length=2, colors=INK_SOFT, labelsize=0)
ax.tick_params(axis="x", labelsize=8, colors=INK_SOFT)

# Style
ax.yaxis.grid(True, which="major", alpha=0.15, linewidth=0.6, color=INK)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
for s in ("left", "bottom"):
    ax.spines[s].set_color(INK_SOFT)

ax.set_xlabel("Column (Reversal Number)", fontsize=10, color=INK)
ax.set_ylabel("Price ($)", fontsize=10, color=INK)
ax.set_title("point-and-figure-basic · python · matplotlib · anyplot.ai", fontsize=12, fontweight="medium", color=INK)

# Annotate the most prominent buy signal — X column breakout above prior X peak (narrative focal point)
if best_breakout is not None:
    b_col, _, b_col_high, _ = best_breakout
    text_offset = 2.5 if b_col < n_cols * 0.65 else -3.5
    ax.annotate(
        "Buy Signal\n(Column Breakout)",
        xy=(b_col, b_col_high),
        xytext=(b_col + text_offset, b_col_high + 4),
        fontsize=7,
        fontweight="bold",
        color=X_COLOR,
        ha="center",
        arrowprops={"arrowstyle": "->", "color": X_COLOR, "lw": 0.8},
        bbox={"facecolor": ELEVATED_BG, "edgecolor": X_COLOR, "alpha": 0.9, "boxstyle": "round,pad=0.3"},
    )

# Legend using color patches (avoids marker/text mismatch)
x_patch = mpatches.Patch(facecolor=X_COLOR, label="Rising (X)", edgecolor=PAGE_BG)
o_patch = mpatches.Patch(facecolor=O_COLOR, label="Falling (O)", edgecolor=PAGE_BG)
leg = ax.legend(handles=[x_patch, o_patch], fontsize=8, loc="upper left")
leg.get_frame().set_facecolor(ELEVATED_BG)
leg.get_frame().set_edgecolor(INK_SOFT)
plt.setp(leg.get_texts(), color=INK_SOFT)

plt.tight_layout()
plt.savefig(f"plot-{THEME}.png", dpi=400, bbox_inches="tight", facecolor=PAGE_BG)

Part of Point and Figure Chart on anyplot.ai.

Other implementations