ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模块化缠论量化框架:分型、笔、线段、中枢与可复现回测

模块化缠论量化框架:分型、笔、线段、中枢与可复现回测

把缠论做成一套模块化缠论量化框架,我在最开始动手的时候想得特别简单——不就是分型、笔、线段、中枢四层结构识别,再叠上三类买卖点吗?真写下去才发现,坑全在细节里:同样一段行情,不同人画出来的笔不一样,线段不一样,中枢位置自然也不一样,最后信号完全对不上。手工画图时你可以靠"眼感"糊过去,可一旦要跑回测、要做参数扫描、要在几百个品种上批量跑,这种模糊性会直接把整个系统拖垮。所以这套框架真正的价值不在于"实现了缠论",而在于把缠论里那些口口相传的口径,逼成一套可配置、可复现、可回归测试的工程约定。这篇文章面向两类人:一类是懂缠论结构、但被工程实现卡住的交易研究者;另一类是有一定编程基础、想把这套结构逻辑落成代码的开发者。我会把模块怎么切、边界在哪、参数怎么定、哪些地方必须留开关、哪些地方一旦写死就等着后悔,一条条摊开讲,代码和口径都给到可以直接抄的程度。

1. 为什么要把缠论拆成模块:从"看图说话"到可复现的工程结构

缠论本身的表述是高度图形化的,它假设读者能"看出"走势的层次。这在人脑里没问题,但在代码里是灾难——计算机不会"看",它只会按你给的规则逐根判断。所以第一件事不是写算法,而是想清楚:哪些东西是规则,哪些东西是参数,哪些东西是策略选择。这三者混在一起,框架就废了。

1.1 缠论落地的三个真实痛点

第一个痛点是口径不统一。光是"笔"的成立条件,市面上就有严格要求顶底分型之间至少含五根独立K线的老口径,也有允许分型共用K线、只看分型是否成立的宽松口径。两种口径在震荡行情里画出来的笔数量能差三成以上。如果你把口径写死在函数内部,想换一种就得改代码、重跑、再对比,一晚上就过去了。正确做法是把口径抽成参数,让同一份数据能同时产出多套结构,并排比较。

第二个痛点是未来函数。这是最容易翻车的地方。缠论里"最后一笔还没走完"是常态,如果你在回测时用"当前已确认的最后一笔"去做决策,看起来没问题,但很多人写着写着就把"这一笔的最低点"当成了已知信息——而那个最低点是在未来才出现的。更隐蔽的是线段:线段的破坏需要后面的走势来确认,如果实现时用全序列一次性划分,再回头取第 i 根K线时刻的结构状态,就等于偷看了未来。

第三个痛点是结构层与策略层耦合。我见过不少实现,中枢识别的函数里顺手写了"如果出现三买就买入",结果想换个止盈逻辑得把整个文件重读一遍。结构识别是纯客观的(给定口径,输出唯一),策略是主观的(同一结构,不同人做不同决策),这两层必须物理隔离。

判断一个缠论框架是否合格,有个很土但很准的标准:把结构识别部分的输出导出成表格,换个人用同样的参数跑一遍,两份表格必须逐行一致。做不到,就别谈回测。

1.2 模块划分的边界怎么切

我最终用的切法是六层,每层只依赖它下面那一层,绝不反向调用。这个单向依赖是框架能长期维护的根本原因。

层级模块职责输入输出是否含参数
L0数据层原始行情标准化Bar序列复权方式、周期
L1预处理层Bar序列去包含后的Bar序列包含处理规则
L2分型层去包含Bar顶/底分型列表无(严格定义)
L3笔层分型列表笔序列最小K线数、是否允许分型共用
L4线段层笔序列线段序列划分算法、特征序列处理方式
L5中枢/走势层线段序列中枢列表、走势类型中枢最少段数、扩展判定
L6信号层结构快照信号事件买卖点类型、确认条件

