从零构建点数图绘制软件:核心算法、Python实现与量化交易应用
1. 项目概述:点数图,一个被低估的技术分析利器
如果你在股票、期货或者加密货币市场里摸爬滚打过一段时间,一定见过K线图、MACD、RSI这些耳熟能详的分析工具。但今天我想聊一个相对小众,却极具洞察力的工具——点数图。这个项目,就是围绕“绘制点数图软件”展开的。简单来说,点数图是一种纯粹以价格变动为考量,过滤掉时间因素和微小波动的图表分析方法。它不关心价格花了多长时间才涨跌,只关心价格是否达到了预设的“格值”和“转向值”,从而用“X”和“O”的柱状排列,清晰地揭示支撑、阻力以及趋势突破。
市面上的主流交易软件,比如同花顺、东方财富,或者TradingView,虽然功能强大,但它们的点数图功能要么藏得很深,要么自定义选项有限,对于想深入研究特定品种、特定参数组合的交易者来说,总感觉隔着一层纱。自己动手写一个点数图绘制软件,听起来好像很复杂,但实际上,它的核心逻辑非常清晰。通过这个项目,你不仅能获得一个完全贴合自己交易习惯的分析工具,更能深入理解价格行为本身的韵律。无论是量化交易员想将其作为策略生成的因子,还是手动交易者想寻找更干净的图表信号,这个工具都能提供独特的价值。接下来,我就把自己从零搭建一个点数图绘制器的完整过程、核心算法、踩过的坑以及一些高阶应用心得,毫无保留地分享出来。
2. 点数图核心原理与设计思路拆解
在动手写代码之前,我们必须彻底吃透点数图是怎么“画”出来的。这决定了我们软件的数据结构和核心算法设计。很多人觉得点数图神秘,其实它的规则非常机械和确定。
2.1 三大核心参数:格值、转向值与绘制方法
点数图的绘制完全依赖于三个参数: 格值 、 转向值 和 绘制方法 。
-
格值 :这是价格变动的最小记录单位。例如,格值设为1元。如果股价从10元涨到10.5元,由于0.5 < 1,点数图不予记录。只有当股价从10元涨到11元(或跌到9元),达到了一个完整的格值,图表上才会增加一个“X”(上涨)或“O”(下跌)。格值决定了图表的敏感度。格值小,图表波动细节多,噪音也可能多;格值大,图表更平滑,只捕捉主要趋势。
-
转向值 :这是决定从一列“X”切换到一列“O”(或反之)所需的反向价格变动格数。最常见的设定是“三点转向”,即转向值=3。假设当前正在绘制一列“X”,股价从50元开始上涨。当股价涨到53元时,我们在同一列添加3个“X”。如果之后股价开始下跌,它必须从当前最高点(53元)下跌至少3个格值(即跌到50元),我们才会结束当前“X”列,并在右侧新开一列“O”,从52元开始绘制(因为转向时,第一格不画,从第二格开始)。转向值控制了趋势转换的“门槛”,是过滤市场噪音的关键。
-
绘制方法 :主要有两种。
- 收盘价法 :只使用每个时间周期(如日、小时)的收盘价来判断是否满足格值变动。这是最传统、最常用的方法,数据容易获取,图表相对稳定。
- 高低价法 :同时考虑周期内的最高价和最低价。只要最高价触及了新的格值上限就画“X”,最低价触及了新的格值下限就画“O”,规则更复杂,但对价格区间反应更灵敏。新手建议先从收盘价法入手。
理解了这三个参数,你就掌握了点数图的灵魂。我们的软件设计,必须让用户能灵活配置这三个参数,并实时看到图表变化。
2.2 软件整体架构设计
我的设计目标是: 一个本地运行的、支持多品种历史数据导入、能灵活调整参数并实时渲染、且图表清晰美观的桌面应用 。我选择了Python作为开发语言,因为它有丰富的数据处理和图形库。
- 前端(图表展示) :
PyQt5或Tkinter。我最终选择了PyQt5,因为它控件丰富、图表集成能力强,做出来的界面更专业。图表绘制部分,matplotlib是首选,它的Axes对象可以让我们精细控制每一个“X”和“O”的矩形框。 - 后端(数据处理与算法) :纯Python。核心是一个“点数图引擎”类,它接收原始价格序列和参数(格值、转向值、方法),输出一个结构化的点数图数据列表。这个列表的每个元素代表一列,包含了列的类型(‘X’或‘O’)、起始价格、包含的格数等信息。
- 数据层 :支持从CSV文件导入(包含日期、开盘、最高、最低、收盘、成交量),这是最通用的方式。也可以预留接口,未来接入在线数据API。
整个工作流程是:用户导入数据 -> 设置参数 -> 引擎计算 -> 前端绘图。这个架构清晰,耦合度低,便于后续扩展(比如加入自动化分析、策略回测等功能)。
3. 核心算法实现与代码解析
这是整个项目的硬核部分。我们将把上述原理翻译成精确的代码逻辑。我将以“收盘价法”和“三点转向”为例,详细拆解。
3.1 数据预处理与格值化
首先,我们需要将原始的价格数据,按照格值进行“离散化”处理。假设我们有一组收盘价列表 close_prices ,格值 box_size = 2 。
def discretize_price(price, box_size, mode='round'):
"""
将实际价格离散化为格值整数倍。
mode: ‘round’四舍五入, ‘floor’向下取整, ‘ceil’向上取整。
通常对于‘X’列用ceil(确保价格上涨触及格值),对于‘O’列用floor。
"""
if mode == 'round':
return round(price / box_size) * box_size
elif mode == 'floor':
return math.floor(price / box_size) * box_size
elif mode == 'ceil':
return math.ceil(price / box_size) * box_size
else:
return price
但更常见的做法是在核心算法中直接进行整数比较,避免频繁浮点数运算。我们可以将价格除以格值,比较其整数部分。
3.2 点数图引擎核心逻辑
这是最关键的类。我们需要维护当前状态:当前列是‘X’还是‘O’,当前列的最高价/最低价(用于判断是否添加新格),以及整个图表的列数据。
class PointAndFigureEngine:
def __init__(self, box_size=1, reversal_boxes=3, method='close'):
self.box_size = box_size # 格值
self.reversal_boxes = reversal_boxes # 转向格数
self.method = method # ‘close’ or ‘high_low’
self.columns = [] # 存储所有列的信息
self.current_column = None # 当前活动列
def calculate_from_prices(self, prices):
"""输入价格序列,计算点数图"""
if not prices:
return []
# 初始化第一列。通常以第一个价格为基准,判断第一个变动方向需要至少两个点。
# 更稳健的做法是找到第一个满足转向值的价格变动来确定初始方向。
# 这里简化处理:假设第一个价格作为基准。
base_price = prices[0]
# 我们需要一个起始方向。一个简单启发式规则:看前N个价格的趋势。
# 这里我们用一个更经典的方法:从第一个价格开始,等待第一个满足转向值的移动来确立初始列。
self._initialize_first_column(prices)
for price in prices[1:]:
self._process_price(price)
return self.columns
def _initialize_first_column(self, prices):
"""初始化第一列。寻找第一个满足转向值的价格运动。"""
if len(prices) < 2:
return
start_price = prices[0]
for i in range(1, len(prices)):
price_diff_in_boxes = (prices[i] - start_price) / self.box_size
if abs(price_diff_in_boxes) >= self.reversal_boxes:
if price_diff_in_boxes > 0:
# 确立为‘X’列
self.current_column = {
'type': 'X',
'start_price': start_price,
'boxes': [start_price] # 存储每个格对应的价格(或格索引)
}
# 将上涨的格数加进去
self._add_boxes_to_current_column(prices[i], is_up=True)
else:
# 确立为‘O’列
self.current_column = {
'type': 'O',
'start_price': start_price,
'boxes': [start_price]
}
self._add_boxes_to_current_column(prices[i], is_up=False)
self.columns.append(self.current_column)
break # 找到初始方向,跳出循环
def _process_price(self, price):
"""处理每一个新价格"""
if self.current_column is None:
return
col_type = self.current_column['type']
# 获取当前列的最高价(X列)或最低价(O列)
if col_type == 'X':
current_extreme = max(self.current_column['boxes'])
# 判断是否继续在当前列添加‘X’
if price >= current_extreme + self.box_size:
self._add_boxes_to_current_column(price, is_up=True)
# 判断是否满足转向条件(从当前最高点下跌 reversal_boxes 个格值)
elif price <= current_extreme - self.reversal_boxes * self.box_size:
self._reverse_column(price, new_type='O')
else: # col_type == 'O'
current_extreme = min(self.current_column['boxes'])
if price <= current_extreme - self.box_size:
self._add_boxes_to_current_column(price, is_up=False)
elif price >= current_extreme + self.reversal_boxes * self.box_size:
self._reverse_column(price, new_type='X')
def _add_boxes_to_current_column(self, price, is_up):
"""向当前列添加新的格。is_up 表示是否是上涨(X列)。"""
col = self.current_column
if is_up:
# 计算需要添加多少个‘X’
# 当前列最高价
current_top = max(col['boxes'])
# 计算新价格对应的最高格值整数倍
target_level = math.floor(price / self.box_size) * self.box_size
# 从 current_top + box_size 开始,到 target_level,每一步长 box_size,都添加一个格
level = current_top + self.box_size
while level <= target_level:
col['boxes'].append(level)
level += self.box_size
else:
# 下跌,添加‘O’
current_bottom = min(col['boxes'])
target_level = math.ceil(price / self.box_size) * self.box_size
level = current_bottom - self.box_size
while level >= target_level:
col['boxes'].append(level)
level -= self.box_size
def _reverse_column(self, price, new_type):
"""发生转向,结束当前列,开启新的一列。"""
# 结束当前列
self.current_column = None
# 新列的起始价格:从触发转向的价格,反向移动一个格值开始。
# 这是点数图的标准画法,避免立即在相邻格反转。
if new_type == 'X':
# 从下跌转向上涨,新‘X’列从 (触发转向价 + 1个格值) 开始画
start_price = math.ceil(price / self.box_size) * self.box_size + self.box_size
else: # ‘O’
# 从上涨转向下跌,新‘O’列从 (触发转向价 - 1个格值) 开始画
start_price = math.floor(price / self.box_size) * self.box_size - self.box_size
new_column = {
'type': new_type,
'start_price': start_price,
'boxes': [start_price]
}
# 还需要根据当前价格,判断是否需要在新列立即添加更多格
# 例如,转向发生时价格已经远离了起始格,可能需要填充多个格。
# 这里调用 _add_boxes_to_current_column 来填充
self.current_column = new_column
self._add_boxes_to_current_column(price, is_up=(new_type=='X'))
self.columns.append(self.current_column)
注意 :上述代码是核心逻辑的示意,实际处理中需要特别注意浮点数精度问题(建议将价格转换为以格值为单位的整数进行计算),以及初始化的边界条件。
_reverse_column方法中关于起始价格的计算是点数图绘制的精髓之一,确保了图表的清晰性。
这个引擎类会输出一个 self.columns 列表,里面记录了每一列的类型、起始价和包含的所有格值价格。有了这个结构化的数据,前端绘图就变得非常简单了。
4. 前端界面与图表绘制实战
有了后端引擎,我们需要一个友好的界面让用户交互。使用 PyQt5 和 matplotlib 的 FigureCanvas 可以很好地集成。
4.1 主界面设计
我们设计一个主窗口,包含以下几个区域:
- 菜单栏/工具栏 :提供文件(导入CSV)、参数设置、图表导出等功能。
- 参数面板 :几个
QSpinBox或QDoubleSpinBox用于输入“格值”、“转向值”,一个QComboBox选择“收盘价/高低价”方法,一个“更新图表”按钮。 - 图表显示区域 :一个
matplotlib的FigureCanvas控件,用于绘制点数图。 - 状态栏/信息栏 :显示当前加载的数据范围、参数等信息。
4.2 使用 Matplotlib 绘制点数图
在 matplotlib 中绘制点数图,本质上是绘制一系列填充了颜色或符号的矩形。每一列就是一组垂直排列的矩形。
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import matplotlib.patches as mpatches
class PnFChartCanvas(FigureCanvas):
def __init__(self, parent=None):
self.fig, self.ax = plt.subplots(figsize=(12, 8), tight_layout=True)
super().__init__(self.fig)
self.setParent(parent)
self.ax.set_title('点数图 (Point & Figure)')
self.ax.set_xlabel('列序')
self.ax.set_ylabel('价格')
self.ax.grid(True, linestyle='--', alpha=0.6)
def plot_pnf(self, columns_data, box_size):
"""绘制点数图。columns_data 来自引擎计算的 self.columns"""
self.ax.clear()
if not columns_data:
return
for col_index, col in enumerate(columns_data):
col_type = col['type']
boxes = col['boxes']
# 一列的横坐标范围,可以设定一个固定宽度,比如0.8
col_width = 0.8
x_left = col_index - col_width / 2
x_right = col_index + col_width / 2
for box_price in boxes:
# 每个格是一个矩形
# 矩形的y坐标是 box_price - box_size/2, 高度是 box_size
# 这样矩形的中心就在 box_price 上
rect = Rectangle(
(x_left, box_price - box_size / 2),
col_width,
box_size,
linewidth=1,
edgecolor='black',
facecolor='green' if col_type == 'X' else 'red' # X列用绿色,O列用红色
)
self.ax.add_patch(rect)
# 也可以在矩形中心添加文字‘X’或‘O’,但用颜色区分通常更清晰
# self.ax.text(col_index, box_price, col_type, ha='center', va='center', fontweight='bold')
# 调整坐标轴范围
all_prices = []
for col in columns_data:
all_prices.extend(col['boxes'])
if all_prices:
min_price, max_price = min(all_prices), max(all_prices)
price_range = max_price - min_price
self.ax.set_ylim(min_price - box_size, max_price + box_size) # 上下留点空间
self.ax.set_xlim(-0.5, len(columns_data) - 0.5)
self.ax.set_xticks(range(len(columns_data)))
self.ax.set_xticklabels([str(i+1) for i in range(len(columns_data))]) # 列编号从1开始
# 添加图例
x_patch = mpatches.Patch(color='green', label='上涨列 (X)')
o_patch = mpatches.Patch(color='red', label='下跌列 (O)')
self.ax.legend(handles=[x_patch, o_patch])
self.fig.canvas.draw_idle() # 刷新画布
将后端引擎和前端绘图连接起来,核心代码就是在点击“更新图表”按钮时,调用引擎的 calculate_from_prices 方法,然后将结果传给 plot_pnf 方法。一个基本的点数图绘制软件就成型了。
5. 高级功能拓展与实战应用
一个基础的绘图工具只能算玩具。要让其真正具备实战价值,我们必须加入一些高级功能。
5.1 多时间框架与参数优化
不同的交易品种(股票、期货、币)和不同的时间周期(日线、小时线),最优的格值和转向值组合是不同的。我们的软件应该支持:
- 参数快速切换 :在界面中提供滑块或输入框,让用户实时调整格值和转向值,图表即时重绘。这能帮助交易者直观感受参数对图表形态的影响。
- 参数扫描与回测 :编写一个简单的回测模块,针对某一段历史数据,遍历一组参数(如格值从0.5到5,转向值从1到5),计算在不同参数下,点数图产生的特定信号(如突破买入、支撑买入)的胜率和盈亏比。这能帮助用户找到适合该品种的“黄金参数”。
5.2 自动化形态识别与交易信号
点数图有一些经典的技术形态,如“双重顶”、“三重底”、“牛市突破”、“熊市突破”等。我们可以尝试用规则来自动识别这些形态并标注在图表上。
def identify_patterns(columns):
"""简单的形态识别示例:识别牛市突破(一列X的高点超过前一列X的高点)"""
patterns = []
for i in range(1, len(columns)):
if columns[i]['type'] == 'X' and columns[i-1]['type'] == 'X':
current_top = max(columns[i]['boxes'])
prev_top = max(columns[i-1]['boxes'])
if current_top > prev_top:
patterns.append({
'type': 'bullish_breakout',
'column_index': i,
'price_level': current_top
})
# 类似地可以识别熊市突破、双重顶等
return patterns
在绘图时,可以在识别到的形态位置添加箭头或文字标注,提示潜在的交易机会。
5.3 数据管理与性能优化
- 增量更新 :当获得新的实时价格时,我们不需要对整个历史数据重新计算点数图。引擎应该支持
update_with_new_price(price)方法,只基于最新的图表状态和最新价格进行增量更新,这对于实时监控至关重要。 - 图表保存与分享 :实现将图表保存为高清PNG或PDF的功能,方便插入报告或分享。
matplotlib的savefig方法可以轻松实现。 - 多品种对比 :在同一个界面中并排显示两个不同品种的点数图,或者叠加显示同一品种不同参数的点数图,便于对比分析。
6. 开发中的常见“坑”与解决之道
在实际开发过程中,我遇到了不少问题,这里总结一下,希望能帮你避开。
-
浮点数精度导致的格值判断错误
- 问题 :价格是浮点数(如10.01),格值也是浮点数(如0.5),直接比较
price >= current_extreme + box_size可能因为浮点数精度问题导致误判。 - 解决 :将所有价格转换为以格值为单位的整数进行计算。例如,
box_size=0.5,价格10.01可以转换为int(round(10.01 / 0.5)) = 20。所有的比较和运算都在整数空间进行,最后绘图时再乘回格值。这是最稳健的方法。
- 问题 :价格是浮点数(如10.01),格值也是浮点数(如0.5),直接比较
-
初始方向判断的陷阱
- 问题 :如何确定第一列是‘X’还是‘O’?如果简单地用前两个价格的涨跌来决定,在价格小幅波动时,可能会产生大量无意义的、频繁转向的列。
- 解决 :采用“等待第一个满足转向值的运动”来确立初始方向,如我前面引擎代码中的
_initialize_first_column方法所示。这符合点数图过滤噪音的哲学。
-
“高低价法”实现的复杂性
- 问题 :高低价法需要考虑一个周期内最高价和最低价,逻辑比收盘价法复杂得多。可能出现最高价触发了新的‘X’,最低价在同一周期又触发了转向条件。
- 解决 :需要定义明确的处理优先级。通常的规则是: 先处理同方向的延续,再处理反方向的转向 。在一个周期内,先判断是否能在当前列添加新格(无论X或O),添加完成后,再判断是否满足转向条件。需要仔细处理时间切片内的价格序列,有时需要将一根K线的高低点视为一个小的价格序列来处理。
-
图表美观性与可读性
- 问题 :直接用矩形绘制,当格值很小、价格跨度很大时,矩形会变得非常细长,难以看清。
- 解决 :可以引入“动态格宽”概念,根据屏幕像素和价格范围自动调整矩形宽度,或者允许用户手动缩放。另外,使用鲜明的对比色(如深绿/深红)并添加边框,能让图表更清晰。对于长列,可以考虑在列中间标注价格数值。
-
大量数据下的性能瓶颈
- 问题 :当处理数千甚至数万根K线数据,并且用户快速滑动参数滑块时,频繁的全量重算和重绘会导致界面卡顿。
- 解决 :
- 算法优化 :确保核心计算函数是高效的,避免不必要的循环。
- 增量计算 :如前所述,支持增量更新。
- 前端防抖 :对参数改变事件进行防抖处理(例如,用户停止调整滑块300毫秒后再触发计算),避免不必要的计算。
- 异步计算 :将耗时的计算任务放到单独的线程中,防止阻塞UI主线程。在PyQt中可以使用
QThread。
开发这个工具的过程,本身就是一个对市场结构深度思考的过程。每一次调试参数,观察图表形态的变化,都让我对“趋势”和“噪音”有了更直观的认识。这个工具现在已经成为我分析市场时,除了K线之外最重要的一个视角。它强迫我忽略时间带来的焦虑,只专注于价格本身的力量变化。如果你也对市场分析感兴趣,强烈建议你亲手实现一遍,其中的收获远大于仅仅使用一个现成的软件。
更多推荐

所有评论(0)