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.

""" anyplot.ai
point-and-figure-basic: Point and Figure Chart
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 80/100 | Updated: 2026-05-20
"""
import os
import numpy as np
import pandas as pd
from lets_plot import (
LetsPlot,
aes,
element_blank,
element_line,
element_rect,
element_text,
geom_abline,
geom_text,
ggplot,
ggsize,
labs,
layer_tooltips,
scale_color_manual,
scale_x_continuous,
scale_y_continuous,
theme,
theme_minimal,
)
from lets_plot.export import ggsave
LetsPlot.setup_html()
# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
# Data
np.random.seed(42)
n_days = 300
returns = np.random.normal(0.001, 0.02, n_days)
returns[50:100] += 0.005 # Uptrend
returns[150:200] -= 0.004 # Downtrend
returns[250:280] += 0.006 # Uptrend
prices = 100 * np.cumprod(1 + returns)
# Point and Figure algorithm (box_size=$2, 3-box reversal)
box_size = 2.0
reversal = 3
pnf_rows = []
current_box = int(prices[0] / box_size) * box_size
direction = None
column_idx = 0
for price in prices:
price_box = int(price / box_size) * box_size
if direction is None:
if price_box > current_box:
direction = "up"
for b in range(int(current_box / box_size), int(price_box / box_size) + 1):
pnf_rows.append((column_idx, b * box_size, "X", "up"))
current_box = price_box
elif price_box < current_box:
direction = "down"
for b in range(int(price_box / box_size), int(current_box / box_size) + 1):
pnf_rows.append((column_idx, b * box_size, "O", "down"))
current_box = price_box
elif direction == "up":
if price_box > current_box:
for b in range(int(current_box / box_size) + 1, int(price_box / box_size) + 1):
pnf_rows.append((column_idx, b * box_size, "X", "up"))
current_box = price_box
elif price_box <= current_box - reversal * box_size:
column_idx += 1
for b in range(int(price_box / box_size), int(current_box / box_size)):
pnf_rows.append((column_idx, b * box_size, "O", "down"))
current_box = price_box
direction = "down"
elif direction == "down":
if price_box < current_box:
for b in range(int(price_box / box_size), int(current_box / box_size)):
pnf_rows.append((column_idx, b * box_size, "O", "down"))
current_box = price_box
elif price_box >= current_box + reversal * box_size:
column_idx += 1
for b in range(int(current_box / box_size) + 1, int(price_box / box_size) + 1):
pnf_rows.append((column_idx, b * box_size, "X", "up"))
current_box = price_box
direction = "up"
df_pnf = pd.DataFrame(pnf_rows, columns=["column", "price", "symbol", "direction"])
df_pnf = df_pnf.drop_duplicates(subset=["column", "price"])
# 45-degree trend lines: 1 box per column in chart coordinates
min_idx = df_pnf["price"].idxmin()
min_price_val = df_pnf.loc[min_idx, "price"]
min_col_val = df_pnf.loc[min_idx, "column"]
max_idx = df_pnf["price"].idxmax()
max_price_val = df_pnf.loc[max_idx, "price"]
max_col_val = df_pnf.loc[max_idx, "column"]
# Ascending support: anchored at chart minimum, slope = +box_size
support_intercept = min_price_val - box_size * min_col_val
# Descending resistance: anchored at chart maximum, slope = -box_size
resistance_intercept = max_price_val + box_size * max_col_val
# Plot
plot = (
ggplot(df_pnf, aes(x="column", y="price", label="symbol", color="direction"))
+ geom_abline(slope=box_size, intercept=support_intercept, color=INK_SOFT, linetype="dashed", size=0.8)
+ geom_abline(slope=-box_size, intercept=resistance_intercept, color=INK_SOFT, linetype="dashed", size=0.8)
+ geom_text(
size=12,
fontface="bold",
tooltips=layer_tooltips().line("Column: @column").line("Price: $@price").line("Signal: @symbol"),
)
+ scale_color_manual(values={"up": "#009E73", "down": "#AE3030"}) # imprint red for down
+ scale_x_continuous(name="Column (Reversal Number)")
+ scale_y_continuous(name="Price ($)")
+ labs(title="point-and-figure-basic · python · letsplot · anyplot.ai")
+ theme_minimal()
+ theme(
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
plot_title=element_text(size=16, color=INK),
axis_title=element_text(size=12, color=INK),
axis_text=element_text(size=10, color=INK_SOFT),
axis_line=element_line(color=INK_SOFT),
legend_position="none",
panel_grid_major_y=element_line(color=INK_SOFT, size=0.2),
panel_grid_minor_y=element_blank(),
panel_grid_major_x=element_blank(),
panel_grid_minor_x=element_blank(),
)
+ ggsize(800, 450)
)
# Save
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")
Part of Point and Figure Chart on anyplot.ai.