注意几个设计上的取舍。第一,L1去包含后的Bar必须保留原始索引映射,也就是每个合并后的Bar要记住它由哪几根原始Bar合成、原始的最高最低在哪。原因很简单:所有交易都发生在原始K线上,如果你只在合并后的序列上算价格,止损位、成交价全都会偏。第二,L2到L5每一层的输出都带一个"确认索引"字段,表示这个结构在第几根原始K线才算被确认。后面回测时,只允许使用确认索引小于等于当前时刻的结构,这样就从机制上堵死了未来函数。第三,L6不直接产生订单,只产生事件,事件交给独立的执行模块处理。

1.3 一套最小可用的接口约定

接口不用多花哨,够用就行。我习惯用Python的dataclass,因为字段明确、可打印、可序列化,调试时直接print就能看清结构。

from dataclasses import dataclass, field from typing import List, Optional @dataclass class Bar: idx: int # 原始序列索引 dt: int # 时间戳 o: float h: float l: float c: float v: float src_idx: List[int] = field(default_factory=list) # 合并了哪些原始Bar @dataclass class Fenxing: kind: str # 'top' / 'bottom' bar_idx: int # 在去包含序列中的位置 raw_idx: int # 对应原始序列位置 price: float confirm_idx: int # 第几根原始Bar之后才确认 @dataclass class Bi: direction: int # 1 向上, -1 向下 start: Fenxing end: Fenxing confirm_idx: int high: float low: float @dataclass class Segment: direction: int start_bi: int end_bi: int high: float low: float confirm_idx: int is_confirmed: bool # 线段是否已被破坏确认 @dataclass class Center: level: int # 级别标记 zg: float # 中枢上沿 zd: float # 中枢下沿 start_idx: int end_idx: int seg_count: int # 延伸了多少段 confirm_idx: int

这段代码看着平平无奇,但confirm_idx这个字段值得单独说一句。它的存在让整个框架有了"时间感"。没有它,你在t时刻拿到的结构列表里混着未来才会确认的对象,回测曲线会漂亮得不像话,实盘上去立刻现原形。我见过最典型的症状是:回测年化收益高得离谱,换到模拟盘第一天就发现信号触发时价格早就跑掉了。

2. 数据与预处理:脏数据会把结构识别彻底带偏

很多人跳过这一层直接写分型,结果跑出来的笔乱七八糟,回头查了三天才发现是某天的停牌数据造成了一根巨大的假K线。预处理不是可选项,它是结构识别的地基。

2.1 K线包含关系处理的正确姿势

包含关系的定义很直白:两根相邻K线,如果一根的高低点完全覆盖另一根,就构成包含。处理规则也直白:向上处理时取两者高点的高、低点的高;向下处理时取高点的低、低点的低。判断"向上还是向下"用的是包含关系发生之前的那两根K线的方向,不是当前这两根。

这里有两个必须说清楚的细节。第一个是连续包含的链式处理。三根甚至五根K线互相包含时,不能两两独立处理,必须按顺序迭代合并,合并出的新K线继续和下一根比较。第二个是方向判定的兜底。序列开头没有前两根K线时怎么办?我的做法是用前两根非包含K线的高低点比较来定初始方向,如果实在没有,就默认向下。这个兜底看起来随意,但它只影响序列最前面极少数几根K线,对整体结构影响可以忽略,纠结它没有意义。

def merge_bars(bars: List[Bar]) -> List[Bar]: if len(bars) < 2: return bars[:] out = [bars[0]] direction = 1 # 1 向上, -1 向下 for cur in bars[1:]: prev = out[-1] has_incl = (cur.h >= prev.h and cur.l <= prev.l) or \ (cur.h <= prev.h and cur.l >= prev.l) if not has_incl: direction = 1 if cur.h > prev.h else -1 out.append(cur) continue if direction == 1: new_h, new_l = max(prev.h, cur.h), max(prev.l, cur.l) else: new_h, new_l = min(prev.h, cur.h), min(prev.l, cur.l) merged = Bar(idx=prev.idx, dt=cur.dt, o=prev.o, h=new_h, l=new_l, c=cur.c, v=prev.v + cur.v, src_idx=prev.src_idx + cur.src_idx) out[-1] = merged return out

