ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LPPL模型实战:用Python对数周期幂律识别泡沫临界点

LPPL模型实战:用Python对数周期幂律识别泡沫临界点 简介这是一份面向金融量化分析与编程学习者的LPPL模型代码包适用于研究股市崩盘预警、量化策略回测或论文复现。包内共4个文件以Python脚本为主分为模型核心实现、辅助工具函数和RS作图模块压缩包仅5KB轻量易用方便直接导入训练数据运行。已有813人学习下载短小精悍适合快速理解LPPL模型的关键步骤。通过该代码读者可以掌握从历史价格数据清洗、时间序列转换到采用最小二乘或梯度下降进行参数拟合再到输出潜在崩盘点位置与可视化绘图的完整流程。绘图脚本还可标记拟合曲线与预测点辅助交叉验证模型稳定性。代码结构清晰便于按需修改参数或嵌入更大的量化分析框架。需要留意的是LPPL模型基于周期性与可预测性假设实际市场噪音较多建议结合其他金融指标共同判断避免单模型过度依赖。1. 一个“预测崩盘”的模型为什么我只信它给出的临界状态如果有人让我用 Python 量化交易策略代码去“预测崩盘”我通常直接摇头。但有一种情况例外用 LPPL 模型对数周期幂律模型去测“当前这轮上涨处在什么阶段”。它不承诺具体哪一天见顶却能给出一个很有价值的量化读数——价格是否已经进入加速自我强化的临界状态。这个来自金融物理学的方案核心是用一条带周期性修正的幂律曲线去拟合对数价格然后观察临界时刻 tc 的收敛程度。适合手里有行情数据、想在择时或风控里加一道“物理过滤器”的量化从业者和数据分析师新手也能照着代码跑通但要读懂输出得先明白它在拟合什么。2. LPPL模型拆解对数周期幂律在捕捉什么市场行为2.1 从一条“越涨越快”的价格曲线说起要理解 LPPL先看它把价格描述成什么。模型不是在预测每天的涨跌而是在拟合一段已经发生上涨的对数价格曲线log(P_t) A B·(t_c - t)^m·[1 C·cos(ω·ln(t_c - t) φ)]这里的 t 是相对时间通常把窗口第一天记为 0t_c 是模型内部的临界时刻P_t 是价格序列。记 ln(P_t) 而不是 P_t是因为在泡沫研究里对数价格能更好体现“百分比涨幅”的动力学也避免价格水平对拟合权重的影响。逐个参数看A 是对数价格的基线水平可以理解为临界时刻附近价格的对数值B 控制幂律项的方向和强度B0 时价格向上加速B0 时价格向下减速后者对应“反泡沫”或崩溃后的修复阶段m 是临界指数物理学上要求 0m1它决定了临界点附近价格奇异的“陡峭程度”C 是振荡项权重原始模型要求 |C|1意思是振荡修正不能盖过主体趋势ω 是对数周期角频率决定振荡在 log(t_c - t) 坐标下的频率φ 是相位t_c 是整条曲线的“奇点时刻”也是最终要输出的核心量。为什么 B0 配合 0m1 会得到“越涨越快”的效果因为 t 从 0 向 t_c 逼近时(t_c - t)^m 趋近于 0但它的导数趋近于负无穷。把 B 乘上去之后对数价格关于时间的斜率变成正数且越来越大于是 ln(P_t) 在图上呈现出一条向上凹的、末端几乎竖直抬升的弧线。这正是投机泡沫末期常见的价格形态上涨幅度和上涨斜率同时放大中间伴随越来越密集的回调。参数物理含义工程上常见取值A对数价格基线拟合得到B幂律幅度与方向拟合得到B0 才有泡沫含义C振荡权重拟合得到|C|1m临界指数(0.01, 0.95)ω对数周期角频率(2, 25)φ相位(0, 2π)t_c临界时刻相对时间轴大于窗口末点2.2 对数周期项捕捉“越来越密”的回调节奏为什么泡沫末期的回调间隔会越来越密金融物理的解释是“模仿效应”当越来越多参与者意识到趋势后买入行为相互强化形成正反馈。这种正反馈在时间轴上留下自相似的印记——把 t_c - t 放大一定倍数价格曲线形态近似不变。这种尺度不变性在数学上表现为当把横轴换成 log(t_c - t) 时周期性振荡的周期变为恒定也就是对数周期。这就解释了为什么振荡项选择 cos(ω·ln(t_c - t)φ) 而不是普通 cos(ωt)。普通余弦的波动间隔在时间轴上是等距的而对数周期项的间隔在靠近 t_c 时按比例缩短换来的是 log 坐标下的等间隔。实战里如果你画出 ln(P_t) 曲线发现回调底部之间的距离越来越短、越来越快这就是“对数周期”迹象。如果一段序列只是匀速上涨、回调间隔均匀那么 LPPL 的振荡项并不会带来拟合提升模型输出的 tc 也会很飘。2.3 LPPL模型的边界临界状态不等于崩盘日期很多人第一次接触 LPPL 就把它当成“崩盘预测器”这是最大的误用。t_c 是数学上让幂律曲线失稳的奇点时刻它在模型里的角色是“临界点”实际市场里崩溃可能提前触发监管干预、流动性抽离、突发事件也可能在 t_c 临近时以剧烈波动代替单日崩盘。因此在把结果接入交易时只能把 t_c 附近的区间当作高波动风险区而不是具体做空或清仓的日期。数据上LPPL 需要一段完整的“加速段”作为输入。常见做法是先看一段 120~250 个交易日的对数价格曲线确认它已经走出明显加速形态再进入拟合流程。横盘震荡、匀速爬升或者刚刚反转的窗口模型仍能拟合出一组参数但那组参数基本没有物理意义。实践里我一般先把价格序列画出来做一次人眼筛查再决定要不要让模型上场。另外还有两个常被忽略的前提价格序列里不能有除权缺口未复权、不能有停牌期间的 0 值选择的资产交易要连续。对日线数据如果中间缺少几天建议用前值填充后再做对数变换。为什么强调这一点因为 log 在非正数上会直接产生 NaN而 NaN 进入差分进化会让整个优化结果变成垃圾。后面第 3 章的代码里我会把这一层保护直接写进数据准备函数。3. 用Python跑通LPPL数据准备、目标函数与最小拟合代码3.1 把行情数据整理成对数价格序列首先明确输入是“一段窗口内的收盘价序列”。真实开发中这大概率来自你自己的数据库、CSV 或行情接口。无论来源是哪里统一把它读成 pandas DataFrameindex 是日期包含 close 列。清洗和复权必须在进入模型前完成不要指望优化器帮你处理脏数据。import numpy as np import pandas as pd def build_log_price(df: pd.DataFrame, end_date: str, window: int 120): 从日线DataFrame中截取一个窗口返回相对时间轴t和对数价格序列。 df: index为日期的行情数据至少包含close列。 end_date: 窗口最后一个交易日格式与df.index一致。 window: 向前回看的交易日数量。 close df.loc[:end_date, close].astype(float).tail(window) # 除权、停牌或脏数据会让close出现非正值直接拦下 if np.any(close 0): raise ValueError(close序列包含非正数请先复权并清洗数据) log_p np.log(close.to_numpy()) t np.arange(len(log_p)) # 相对时间从0开始 return t, log_p这段代码做了三件事按 end_date 截取窗口、丢弃窗口外数据、把收盘价转成对数。为什么用相对时间整数而不是真实日期因为 LPPL 公式里出现的 (t_c - t) 只要保持一致用整数能把数值量级控制在几十到几百避免真实日期字符串无法参与幂运算的问题。输出里 t_c 是相对天数需要还原成真实日期时再映射回去即可。3.2 用线性消元把七参数降到四参数直接对 7 个参数做全局优化理论上可以但实际效果很差。原因在于 A、B、C 三者之间存在组合关系差分进化会在维度灾难里浪费大量迭代。更常见的做法是先固定 m、ω、φ、t_c把模型改写成三项线性叠加设 x (t_c - t)^my x·cos(ω·ln(t_c - t)φ)则 ln(P_t) A B·x B·C·y。令 D B·C那么 (A, B, D) 对给定非线性参数是最小二乘问题可以直接用 np.linalg.lstsq 一步解出。搜索维度从 7 降到 4收敛概率大幅提升这也是我踩过多次全参数优化的坑之后固定下来的做法。def lppl_objective(nonlin, t, log_p): m, omega, phi, tc nonlin if tc t[-1]: return 1e10 dt tc - t with np.errstate(overignore, invalidignore): power dt ** m osc np.cos(omega * np.log(dt) phi) design np.column_stack([np.ones_like(power), power, power * osc]) coef, *_ np.linalg.lstsq(design, log_p, rcondNone) fitted design coef return float(np.sum((log_p - fitted) ** 2))目标函数里最关键的是第一道拦截tc t[-1] 时直接返回 1e10 这个极大的损失。原因是 (t_c - t) 必须为正tc 一旦落到窗口内部后续 log 和幂运算就会出现非正数产生 NaN。返回大数而不是直接报错是为了让差分进化在种群迭代时自动淘汰这些非法个体。design 是三列设计矩阵分别对应常数项、幂律项和振荡项用 np.errstate 包住计算避免极端候选解刷屏 Warning。3.3 一次完整的差分进化拟合与结果解读准备好数据和目标函数后就能调用 scipy 的全局优化器。下面是一段可直接运行的完整代码窗口设为 120 个交易日from scipy.optimize import differential_evolution window 120 bounds [ (0.01, 0.95), # m: 临界指数 (2.0, 25.0), # omega: 对数周期角频率 (0.0, 2 * np.pi), # phi: 相位 (window - 1.001, window * 2) # tc: 相对时间大于窗口末点 ] res differential_evolution( lppl_objective, bounds, args(t, log_p), seed7, popsize20, maxiter500, polishTrue, tol1e-8, mutation(0.5, 1.0), recombination0.8, workers1 ) m, omega, phi, tc res.x dt tc - t power dt ** m osc np.cos(omega * np.log(dt) phi) X np.column_stack([np.ones_like(power), power, power * osc]) A, B, D np.linalg.lstsq(X, log_p, rcondNone)[0] C D / B print(fm{m:.3f} omega{omega:.3f} tc{tc:.2f}) print(fA{A:.3f} B{B:.3f} C{C:.3f}) print(f拟合误差{np.sqrt(np.mean((log_p - X [A, B, D]) ** 2)):.5f})这里几个参数值得解释。popsize20 和 maxiter500 是拟合 LPPL 的常用组合polishTrue 让差分进化在收敛后用 L-BFGS-B 对最优解做一次局部精修把 tc 精度从 1e-6 量级提升到 1e-2 量级workers1 保证结果可复现换机器也不容易漂。seed 固定后反复运行结果应当完全相同。如果你为了提速把 workers 改成 -1就要接受不同批次结果有微小差异这在回测里很致命。拿到结果先看三项m 是否在 (0.1, 0.9) 区间内B 是否小于 0|C| 是否小于 1。如果 m 贴着 0.95 上边界或者 C 的绝对值大于 1这组拟合更可能是数学上的最优而不是物理上有意义的泡沫信号。我把这三项检查叫“物理区间过滤”后面排错部分还会展开。4. 参数边界与优化器设置LPPL拟合的四个关键旋钮4.1 非凸误差面为什么梯度下降在这里基本没用LPPL 的目标函数在参数空间里是高度非凸的。随便给一组初值让 L-BFGS-B 直接优化十有八九停在某个局部谷可能是 omega 被拟合到高频噪声上可能是 tc 跑到窗口之外。原因在于对数周期项的振荡频率和相位会让误差面出现大量平行山谷几乎每个谷都对应一组“看起来能解释部分数据”的参数。因此实际项目中我很少只做局部优化而是把差分进化当第一道粗筛。差分进化先用种群在边界内撒点通过变异和交叉向低误差区域收缩最后再用 polish 精修。这相当于先用粗网格把整片山区扫一遍再对最低点做精细测量。如果你跳过这一步直接用 scipy.optimize.minimize 从随机点出发结果会很随机。4.2 m、omega、phi、tc 四参数上下界的工程取值在差分进化里边界的设置直接决定搜索效率和结果性质。给得太宽会浪费迭代给得太窄会漏掉合理解。我的常用边界如下参数下界上界依据m0.010.95m 超过 0.95 时临界奇异几乎消失低于 0.01 时退化为常数项omega2.025.0小于 2 时对数周期振荡在窗口内不足一个周期大于 25 时周期过密日线数据不可能支撑phi02π相位本身无物理限制取完整周期即可tct[-1]1e-3t[-1]windowtc 必须大于窗口末点上界约为一个窗口长度防止 tc 漂到太远的未来tc 的边界值得多说两句。下界必须严格大于 t[-1]因为 (t_c - t) 对最后一个点也要是正数如果 tc 等于最后一天log(0) 直接产生负无穷。上界取“末点窗口长度”是经验值。我的做法是如果最优 tc 稳定落在区间中间这个信号可信度更高如果 tc 老贴着上界说明价格还没走到“临界前夜”需要换更长窗口或放弃该资产。4.3 differential_evolution 的参数旋钮很多人把 scipy 的差分进化当黑盒只传 bounds 就撒手结果就是“有时灵有时不灵”。关键参数按下面的经验来调popsize15~20每个维度的种群粒子数。LPPL 误差面噪声大默认值通常够但如果你发现连续几次用不同 seed 的结果差别很大先加到 25 看看而不是急着加 maxiter。maxiter500~1000迭代代数。收敛度由 popsize 和 maxiter 共同决定。一组快速判断方法固定 seed 跑完打印 res.fun 和 res.nit如果 res.nit 顶到 maxiter 且 res.fun 明显高于多次尝试的最小值说明迭代不够。polishTrueDE 结束前的局部精修。建议保持开启因为 DE 的收敛精度只有 1e-6 量级而最终输出的 tc 可能需要 1e-2 天级别的精度。mutation(0.5, 1.0) 取一个区间让算法在不同阶段自适应变化强度比固定单值更稳。recombination0.8 是连续参数问题的通用默认值。tol1e-8 是种群相对收敛容差设小了会增加迭代次数设大了可能提前收敛到粗糙解。另外务必固定 seed。别小看这个习惯不固定 seed你的回测、复现、参数对比全部失去意义。同一份代码同一个 seed跑出来的参数必须一字不差才算一次可复现的 LPPL 分析。4.4 数值保护与失效模式在差分进化搜索过程中种群会产生大量不合法的候选参数。比如 tc 小于等于窗口末点、dt 过大导致幂运算溢出、omega 很大导致 cos 参数巨大。这些情况放任不管轻则 Warning 刷屏重则把目标函数变成 NaN而 NaN 在比较运算符里会产生不可预期的排序导致整个种群退化。我习惯在目标函数入口先做一次合法性检查tc t[-1] 时直接返回 1e10然后 np.errstate 屏蔽溢出 Warning。1e10 不是随便取的它要远大于正常拟合残差又避免使用 Inf因为 Inf 在后续运算里可能重新引入 NaN。用“大而有限”的值差分进化能稳妥地淘汰非法个体。最后一个容易忽略的点线性消元用的是 np.linalg.lstsq 而不是直接求逆。design 矩阵三列之间存在相关性如果直接用 np.linalg.solve(design.T design, ...)在 |C| 接近 1、幂律项和振荡项高度相关时会得到病态解。lstsq 用 SVD 做最小二乘数值稳定性好得多代价只是微不足道的速度损失。5. LPPL模型落地排查5个踩坑记录与修复方法5.1 拟合结果每次都不一样DE没“进化完”最常见的症状是同一份数据、同一个脚本只是改了 seed输出的 tc 从 110 跳到 75m 从 0.3 跳到 0.7。一开始我也以为是随机种子在作怪后来打印 res.nit 才发现差分进化根本没有收敛maxiter 用完了还停在半路。原因有两层要么种群太小误差面上有多个深度接近的谷种群没法聚焦到其中一个要么 maxiter 不够算法还在“探索”阶段就被叫停。我在实践里发现LPPL 中八成“结果不稳定”都出在 popsize 太小。解决方法是固定 seed 后把 popsize 从 20 加到 30maxiter 从 500 加到 1000重新对比 res.fun。如果几个 seed 得到的目标函数值都接近同一数值但参数仍分散说明误差面上存在多个深度几乎相同的谷这时要按参数物理意义取舍优先保留 B0、|C|1、m 在 0.1~0.8 这组解。5.2 拟合出B0这段行情不是LPPL眼中的泡沫有一次我跑纳斯达克指数的一段日线拟合结果很漂亮拟合误差小到小数点后四位但 B 是 0.02。当时差点直接拿去用后来回头看了一眼对数价格曲线才意识到问题那段行情根本是“减速上涨”斜率在收窄不是 LPPL 描述的加速泡沫。原因在于 B0 意味着 ln(P_t) 在向 t_c 逼近时斜率在衰减对应的是价格减速收敛这通常出现在下跌趋势的尾部或牛市后的修复阶段。LPPL 模型原本描述的加速泡沫需要 B0B0 只能说明模型找到了一条数学上拟合得不错、物理上完全不同含义的曲线。解决方法是先检查窗口起点是否选晚或选早了。泡沫加速段通常从“开始明显加速”的位置起算如果窗口把前期慢涨和后期加速混在一起B 的符号可能不稳定。更稳妥的做法是换一段更纯粹的加速段重新拟舍或者直接认定该资产当前不适合用 LPPL。不要为了让结果好看而硬保留 B0 的参数那是自欺欺人。5.3 对数价格突然出现NaN非正数混进数据了运行 build_log_price 时报 log 出错或者差分进化跑到一半目标函数返回 NaNres.x 全是 nan这是新手最容易遇到、也最隐蔽的问题。表面上代码逻辑没问题实际是行情数据里有 0 或负值。原因几乎都出在数据源除权未复权、退市整理期价格异常、停牌期间被补成 0。少数情况是 tc 等于窗口末点时 log(dt) 遇到 0但这一点已经在目标函数里拦掉了。真正需要防的是上游数据本身不干净。解决方法是把检查写在数据准备函数里越早越好。我在 3.1 的代码里已经加了if np.any(close 0): raise ValueError目的就是把脏数据拦在模型外面。遇到停牌空值用 ffill 填充或直接剔除该交易日除权问题必须复权后再做对数变换因为价格缺口在 LPPL 里会被当成一次剧烈波动直接影响 omega 和相位估计。5.4 tc总贴在窗口末点或上边界模型在“强行营业”跑多轮之后发现 tc 不是等于 t[-1] 附近就是顶到上界 2×window很少落到区间中间。这种“边界解”看起来也是优化结果其实是在强行拟合一段不具备临界特征的价格序列。tc 卡下界说明当前窗口内价格接近线性临界奇异性还没形成差分进化只能用最近的点去凑一个“明天就崩”的假象。tc 卡上界说明数据里根本没有足够信息让曲线上翘模型把临界点推到边界外才勉强拟合。解决方法是卡下界时把窗口起点往前移到加速启动位置或者再多积累几十个交易日再拟合卡上界时先把 tc 上界放宽到 3×window 跑一次如果 tc 仍然贴着新边界基本可以判定这段序列没有 LPPL 意义上的临界行为别硬解。记住模型不会拒绝任何数据拒绝是你的责任。5.5 全市场几十只股票同时报警忘了加物理区间过滤我在滚动回测里踩过最大的坑对股票池里 200 只股票做滚动 LPPL结果每天有 30 只都输出“tc 在未来 10 天内”看起来像一场集体崩盘预警。如果真照这个信号操作每天都要清仓一半持仓。原因在于 LPPL 对任意一段上行价格都能拟合出一组数学最优参数它本身没有内置“是不是泡沫”的判断。如果只按 tc 取值筛选等于把数学拟合结果直接当成了宏观警报。缺的正是物理区间的过滤B 的符号、|C| 的范围、m 和 omega 的合理区间、残差水平。解决方法是写一个 check 函数把条件串起来B0、abs(C)1、0.1m0.8、2omega15、拟合 RMSE 控制在价格对数标准差的 10% 以内。只有全部通过才置为“候选信号”。最后再要求连续两个滚动窗口都指向相近 tc比如 tc 与窗口末点的时间差变化不超过 20%才允许进入预警名单。这样一圈过滤下来真正报出来的数量通常是个位数而不是几十个。6. 让LPPL从“拟合好”到“能信”稳定性和可视化验证6.1 滚动窗口重估看tc是否“跟得上”拟合只是一次快照要判断信号可信我习惯每 5 个交易日滑动一次窗口重估。如果资产真的处于加速泡沫期随着窗口后移tc 也应该稳步向后移动且 tc 与窗口末点的时间差大致稳定。如果 tc 一直原地不动、与窗口末点的距离越来越近往往意味着临界点确实临近了如果 tc 随机跳动说明参数没有被数据充分约束。具体操作是写一个 for 循环重复调用第 3 章那套拟合函数记录每个窗口的 (m, omega, tc)。最后把 tc 序列画成一条曲线观察走向。这一步非常依赖 Python 数据分析与可视化的基本功把多窗口的参数变化画在同一张图上比看单个拟合结果可靠得多。6.2 画拟合曲线与残差用眼睛验收拟合优度不能只靠 R²。LPPL 曲线在视觉上有非常明显的形态画图能帮你清除很多统计盲区。把对数价格、模型拟合线和残差放一起import matplotlib.pyplot as plt dt tc - t fitted A B * (dt ** m) * (1 C * np.cos(omega * np.log(dt) phi)) resid log_p - fitted fig, (ax1, ax2) plt.subplots(2, 1, figsize(10, 6)) ax1.plot(t, log_p, labellog price) ax1.plot(t, fitted, --, labelLPPL fit) ax1.legend() ax2.plot(t, resid, labelresidual) ax2.axhline(0, colorgray, lw0.5) ax2.legend() plt.show()画完看两点残差是否均匀分布在 0 轴两侧而不是在大波段上同步偏离残差后半段是否越来越密。如果残差在后半段明显偏向同一侧或者振荡频率和拟合线不一致先怀疑 omega 被优化到了不合理的值再怀疑窗口内有事件干扰。我自己的习惯是每次拿到新的拟合结果先运行这段可视化脚本确认曲线形态符合 LPPL 的“末端翘起振荡加密”特征再谈参数。数学上好看、物理上不像的拟合在大规模回测里最终都会露出马脚。做 LPPL 这几年我最深的教训是把它当“风向标”而不是“发令枪”。它告诉你的是市场正在接近临界而不是哪一天崩盘把 tc 那一行当作硬止损点的人通常会被市场教育。合理的用法是先用多个窗口确认临界读数稳定再把它并入原有风控流程作为降低仓位或收紧止损的参考信号。希望帮到你。本文还有配套的精品资源点击获取
返回列表