1. 项目概述:点数图,一个被低估的技术分析利器

如果你在股票、期货或者加密货币市场里摸爬滚打过一段时间,一定见过K线图、MACD、RSI这些耳熟能详的分析工具。但今天我想聊一个相对小众,却极具洞察力的工具——点数图。这个项目,就是围绕“绘制点数图软件”展开的。简单来说,点数图是一种纯粹以价格变动为考量,过滤掉时间因素和微小波动的图表分析方法。它不关心价格花了多长时间才涨跌,只关心价格是否达到了预设的“格值”和“转向值”,从而用“X”和“O”的柱状排列,清晰地揭示支撑、阻力以及趋势突破。

市面上的主流交易软件,比如同花顺、东方财富,或者TradingView,虽然功能强大,但它们的点数图功能要么藏得很深,要么自定义选项有限,对于想深入研究特定品种、特定参数组合的交易者来说,总感觉隔着一层纱。自己动手写一个点数图绘制软件,听起来好像很复杂,但实际上,它的核心逻辑非常清晰。通过这个项目,你不仅能获得一个完全贴合自己交易习惯的分析工具,更能深入理解价格行为本身的韵律。无论是量化交易员想将其作为策略生成的因子,还是手动交易者想寻找更干净的图表信号,这个工具都能提供独特的价值。接下来,我就把自己从零搭建一个点数图绘制器的完整过程、核心算法、踩过的坑以及一些高阶应用心得,毫无保留地分享出来。

2. 点数图核心原理与设计思路拆解

在动手写代码之前,我们必须彻底吃透点数图是怎么“画”出来的。这决定了我们软件的数据结构和核心算法设计。很多人觉得点数图神秘,其实它的规则非常机械和确定。

2.1 三大核心参数:格值、转向值与绘制方法

点数图的绘制完全依赖于三个参数: 格值 转向值 绘制方法

  1. 格值 :这是价格变动的最小记录单位。例如,格值设为1元。如果股价从10元涨到10.5元,由于0.5 < 1,点数图不予记录。只有当股价从10元涨到11元(或跌到9元),达到了一个完整的格值,图表上才会增加一个“X”(上涨)或“O”(下跌)。格值决定了图表的敏感度。格值小,图表波动细节多,噪音也可能多;格值大,图表更平滑,只捕捉主要趋势。

  2. 转向值 :这是决定从一列“X”切换到一列“O”(或反之)所需的反向价格变动格数。最常见的设定是“三点转向”,即转向值=3。假设当前正在绘制一列“X”,股价从50元开始上涨。当股价涨到53元时,我们在同一列添加3个“X”。如果之后股价开始下跌,它必须从当前最高点(53元)下跌至少3个格值(即跌到50元),我们才会结束当前“X”列,并在右侧新开一列“O”,从52元开始绘制(因为转向时,第一格不画,从第二格开始)。转向值控制了趋势转换的“门槛”,是过滤市场噪音的关键。

  3. 绘制方法 :主要有两种。

    • 收盘价法 :只使用每个时间周期(如日、小时)的收盘价来判断是否满足格值变动。这是最传统、最常用的方法,数据容易获取,图表相对稳定。
    • 高低价法 :同时考虑周期内的最高价和最低价。只要最高价触及了新的格值上限就画“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 主界面设计

我们设计一个主窗口,包含以下几个区域:

  1. 菜单栏/工具栏 :提供文件(导入CSV)、参数设置、图表导出等功能。
  2. 参数面板 :几个 QSpinBox QDoubleSpinBox 用于输入“格值”、“转向值”,一个 QComboBox 选择“收盘价/高低价”方法,一个“更新图表”按钮。
  3. 图表显示区域 :一个 matplotlib FigureCanvas 控件,用于绘制点数图。
  4. 状态栏/信息栏 :显示当前加载的数据范围、参数等信息。

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. 开发中的常见“坑”与解决之道

在实际开发过程中,我遇到了不少问题,这里总结一下,希望能帮你避开。

  1. 浮点数精度导致的格值判断错误

    • 问题 :价格是浮点数(如10.01),格值也是浮点数(如0.5),直接比较 price >= current_extreme + box_size 可能因为浮点数精度问题导致误判。
    • 解决 :将所有价格转换为以格值为单位的整数进行计算。例如, box_size=0.5 ,价格10.01可以转换为 int(round(10.01 / 0.5)) = 20 。所有的比较和运算都在整数空间进行,最后绘图时再乘回格值。这是最稳健的方法。
  2. 初始方向判断的陷阱

    • 问题 :如何确定第一列是‘X’还是‘O’?如果简单地用前两个价格的涨跌来决定,在价格小幅波动时,可能会产生大量无意义的、频繁转向的列。
    • 解决 :采用“等待第一个满足转向值的运动”来确立初始方向,如我前面引擎代码中的 _initialize_first_column 方法所示。这符合点数图过滤噪音的哲学。
  3. “高低价法”实现的复杂性

    • 问题 :高低价法需要考虑一个周期内最高价和最低价,逻辑比收盘价法复杂得多。可能出现最高价触发了新的‘X’,最低价在同一周期又触发了转向条件。
    • 解决 :需要定义明确的处理优先级。通常的规则是: 先处理同方向的延续,再处理反方向的转向 。在一个周期内,先判断是否能在当前列添加新格(无论X或O),添加完成后,再判断是否满足转向条件。需要仔细处理时间切片内的价格序列,有时需要将一根K线的高低点视为一个小的价格序列来处理。
  4. 图表美观性与可读性

    • 问题 :直接用矩形绘制,当格值很小、价格跨度很大时,矩形会变得非常细长,难以看清。
    • 解决 :可以引入“动态格宽”概念,根据屏幕像素和价格范围自动调整矩形宽度,或者允许用户手动缩放。另外,使用鲜明的对比色(如深绿/深红)并添加边框,能让图表更清晰。对于长列,可以考虑在列中间标注价格数值。
  5. 大量数据下的性能瓶颈

    • 问题 :当处理数千甚至数万根K线数据,并且用户快速滑动参数滑块时,频繁的全量重算和重绘会导致界面卡顿。
    • 解决
      • 算法优化 :确保核心计算函数是高效的,避免不必要的循环。
      • 增量计算 :如前所述,支持增量更新。
      • 前端防抖 :对参数改变事件进行防抖处理(例如,用户停止调整滑块300毫秒后再触发计算),避免不必要的计算。
      • 异步计算 :将耗时的计算任务放到单独的线程中,防止阻塞UI主线程。在PyQt中可以使用 QThread

开发这个工具的过程,本身就是一个对市场结构深度思考的过程。每一次调试参数,观察图表形态的变化,都让我对“趋势”和“噪音”有了更直观的认识。这个工具现在已经成为我分析市场时,除了K线之外最重要的一个视角。它强迫我忽略时间带来的焦虑,只专注于价格本身的力量变化。如果你也对市场分析感兴趣,强烈建议你亲手实现一遍,其中的收获远大于仅仅使用一个现成的软件。

更多推荐