这段代码有个坑我要提醒:合并后的Bar,o应该保留前一根的开盘、c用当前收盘,这样它的形态更接近"这一段走势的合并体"。有人喜欢把o和c也取极值,那样会导致后续回测里用合并Bar的价格做成交价时出现不可能的价格。还有src_idx必须累加,这是回溯到原始K线的唯一线索。

实操心得:包含处理跑完后,一定要做一次断言检查——处理后的序列里,任意相邻两根K线都不允许再存在包含关系。这个断言帮我抓到过至少两次逻辑错误,都是链式合并时方向变量没及时更新导致的。

2.2 复权、周期与数据对齐

价格序列的复权方式直接决定结构。前复权在每次分红除权后会重算历史价格,这意味着你昨天跑出来的笔,今天可能变了——回测结果不可复现,这是致命的。我的建议是:结构识别用不复权或后复权价格,因为后复权历史值稳定,不会因为新的除权事件而变动;只在计算收益率、做资金曲线时,用统一的复权口径换算。如果标的常年大比例分红,用不复权价格会让分型位置失真,这时候后复权是更好的选择。

多周期的问题更麻烦。日线结构和30分钟结构要联合使用时,两边的时间戳必须严格对齐到同一根日线的边界。我见过有人把30分钟最后一根未走完的K线也拿去做结构识别,结果日线收盘前那半小时信号不断闪烁。正确做法是:多周期分析时,高周期只使用已收盘的数据,低周期如果需要盘中信号,必须明确标注为"未确认",并且不允许参与任何跨周期的一致性判断。

2.3 数据质量的几个硬性检查

在进入L2之前,我会跑一组检查:时间戳严格递增、OHLC满足l <= min(o,c)和h >= max(o,c)、单根振幅不超过某个阈值(比如前20根平均振幅的8倍,超过就标记)、成交量为零但价格有变动的记录单独列出。这些检查不修复数据,只打标记,因为修复逻辑本身就可能引入偏差,让人误以为数据是干净的。

3. 分型与笔:口径参数化是唯一出路

分型和笔是缠论里最"看得见"的两层,也是争议最大的两层。我的原则很明确:分型严格按定义走,笔把所有争议点做成参数,默认值选一套自己回测过最稳的,但绝不封死。

3.1 分型的严格定义与伪分型过滤

顶分型的定义是:中间那根K线的高点高于左右两根,且低点也高于左右两根。底分型反过来。这个定义本身没有歧义,但实现时有两个细节要处理。

第一个细节是左右邻接关系。分型必须在去包含后的序列上判断,因为包含关系会让"三根K线"这个概念失效。第二个细节是同向分型的去重。连续出现两个顶分型且中间没有底分型时,保留价格更高的那个;底分型保留价格更低的那个。这个去重不做,后面的笔会画出一堆莫名其妙的短笔。

还有一种情况叫"分型重叠",即顶分型的中间K线同时也是另一个顶分型的一部分。严格按定义,每个位置只可能有一个分型,但去重逻辑写错时会重复输出。我一般在输出前按bar_idx排序再按kind去重,简单粗暴但有效。

3.2 笔的成立条件与参数取舍

笔的核心参数有三个,我把它们全部暴露出来:

  • min_bars:顶底分型之间至少需要多少根K线(含分型自身所在K线)。老口径是5,部分实现用4。
  • allow_share:两个分型是否可以共用K线。允许共用会显著增加笔的数量,在震荡市里尤其明显。
  • min_amplitude:笔的最小幅度过滤,用价格百分比或ATR倍数表示。这个参数不是缠论原生的,纯属工程补丁,用来滤掉那些结构上成立但幅度小到没有交易价值的笔。

