|
| 1 | +""" pyplots.ai |
| 2 | +point-and-figure-basic: Point and Figure Chart |
| 3 | +Library: bokeh 3.8.2 | Python 3.13.11 |
| 4 | +Quality: 91/100 | Created: 2026-01-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from bokeh.io import export_png, save |
| 10 | +from bokeh.models import ColumnDataSource |
| 11 | +from bokeh.plotting import figure |
| 12 | + |
| 13 | + |
| 14 | +# Generate synthetic stock price data |
| 15 | +np.random.seed(42) |
| 16 | +n_days = 300 |
| 17 | + |
| 18 | +# Start price and generate realistic daily returns |
| 19 | +start_price = 100 |
| 20 | +daily_returns = np.random.normal(0.0005, 0.015, n_days) |
| 21 | + |
| 22 | +# Add some trending periods |
| 23 | +daily_returns[50:80] += 0.003 # Uptrend |
| 24 | +daily_returns[100:140] -= 0.004 # Downtrend |
| 25 | +daily_returns[180:220] += 0.0035 # Uptrend |
| 26 | +daily_returns[240:280] -= 0.003 # Downtrend |
| 27 | + |
| 28 | +close_prices = start_price * np.cumprod(1 + daily_returns) |
| 29 | + |
| 30 | +# Generate high/low based on close |
| 31 | +volatility = np.abs(np.random.normal(0, 0.01, n_days)) |
| 32 | +high_prices = close_prices * (1 + volatility) |
| 33 | +low_prices = close_prices * (1 - volatility) |
| 34 | + |
| 35 | +dates = pd.date_range("2024-01-01", periods=n_days, freq="D") |
| 36 | + |
| 37 | +df = pd.DataFrame({"date": dates, "high": high_prices, "low": low_prices, "close": close_prices}) |
| 38 | + |
| 39 | +# Point and Figure calculation |
| 40 | +box_size = 2.0 # Each box represents $2 |
| 41 | +reversal = 3 # 3-box reversal |
| 42 | + |
| 43 | +# Calculate P&F columns |
| 44 | +columns = [] |
| 45 | +current_direction = None # 'X' for up, 'O' for down |
| 46 | +current_column_start = None |
| 47 | +current_column_end = None |
| 48 | + |
| 49 | +# Initialize with first price |
| 50 | +first_price = df["close"].iloc[0] |
| 51 | +box_start = np.floor(first_price / box_size) * box_size |
| 52 | + |
| 53 | +for _i, row in df.iterrows(): |
| 54 | + price = row["close"] |
| 55 | + box_price = np.floor(price / box_size) * box_size |
| 56 | + |
| 57 | + if current_direction is None: |
| 58 | + # Initialize first direction based on next movement |
| 59 | + current_column_start = box_start |
| 60 | + current_column_end = box_start |
| 61 | + # Wait for significant move |
| 62 | + if price >= box_start + box_size: |
| 63 | + current_direction = "X" |
| 64 | + current_column_end = box_price |
| 65 | + elif price <= box_start - box_size: |
| 66 | + current_direction = "O" |
| 67 | + current_column_end = box_price |
| 68 | + else: |
| 69 | + if current_direction == "X": |
| 70 | + # In uptrend |
| 71 | + if box_price >= current_column_end + box_size: |
| 72 | + # Continue up |
| 73 | + current_column_end = box_price |
| 74 | + elif box_price <= current_column_end - reversal * box_size: |
| 75 | + # Reversal down - save current column and start new O column |
| 76 | + columns.append({"type": "X", "start": current_column_start, "end": current_column_end}) |
| 77 | + current_direction = "O" |
| 78 | + current_column_start = current_column_end - box_size |
| 79 | + current_column_end = box_price |
| 80 | + else: |
| 81 | + # In downtrend |
| 82 | + if box_price <= current_column_end - box_size: |
| 83 | + # Continue down |
| 84 | + current_column_end = box_price |
| 85 | + elif box_price >= current_column_end + reversal * box_size: |
| 86 | + # Reversal up - save current column and start new X column |
| 87 | + columns.append({"type": "O", "start": current_column_start, "end": current_column_end}) |
| 88 | + current_direction = "X" |
| 89 | + current_column_start = current_column_end + box_size |
| 90 | + current_column_end = box_price |
| 91 | + |
| 92 | +# Save the last column |
| 93 | +if current_direction is not None: |
| 94 | + columns.append({"type": current_direction, "start": current_column_start, "end": current_column_end}) |
| 95 | + |
| 96 | +# Prepare data for plotting |
| 97 | +x_data = [] |
| 98 | +o_data = [] |
| 99 | + |
| 100 | +for col_idx, col in enumerate(columns): |
| 101 | + if col["type"] == "X": |
| 102 | + start = min(col["start"], col["end"]) |
| 103 | + end = max(col["start"], col["end"]) |
| 104 | + boxes = np.arange(start, end + box_size / 2, box_size) |
| 105 | + for box in boxes: |
| 106 | + x_data.append({"col": col_idx, "price": box}) |
| 107 | + else: |
| 108 | + start = max(col["start"], col["end"]) |
| 109 | + end = min(col["start"], col["end"]) |
| 110 | + boxes = np.arange(end, start + box_size / 2, box_size) |
| 111 | + for box in boxes: |
| 112 | + o_data.append({"col": col_idx, "price": box}) |
| 113 | + |
| 114 | +# Create figure |
| 115 | +p = figure( |
| 116 | + width=4800, |
| 117 | + height=2700, |
| 118 | + title="point-and-figure-basic · bokeh · pyplots.ai", |
| 119 | + x_axis_label="Column (Reversal)", |
| 120 | + y_axis_label="Price ($)", |
| 121 | +) |
| 122 | + |
| 123 | +# Style settings - scaled for 4800x2700 canvas |
| 124 | +p.title.text_font_size = "48pt" |
| 125 | +p.xaxis.axis_label_text_font_size = "36pt" |
| 126 | +p.yaxis.axis_label_text_font_size = "36pt" |
| 127 | +p.xaxis.major_label_text_font_size = "28pt" |
| 128 | +p.yaxis.major_label_text_font_size = "28pt" |
| 129 | + |
| 130 | +# Background and grid |
| 131 | +p.background_fill_color = "#fafafa" |
| 132 | +p.grid.grid_line_alpha = 0.3 |
| 133 | +p.grid.grid_line_dash = "dashed" |
| 134 | +p.xgrid.grid_line_color = "#cccccc" |
| 135 | +p.ygrid.grid_line_color = "#cccccc" |
| 136 | + |
| 137 | +# Plot X markers (bullish - green) |
| 138 | +if x_data: |
| 139 | + x_source = ColumnDataSource(data={"col": [d["col"] for d in x_data], "price": [d["price"] for d in x_data]}) |
| 140 | + p.text( |
| 141 | + x="col", |
| 142 | + y="price", |
| 143 | + text={"value": "X"}, |
| 144 | + source=x_source, |
| 145 | + text_font_size="48pt", |
| 146 | + text_color="#2E7D32", |
| 147 | + text_align="center", |
| 148 | + text_baseline="middle", |
| 149 | + text_font_style="bold", |
| 150 | + ) |
| 151 | + |
| 152 | +# Plot O markers (bearish - red) |
| 153 | +if o_data: |
| 154 | + o_source = ColumnDataSource(data={"col": [d["col"] for d in o_data], "price": [d["price"] for d in o_data]}) |
| 155 | + p.text( |
| 156 | + x="col", |
| 157 | + y="price", |
| 158 | + text={"value": "O"}, |
| 159 | + source=o_source, |
| 160 | + text_font_size="48pt", |
| 161 | + text_color="#C62828", |
| 162 | + text_align="center", |
| 163 | + text_baseline="middle", |
| 164 | + text_font_style="bold", |
| 165 | + ) |
| 166 | + |
| 167 | +# Add support trend line (45-degree ascending from low) |
| 168 | +all_prices = [d["price"] for d in x_data] + [d["price"] for d in o_data] |
| 169 | +if all_prices: |
| 170 | + min_price = min(all_prices) |
| 171 | + max_price = max(all_prices) |
| 172 | + |
| 173 | + # Find lowest point for support line |
| 174 | + support_start_col = 0 |
| 175 | + support_start_price = min_price - box_size |
| 176 | + support_end_col = len(columns) - 1 |
| 177 | + support_end_price = support_start_price + (support_end_col - support_start_col) * box_size |
| 178 | + |
| 179 | + if support_end_price <= max_price + 2 * box_size: |
| 180 | + p.line( |
| 181 | + x=[support_start_col, support_end_col], |
| 182 | + y=[support_start_price, support_end_price], |
| 183 | + line_width=5, |
| 184 | + line_color="#306998", |
| 185 | + line_dash="solid", |
| 186 | + legend_label="Support Trend", |
| 187 | + ) |
| 188 | + |
| 189 | + # Find highest point for resistance line |
| 190 | + resistance_start_col = 0 |
| 191 | + resistance_start_price = max_price + box_size |
| 192 | + resistance_end_col = len(columns) - 1 |
| 193 | + resistance_end_price = resistance_start_price - (resistance_end_col - resistance_start_col) * box_size |
| 194 | + |
| 195 | + if resistance_end_price >= min_price - 2 * box_size: |
| 196 | + p.line( |
| 197 | + x=[resistance_start_col, resistance_end_col], |
| 198 | + y=[resistance_start_price, resistance_end_price], |
| 199 | + line_width=5, |
| 200 | + line_color="#FFD43B", |
| 201 | + line_dash="solid", |
| 202 | + legend_label="Resistance Trend", |
| 203 | + ) |
| 204 | + |
| 205 | +# Legend styling |
| 206 | +p.legend.location = "top_left" |
| 207 | +p.legend.label_text_font_size = "28pt" |
| 208 | +p.legend.background_fill_alpha = 0.8 |
| 209 | +p.legend.glyph_height = 30 |
| 210 | +p.legend.glyph_width = 30 |
| 211 | + |
| 212 | +# Save as PNG and HTML (interactive) |
| 213 | +export_png(p, filename="plot.png") |
| 214 | +save(p, filename="plot.html") |
0 commit comments