min_bars选5还是4,不是审美问题,是频率问题。我用同一份五年日线数据做过对比:min_bars=5时全样本约产生1200笔,min_bars=4时约1700笔,多出来的500笔里绝大多数是震荡区间的短笔,这些笔会让中枢识别变得非常碎,最终信号数量翻倍但胜率下降明显。所以我的默认值是5,只有在做高频或分钟级别时才考虑放宽到4,因为分钟级别的绝对波动小,用5会漏掉太多结构。

min_amplitude这个参数要小心。它本质上是引入了一个"重要性"的主观判断,用不好会把真正的转折点滤掉。我的经验是把它设得很低,比如0.3倍ATR,只用来过滤那些几乎水平的笔,不要用它来做主要筛选。

def build_bi(fxs: List[Fenxing], bars: List[Bar], min_bars: int = 5, allow_share: bool = False) -> List[Bi]: if len(fxs) < 2: return [] bis = [] cur = fxs[0] for nxt in fxs[1:]: if nxt.kind == cur.kind: # 同向分型取更极端者 if (cur.kind == 'top' and nxt.price > cur.price) or \ (cur.kind == 'bottom' and nxt.price < cur.price): cur = nxt continue gap = nxt.bar_idx - cur.bar_idx need = min_bars if not allow_share else min_bars - 1 if gap < need: continue direction = 1 if nxt.kind == 'top' else -1 bis.append(Bi(direction=direction, start=cur, end=nxt, confirm_idx=nxt.confirm_idx, high=max(cur.price, nxt.price), low=min(cur.price, nxt.price))) cur = nxt return bis

这段逻辑的关键在于"不满足条件的反向分型直接跳过,但不更新cur"。很多实现会在这里把cur更新成那个不合格的分型,导致后面整条笔链错位。另外要注意最后一笔的状态:如果序列末尾还有一个未配对的端点,它是"未完成笔",必须单独标记,回测时默认不用它做决策。

3.3 线段划分:两条路线怎么选

线段是分歧最大的一层。路线一是有特征序列的严格划分:把笔按方向分类,同向笔构成特征序列,特征序列内部先做包含处理,再看是否形成顶底分型来判定线段破坏。路线二是简化折线法:直接看反向笔是否跌破(或升破)前一笔的起点,用固定的破坏规则划段。

两条路线的差别不在代码量,在稳定性。严格特征序列法在趋势行情里很准,但在剧烈震荡、笔的数量多且幅度接近时,特征序列的包含处理会反复改变结果,可能出现"一段被划成三段、第二天又变回一段"的情况。简化折线法稳定得多,但会漏掉一些复杂线段的细节。

我的选择是:主用特征序列法,但把"特征序列包含处理"和"破坏判定用收盘价还是极值价"都做成开关,同时额外记录一个简化法的结果作为对照。回测时用特征序列法的结果,如果发现某段时间信号异常密集,就切到对照结果看看是不是划分抖动导致的。

def segment_confirm(bis: List[Bi], i: int) -> bool: """判断第 i 笔所在线段是否被破坏(简化折线口径)""" if i < 3: return False cur_dir = bis[i].direction # 反向笔突破前一同向笔的起点,视为破坏候选 for j in range(i - 1, max(i - 4, -1), -1): if bis[j].direction != cur_dir: continue if cur_dir == 1 and bis[i].low < bis[j].start.price: return True if cur_dir == -1 and bis[i].high > bis[j].start.price: return True return False

这段只是简化口径的示意,真实实现里还要处理"破坏后是否形成新线段"的问题——破坏了不等于新线段成立,还要看后续能否走出独立的反向结构。这一点如果搞混,线段数量会翻倍。

4. 中枢与走势类型:结构嵌套的工程化

中枢是缠论的心脏,也是级别概念的载体。它的定义不复杂:连续三个次级别走势类型的重叠区间。麻烦的是"次级别"和"重叠"怎么落地。

4.1 中枢的 ZG 与 ZD 怎么算

用线段作为次级别走势时的算法是:取连续三段,第一段的高点记为h1、低点记为l1,第二段h2/l2,第三段h3/l3。中枢上沿ZG = min(h1, h2, h3),中枢下沿ZD = max(l1, l2, l3)。如果ZG > ZD,说明三段存在公共重叠区,中枢成立。

举个具体数字。假设三段的价格区间分别是[10.0, 11.5]、[10.6, 12.2]、[10.9, 11.8],那么ZG = min(11.5, 12.2, 11.8) = 11.5,ZD = max(10.0, 10.6, 10.9) = 10.9,重叠区[10.9, 11.5],宽度0.6,占中枢中轴价格的5.3%。这个宽度很重要:宽度太窄的中枢(比如小于0.5倍ATR)在实盘里基本没有操作价值,进出都会被成本吃掉,我一般会加一个过滤。

中枢形成后还要处理延伸和扩展。延伸是指后续走势仍在中枢区间内震荡,中枢的起止时间拉长但ZG/ZD不变;扩展是指走势离开中枢后又被拉回,形成更大级别的中枢。这两个概念在代码里必须分开处理,因为延伸只改end_idx和seg_count,扩展要新建一个更高级别的中枢对象并把原来的作为子结构挂上去。我见过把扩展直接当延伸处理的实现,结果级别永远是1,走势类型判断全错。

4.2 走势类型与级别递归

走势类型分为趋势(上涨/下跌)和盘整。判定依据是:同级别中枢是否出现两个及以上且方向一致、中枢之间是否有明确的连接段。这个判断在代码里需要一个状态机:记录当前处于第几个中枢、中枢之间的连接段方向、是否出现中枢区间不重叠。

级别递归是这套框架最能体现"模块化"价值的地方。理想做法是:把L3到L5整条链路包装成一个函数analyze_level(bars, config),输入低级别数据输出该级别的中枢和走势,然后把低级别的线段序列当作高级别的"K线"再喂进同一个函数。但实话说,这条路走通的人不多,因为高级别的"K线"如何定义OHLC就是一个巨大的口径问题。

我的务实做法是:不做真正的多级别递归,而是用同一套算法在不同周期的原始数据上各跑一遍,然后通过时间对齐把结果关联起来。日线跑出来的中枢和30分钟跑出来的中枢,判断它们是否"同级别",靠的是它们的时间跨度和价格区间的重叠程度,用经验阈值来卡。这套做法不优雅,但能跑、能调试、能解释,比一个跑不通的完美递归强得多。

5. 信号层与回测:把结构翻译成可执行动作

结构识别做得再漂亮,不能落到可执行动作上就是自娱自乐。这一层的核心原则是:结构给条件,策略给决策,执行给价格。

5.1 三类买卖点的工程化翻译

一买的结构条件是:下跌趋势中出现最后一个中枢,之后向下离开中枢的一段与前一段相比出现背驰。工程上要落到可计算的东西,"背驰"我用了两个判据:一是离开段的MACD柱面积小于进入段的某个比例(我常用0.8),二是离开段的价格幅度小于进入段。两个条件同时满足才算,单条件误报太多。

二买是:一买之后的第一次回调,回调低点不破一买低点。三买是:价格向上离开中枢后回调,回调低点不进入中枢区间(严格说是回调的最低点高于ZG)。三买的结构最清晰,误报也最少,我个人用三买的频率最高。

关键点在于确认时点。二买的"不破前低"必须等到回调段结束才能确认,而回调段结束时价格往往已经反弹了一段。这不是实现缺陷,是这套理论本身的性质决定的。所以我的信号事件里会同时记录structure_time(结构成立时刻)和signal_time(可执行时刻),回测里用后者,这样收益数字才是诚实的。

5.2 信号层与执行层解耦

执行层要处理的东西和结构完全无关:手续费、滑点、涨跌停无法成交、持仓上限、单品种风险敞口。我把这些全部放在执行模块,信号层只输出{时间, 方向, 品种, 参考价, 止损价, 信号强度}。这样换执行逻辑时,结构代码一行都不用动。

止损价的设置我用了两个锚点:结构锚点(比如一买的低点下方0.3倍ATR)和资金锚点(单笔最大亏损不超过总资金的1%)。两个锚点取更严格的那个作为最终止损。仓位则用止损距离反推:仓位 = 单笔风险预算 / (入场价 - 止损价)。这个算法比固定手数科学得多,因为它让每笔交易的风险是恒定的,回测出来的最大回撤也更可控。

5.3 回测口径与必须看的指标

回测最容易被自己骗。我列一下我每次必看的东西,缺一个我就不信这个结果:

指标我关注的原因健康区间参考
信号数量太少说明过拟合,太多说明过滤失效五年日线50-300笔
平均持仓周期与信号确认延迟强相关3-15个交易日
胜率单独看没意义,必须配盈亏比40%-55%
盈亏比低于1.5基本没戏1.5以上
最大回撤看的是回撤修复时间修复时间小于回撤期3倍
换手成本占比手续费+滑点占总收益比例低于30%
参数敏感性参数微调后收益是否断崖邻域内收益变化小于30%

最后一行的参数敏感性测试,是我判断策略是否过拟合的主要手段。具体做法是把min_bars从4扫到6、背驰比例从0.6扫到1.0,看收益曲线的形状。如果只有一个点表现好、旁边全是坑,那就是过拟合,别自欺欺人。

提醒一句:以上所有指标只在样本内成立,样本外的表现才算数。回测里没有"应该",只有"发生过"。

6. 性能与工程化:让框架能跑在真实场景里

单品种日线跑一遍几百毫秒,怎么写都行。一旦要跑20个品种、5个参数组合、10年分钟数据,纯Python的循环会把你按在地上摩擦。

6.1 增量计算与状态机

最有效的优化不是把循环写得更快,而是别重复算。分型和笔的计算本质上是一次线性扫描,历史部分一旦确认就不会再变。所以我把笔的构建做成增量式:维护一个"最后一个已确认分型"的指针和一个待确认分型缓冲区,每来一根新K线,只更新缓冲区,只有确认时才落库。

class BiStateMachine: def __init__(self, min_bars=5): self.min_bars = min_bars self.bis = [] # 已确认笔 self.pending_fx = None # 待确认分型 self.last_fx = None # 上一个已确认分型 def on_bar(self, merged_bars, fxs): """新K线到来后调用,fxs 为最新分型列表""" if not fxs: return latest = fxs[-1] if self.last_fx is None: self.last_fx = latest return if latest.kind == self.last_fx.kind: if (latest.kind == 'top' and latest.price > self.last_fx.price) or \ (latest.kind == 'bottom' and latest.price < self.last_fx.price): self.last_fx = latest return if latest.bar_idx - self.last_fx.bar_idx >= self.min_bars: self.bis.append(self._make_bi(self.last_fx, latest)) self.last_fx = latest

这套状态机的代价是:如果某天你修正了历史数据,或者改了min_bars参数,就必须从头重算。所以我的框架里维护了一个缓存键,键里包含品种 + 周期 + 复权方式 + 全部结构参数 + 数据版本号,任一变化就失效重算。这个设计的额外收益是:它能防止你在改了参数之后忘记重算、用旧结果跑出新结论。

6.2 瓶颈定位的实测经验

我用一组具体数据说话:10年日线约2400根K线,200个品种,一次全量结构识别。

环节优化前耗时优化后耗时手段
读数据+清洗8s8sIO瓶颈,优化空间小
包含处理45s6snumpy向量化预判+减少对象创建
分型识别12s5s去掉冗余的列表拷贝
笔构建30s4s状态机增量
线段划分180s40s缓存特征序列,避免重复包含处理
中枢识别25s6s只对新增线段做增量判定

线段划分是最大头,占了总时间一半以上。优化它的关键不是算法复杂度,而是减少重复的特征序列包含处理——很多实现每次判断线段破坏时都重新算一遍特征序列的包含关系,这是典型的重复劳动。

6.3 回归测试:这部分必须做

结构识别最怕的是"改了A,B悄悄坏了"。我的做法是维护一组固定的小样本,每个样本对应一个已知容易出错的场景:单边上涨、V型反转、长期横盘、包含关系密集、停牌跳空、极端缩量。每个样本跑完,把分型数、笔数、线段数、中枢数四个数字存成基准值,每次改动代码跑一遍对比。数字对不上就查原因,查完再更新基准。

这套测试帮我拦住过一次很隐蔽的回归:我把分型去重时用了set,结果同类分型在价格完全相同时顺序被打乱,导致笔的连接顺序变了。这种bug不跑回归测试绝对发现不了。

7. 常见问题与排查实录

7.1 问题速查表

现象高概率原因排查手法
笔数量明显偏多allow_share开着或min_bars太小打印笔长度分布,看短笔占比
中枢特别碎线段划分抖动,或中枢最少段数设成2把线段结果导出人工看几段
回测收益异常高未来函数,用了未确认结构检查confirm_idx是否有超前
信号当天出现当天成交用收盘价决策又用收盘价成交改成次日开盘价成交重跑
换参数后结果全变参数本身敏感,或缓存没失效关掉缓存强制重算对比
同一份数据两次结果不同用了无序遍历、字典/集合顺序全局排序,禁用无序结构
多品种结果差异极大数据复权口径不统一统一用后复权重跑
分钟线信号疯狂闪烁用了未走完的K线只在Bar闭合后触发计算

7.2 我踩过的三个坑

第一个坑:把"最后一笔的最低点"当成已知。早期的实现里,我判断三买时用了"回调段的最低点高于中枢上沿",而这个回调段在当下可能还没走完,它的最低点随时会被刷新。修复方式是把条件改成"当前价格是否已经离开中枢并且回调未被拉回",用已发生的事实而不是待确认的极值。

第二个坑:线段划分用全序列一次算完,再按时间取快照。这看起来很美,实际上每个时间点的线段划分都掺入了未来数据。后来我改成在每个confirm_idx处截断,重跑一次局部分析。性能差了几十倍,但结果是诚实的。这种做法在最终版本里被增量状态机替代,思路是一样的:只允许使用当下已确认的信息。

第三个坑:中枢级别标识混乱。我一开始用一个整数level表示级别,结果不同品种、不同周期的level没法比较,跨品种组合时完全乱套。后来改成用"平均持续时间+价格区间宽度"两个维度描述中枢,级别只作为同一品种内部的相对标记,组合层面靠这两个维度做匹配。

7.3 给准备动手的人几句实话

这套框架写到现在,结构识别的代码大概三千行,策略和回测加起来两千行,测试和工具脚本一千多行。真正花时间的地方不是算法,是口径统一和验证。如果你打算动手,我有三条建议:第一,先把结构识别的输出做成可视化的静态图或者导出的CSV,每改一次都肉眼对一遍,这一步省不得;第二,所有参数都要有默认值并且记录在配置文件里,别散落在代码注释中;第三,任何"我觉得这样更合理"的改动,都要配一个回归测试样本,否则过三个月你自己都不知道为什么这么写。

最后分享一个我个人在实际操作中的体会:这套框架真正开始好用,是在我放弃"让它替我做决策"这个念头之后。它的价值在于把一个模糊的图形判断变成一份可查询、可统计、可回溯的结构台账——某次亏损到底是因为结构判断错了,还是因为执行价太差,或者纯粹是行情没走出来,有了台账就能分开看。结构本身不产生收益,把结构和执行成本、仓位管理放在一起看,才有讨论的意义。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进