简介:一份关于网格交易策略自适应优化的技术资料,面向量化投资者与Python股票分析爱好者,解决固定网格在剧烈行情中容易过早满仓或空仓的问题。文档围绕作者提出的“魔灯”方法展开,先阐述网格交易原理与缺陷,再重点介绍自适应调整策略:以收盘价、最高价、最低价的标准差衡量波动,波动大时放大网格,波动小时缩小网格;同时按RSV相对强弱值将买入与卖出网格差异化,例如RSV=0.85时买入网格设为0.15元、卖出网格设为0.85元,并附完整计算示例。资源为单个docx文档,约165KB,内容层次清晰,从原理剖析到公式示例一应俱全。目前已有6000余人学习/下载,可作为构建个人量化策略或学习Python量化分析的有力参考。
1. 自动调整网格大小:从固定间距到波动率驱动的自适应网格
自动调整网格大小,本质上是把网格交易策略里手工拍定的网格间距,改成一个随时跟着波动率走的变量。固定网格最让人头疼的场景我见过不少:价格在窄幅区间里磨蹭,间距设得太细,来回成交,手续费一扣利润所剩无几;行情突然放大的时候,间距又显得太疏,价格一根K线就穿过好几个网格,仓位迅速堆满。问题的根源不在某个参数选得不好,而是网格间距被当成常量了。这个方案的核心思路是:用ATR或标准差实时估计波动率,把波动率按比例映射成网格间距,并自动撤掉旧挂单、按新间距重挂。文章后面会给出可运行的策略骨架、参数设置边界和几个最容易翻车的细节。适合正在写网格策略、觉得固定网格效率有限的开发者。
2. 网格大小为什么要跟着波动率走:从固定网格失效讲起
2.1 固定网格失效的边界:波动率变化后到底亏在哪
网格策略的盈利逻辑很简单:围绕一个基准价,每隔固定间距挂一层买单一层卖单,价格跌下来买进,涨上去卖出,赚的是每一个网格的价差。这个价差的大小,恰好等于网格间距乘以每格数量。问题是,盈利来源虽然简单,但它对「间距和波动率是否匹配」极其敏感。
拿一个具体的数字来说明。某品种基准价100,网格间距2%,每格数量10。一格成交带来的毛利润是100 × 0.02 × 10 = 20。如果交易对单边手续费是0.1%,双边就是0.2%,一格实际利润约等于20 - 100 × 10 × 0.002 = 18。这看起来没问题。但如果把间距缩到1%,一格毛利润变成10,同样双边手续费要扣掉2,手续费占利润的比例从10%涨到20%。间距越小,手续费占比越高,这是网格间距不能无限缩小的第一道约束。
再看波动率变化的影响。假设价格一天的正常波幅是4%,间距2%,那么一根日K线能穿过一到两个网格,成交节奏合适。当波幅突然放大到12%,同样一根K线可能从100一路扫到106,连续穿过三个网格。买单一层层被吃掉,仓位快速堆积,而价格后续回调时,网格接回来的速度跟不上持仓增长,持仓成本被整体抬高。反过来,波幅收缩到1%时,2%的间距意味着价格一天都摸不到下一格,资金闲置,网格形同虚设。
所以固定网格的失效边界在于:波动率变了,间距没变。间距本质上是「波动率的一个比例」,固定间距就等于假设波动率永远不变。这个假设一旦被打破,网格要么过度交易、要么完全不交易。
2.2 自适应网格的四个环节:估计、映射、计算、重挂
一个「自动调整网格大小」的策略,通常由四个环节串起来。第一是波动率估计,取最近N根K线,算ATR或者收益率标准差,得到一个代表当前行情剧烈程度的数字。第二是间距映射,把波动率通过一个线性关系转成网格间距,常见做法是「间距 = k × 波动率」,再对间距设一个下限,防止波动率接近0时网格密到无法执行。第三是网格计算,以当前价格或某个中轨价为参考点,按间距展开一层买单一层卖单,生成一串挂单价位。第四是撤单重挂,把旧的网格单全部撤掉,清理成交残留后,按新价位重新挂单。
这四个环节缺一个都会出问题。只改间距不撤旧单,新旧网格会在同一价格区间重叠,资金占用翻倍;只撤单不重挂,等于把策略停掉了。网上不少「动态网格」的实现,其实只是换了个间距重新画线,没有处理撤单和对账,实盘一跑就乱套。
说到「重新画线」,有一个实现细节值得注意:网格重挂必须是全量重排,而不是增量更新。比如旧间距是2%,新间距缩到1.5%,如果只是把新价位追加到原来的挂单列表里,那么原来2%间距的那批单子还挂在盘口上,新旧两套网格同时在跑,仓位规模会膨胀到无法控制。所以无论间距变大还是变小,每次调整都应当撤掉所有未成交单,基于最新参考价重新生成全套网格。
2.3 ATR、标准差与真实波幅:选哪个当波动率信号
波动率信号的选择直接影响网格调整的触发时机。三种常见指标各有偏向,用下来差异不小。
| 指标 | 计算方式 | 对跳空的反应 | 平滑度 | 适用场景 |
|---|---|---|---|---|
| ATR | 最近N根K线真实波幅的均值 | 敏感 | 中等 | 波动率突变频繁的品种 |
| 收益率标准差 | 最近N根K线收益率的标准差 | 迟钝 | 较平滑 | 趋势性较稳的行情 |
| 真实波幅 | 单根K线的 max(高-低, 高-昨收, 低-昨收) | 极敏感 | 噪声大 | 需先平滑后再用 |
我一般默认用ATR。它对跳空和单根大阳大阴反应直接,不会像收益率标准差那样把极端K线「平均」掉,适合网格这种需要感知区间边界的场景。如果行情本身噪声很大,把ATR周期从默认的14提到20或30,平滑度就上来了。真实波幅一般不直接当间距用,因为单根K线的噪声会导致网格频繁调整,通常先对TR做N期均值得到ATR,再去做间距映射。
选型还要结合K线周期。用4小时K线时,ATR的N取20左右比较稳,太短会被单根K线带偏;用1分钟K线时,ATR的波动很剧烈,最好再套一层EMA,或者直接用较长周期的标准差。这个选择没有绝对对错,但会影响后面要讲的调整阈值和冷却期怎么设。
3. 一个最小可运行的自动调整网格策略骨架:代码与执行时序
3.1 模块划分:波动率估计器、网格计算器、订单执行器
把自适应网格策略拆成三个模块,是我做这类策略时的习惯。波动率估计器负责喂K线、产出ATR或标准差;网格计算器负责把波动率映射成间距,再生成一组带方向的挂单价位;订单执行器负责撤单、对账、重挂的时序管理。这样拆的好处是换交易品种、换交易所时只改执行器,波动率和网格逻辑可以原样复用。
订单执行器是最容易出问题的地方,也是固定网格策略里原本没有的模块。固定网格建好之后很少动单,自适应网格每次重挂都要和交易所的撤单、查询、下单接口打交道,这里的时序错误会直接导致仓位错乱。
3.2 用 Python 搭一个自动调网格的最小策略骨架
下面这个骨架只保留核心逻辑,依赖一个抽象的Broker接口。真实环境里,把Broker的方法替换成对应交易所的API封装即可。
from dataclasses import dataclass @dataclass class Bar: ts: int # K线时间戳(秒) high: float # 最高价 low: float # 最低价 close: float # 收盘价 class Broker: """交易所接口抽象层,真实环境替换成对应 SDK 封装""" def cancel_all(self) -> None: """撤销所有未成交的网格挂单,并等待交易所确认返回""" ... def sync_state(self) -> dict: """拉取最新持仓、余额与成交记录,返回统一账户状态""" ... def place_orders(self, prices: list[float], qty: float) -> None: """在给定价位上按计算好的数量挂出全部网格单""" ... class AutoGridDemo: """自动调整网格大小:波动率驱动 + 撤单重挂""" def __init__(self, broker, k=1.0, atr_period=20, cool_down_sec=3600, min_spacing=0.005, max_grids=30, rebalance_pct=0.2): self.broker = broker self.k = k # 网格间距 = k * ATR self.atr_period = atr_period # ATR 窗口 self.cool_down_sec = cool_down_sec # 两次重挂的最短间隔 self.min_spacing = min_spacing # 间距下限,防止网格过密 self.max_grids = max_grids # 网格单边最大层数 self.rebalance_pct = rebalance_pct # 触发重挂的间距变化比例 self.candles: list[Bar] = [] self.grid_prices: list[float] = [] self.last_adjust_ts = 0 def on_bar(self, bar: Bar) -> None: # 1. 维护最近 N 根K线 self.candles.append(bar) self.candles = self.candles[-self.atr_period:] if len(self.candles) < self.atr_period: return # 2. 估计波动率,并把波动率映射成网格间距 atr = self._estimate_atr() grid_width = max(self.k * atr, self.min_spacing) # 3. 冷却期和变化阈值同时满足才触发重挂 if bar.ts - self.last_adjust_ts < self.cool_down_sec: return if self._width_changed(grid_width): self._resync_grid(bar.close, grid_width) self.last_adjust_ts = bar.ts def _estimate_atr(self) -> float: trs = [] for i in range(1, len(self.candles)): prev_close = self.candles[i - 1].close cur = self.candles[i] tr = max(cur.high - cur.low, abs(cur.high - prev_close), abs(cur.low - prev_close)) trs.append(tr) return sum(trs) / len(trs) def _width_changed(self, new_width: float) -> bool: if not self.grid_prices: return True # 首次启动直接建网格 old_width = abs(self.grid_prices[1] - self.grid_prices[0]) return abs(new_width - old_width) / old_width > self.rebalance_pct def _resync_grid(self, ref_price: float, grid_width: float) -> None: # 1. 先撤掉旧网格单,等待交易所确认 self.broker.cancel_all() # 2. 撤单完成后重新拉取账户状态,处理成交残留 state = self.broker.sync_state() # 3. 以最新参考价为中心生成网格价位 half = self.max_grids // 2 prices = [ref_price + grid_width * i for i in range(-half, half + 1)] # 4. 每格数量按可用资金与最大层数联动计算 qty = self._calc_qty(state, ref_price) self.broker.place_orders(prices, qty) self.grid_prices = prices def _calc_qty(self, state: dict, ref_price: float) -> float: # 每格数量 = 可用资金 * 单格风险比例 / 最大层数 available_cash = state.get("available_cash", 0.0) risk_per_grid = 0.02 cash_per_grid = available_cash * risk_per_grid / self.max_grids return cash_per_grid / ref_price逻辑上,这段代码在做三件事。on_bar每根K线都更新波动率估计,但更新网格要等两个条件同时满足:冷却期已过,且新间距和旧间距的差异超过20%。这个「双条件」设计是为了避免频繁重挂。_resync_grid的顺序是「先撤单、再对账、最后重挂」,这是整套实现里最关键的时序,下一节细说。_calc_qty把每格数量和最大层数绑在一起,间距缩小时网格层数不会无限膨胀。
参数上,k是网格间距相对ATR的倍数,k=1表示间距直接用ATR数值;想把网格调密就降到0.5左右,想疏一点就升到1.5。min_spacing设为0.005,表示即使ATR算出来是0.001,间距也至少是价格的千分之五,避免波动率极低时挂出几十层无法成交的单。cool_down_sec取3600秒,意思是波动率再怎么变,两次重挂至少间隔一小时。rebalance_pct取0.2,新间距和旧间距相比至少变化20%才触发重挂。
3.3 撤单与重挂的时序细节:先对账、再重挂
_resync_grid在真实交易所环境里,比骨架代码要复杂得多。第一个坑是撤单的异步性。很多交易所的撤单接口发出后不会立刻生效,需要轮询订单状态确认全部撤完。如果撤单命令刚发出去就挂新单,旧单可能还在盘口上,新单又上去了,同一价位附近出现双份挂单,资金占用直接翻倍。
第二个坑是撤单过程中的成交残留。从发出撤单指令到交易所确认之间,某些限价单可能刚好被吃掉一部分。如果策略不理会这部分残留,重挂后的网格基准价就会错位,网格整体偏向某一侧,变成「看似对称、实则偏多或偏空」。处理办法是撤单完成后强制调用一次sync_state,把最新持仓、余额、成交记录全部拉回来,再以这个状态去算每格数量。
第三个坑是参考价的选择。骨架代码里用on_bar触发时的bar.close作为重挂参考价,但真实环境中撤单确认已经过去了若干秒,价格大概率走了一段。更严谨的做法是以撤单完成后的最新盘口价或最新成交价作为参考价,保证新网格的中心点贴合当前价格。我一般会在撤单等待循环结束后重新取一次ticker价格,再生成网格价位。用一个更接近实盘的时序片段来表示:
def safe_resync(self, ref_price, grid_width): # 1. 撤单并等待全部确认 self.broker.cancel_all() deadline = time.time() + 10 while not self.broker.all_orders_cancelled() and time.time() < deadline: time.sleep(0.5) # 2. 撤单完成后强制对账,确认残留成交 state = self.broker.sync_state() # 3. 用最新价格重新取参考价,再生成并挂出新网格 latest_price = self.broker.fetch_ticker() half = self.max_grids // 2 prices = [latest_price + grid_width * i for i in range(-half, half + 1)] qty = self._calc_qty(state, latest_price) self.broker.place_orders(prices, qty) self.grid_prices = prices提示:all_orders_cancelled在真实API里通常实现为「查询未成交订单列表,返回空列表即全部撤完」。不要省略这个等待过程。
4. 参数怎么设才不矫枉过正:网格间距、波动率窗口与回测口径
4.1 波动率窗口与更新频率:平均多久重装一次网格
波动率窗口的选取直接决定网格调整是「跟得上行情」还是「反应迟钝」。ATR周期取14是很多指标的默认值,但对网格调整来说偏短。14根4小时K线只覆盖不到三天,任意一根大K线进出窗口都会让ATR抖动,进而触发不必要的网格重挂。我一般用20到30,相当于覆盖五到七天的波动水平。
更新频率要分两件事看:检查和调整。检查是每根K线都做,计算一次当前ATR和最新间距;调整则受冷却期和变化阈值双重限制。这样设计的原因是:检查代价低,调整代价高。每次调整都涉及撤单、对账、重挂三步,手续费和滑点成本都在这里,所以宁可多检查、少调整。
K线周期的选择也要配合品种。高波动品种用1小时K线就够,低波动品种可能需要4小时K线。一个实用的判断标准是:让ATR的数值稳定在网格间距的合理范围内,如果ATR经常小于min_spacing,说明K线周期太短或者波动率窗口太长,需要调整。
4.2 调整阈值与冷却期:避免在临界值反复触发
调整阈值rebalance_pct和冷却期cool_down_sec是配套的,两者缺一个都会出问题。阈值设太小,ATR在临界值附近抖动时就会反复触发重挂;阈值设太大,波动率明显变化了网格却不跟着调整,失去自适应的意义。我通常把阈值放在0.2到0.3之间,也就是间距变化超过两成再动手。
冷却期的设置要从「网格的成交节奏」出发。如果当前间距使得平均每4小时成交一层,冷却期至少应该覆盖一到两次成交,也就是1到3小时。如果成交频率是每30分钟一层,冷却期反而要更长,因为频繁成交本身就会改变持仓结构,此时再叠加网格重挂,仓位会变得很难对账。表里是我常用的几组参数范围。
| 参数 | 作用 | 常见范围 | 边界提示 |
|---|---|---|---|
| k | 间距相对ATR的倍数 | 0.5~1.5 | 小于0.5容易手续费超标 |
| ATR周期 | 波动率平滑程度 | 14~30 | 小于10会频繁触发调整 |
| rebalance_pct | 触发调整的间距变化阈值 | 0.15~0.30 | 小于0.1容易临界抖动 |
| cool_down_sec | 两次重挂的最短间隔 | 3600~14400 | 小于1800会过度交易 |
| max_grids | 单边最大层数 | 20~40 | 同时受资金和API挂单数限制 |
这些参数设完不是就结束了。某段时间参数调整在我这里一度像玄学,后来才发现问题不在单个参数的取值,而是参数之间的联动。比如max_grids从30提到40,单格风险比例没降,资金占用就会扩大;k从1.0降到0.6,成交频率上去了,冷却期不跟着调,手续费就会吃掉收益。所以每组参数都要放在一起看。
4.3 回测口径:手续费、滑点与资金占用怎么算
自适应网格比固定网格更容易在回测里虚高收益,原因是每次重挂都引入了固定网格没有的成本。第一个口径是手续费,网格策略本身就是高频交易,自适应网格又增加了撤单和重挂的次数,回测必须按双边手续费计算,不能只算单边。
第二个口径是滑点。网格单都是限价单,理论上没有滑点,但撤单重挂期间如果价格快速移动,新挂单可能以不利价格成交。我一般按网格间距的四分之一来估算这部分滑点,保守但够用。
第三个口径是资金占用。挂单占用的保证金要计入资金曲线,尤其是max_grids较大时。自适应网格在重挂瞬间会有一段「空窗期」——旧单撤了、新单还没挂上,这段时间仓位风险是暴露的。回测如果假设调网格瞬间完成,相当于送了一段无风险收益。更稳的做法是在回测里把撤单和重挂模拟为至少间隔一根K线,让价格有机会在这段时间里移动。
5. 自动调网格的5个常见翻车点:踩坑记录与修复方案
5.1 波动率骤降后网格越挂越密:单子堆积的修复
现象:行情从大波动突然转入窄幅震荡,ATR快速下降,网格间距被压缩到接近min_spacing,盘口上挂了几十层单,资金占用暴涨。
原因:多数情况是间距变小时,旧网格的远端单没有清理,新网格又在更窄的范围内叠加,导致同一价位附近不同间距的单子同时存在。另一种原因是max_grids没有参与资金计算,间距缩小后层数不变、单格金额不变,总占用自然翻倍。
解决:每次重挂必须全量撤单,不允许增量补单。max_grids作为硬上限,间距变小后强制压缩层数;每格金额用「可用资金 × 风险比例 / max_grids」反算,而不是沿用建仓时定死的数值。
5.2 波动率临界值抖动导致频繁重挂
现象:ATR在阈值附近来回横跳,策略每隔一两根K线就触发一次重挂,手续费持续累积,账户曲线稳步下滑。
原因:rebalance_pct设置太小,再加上ATR是对TR直接求平均,没有额外平滑,单根大K线进入窗口时ATR弹起、离开窗口时又跌落,形成锯齿状的估计值。
解决:把rebalance_pct提高到0.2以上,让触发条件变得迟钝;对ATR先做EMA平滑再使用;冷却期兜底,三招叠加使用。最关键的还是冷却期,它保证即使阈值被突破,两次重挂之间也有足够时间让行情走出一个完整结构。
5.3 撤单时的部分成交残留:持仓基准价错位
现象:一次重挂之后,网格上下层数看起来对称,但账户持仓明显偏多或偏空,后续价格一波动就出现单边亏损。
原因:撤单指令发出到确认之间,部分限价单成交了,仓位变了,但策略用撤单前记录的基准价生成了新网格,基准自然错位。
解决:撤单完成后强制重新对账,以最新持仓为准。如果残留仓位超过单格数量,先把残留部分纳入基准计算,或者等下一轮调整时用当前实际持仓重新铺网格。核心是「先对账、再挂单」,永远不要用撤单前的旧状态去生成新网格。
5.4 网格间距缩小导致资金占用失控
现象:间距从2%缩到0.8%,网格覆盖同样的价格区间需要多出一倍以上的层数,可用资金很快见底,极端情况下直接触发保证金不足。
原因:每格数量被设成常量,没有随网格层数或间距动态调整。间距缩小后层数变多,每格金额不变,总占用自然放大。
解决:每格数量 = 可用资金 × 单格风险比例 / 当前最大层数,每次重挂前重新计算。max_grids不只是一个层数限制,它应该同时参与每格金额的推导。这样即使间距缩小,资金占用也被限制在预设的风险比例内。
5.5 回测收益虚高的未来函数
现象:历史回测曲线像黑匣子一样漂亮,一上模拟盘就现原形,成交价格和持仓对不上。
原因:回测逻辑里用当根K线的收盘价计算ATR,又用同一个收盘价生成当根K线内的挂单价。这在回测中相当于提前知道了当根K线的高低价,实际交易里信号出现和下单完成之间有时间差。
解决:所有信号只使用已收盘的K线数据。在回测循环里,当根K线计算出的新间距和挂单价,从下一根K线开始生效;实盘实现中,用bar.close生成信号后,在下一根K线的开盘附近执行撤单重挂。这样虽然牺牲了一点及时性,但去掉未来函数后,回测结果才是可信的。
6. 进阶验证:用三种模拟行情压测自适应网格
6.1 构造波动率突变场景的模拟框架
参数调得再精细,没有在极端场景下验证过都等于没调。这里给一个简单的压测方法:生成带波动率阶段切换的模拟价格序列,跑固定网格和自适应网格做对比。随机游走虽然不能代表真实行情,但足以暴露策略在波动率突变时的行为差异。
import math import random def gen_regime_prices(base=100.0, n=2000, mode="normal"): """按模式生成模拟价格序列,用于压测网格策略""" prices = [base] p = base switch_at = n // 2 for i in range(1, n): if mode == "expansion" and i == switch_at: vol = 0.03 # 波动率放大三倍 elif mode == "shrink" and i == switch_at: vol = 0.002 # 波动率收缩到原来的五分之一 elif mode == "jump" and i == switch_at: p = p * 1.08 # 模拟一次跳空 prices.append(p) continue else: vol = 0.01 p = p * math.exp(random.gauss(0, vol)) prices.append(p) return prices分别用expansion、shrink、jump三种模式生成价格序列,喂给固定网格和上一章的AutoGridDemo。对比时重点看三个数:最大回撤、网格重挂次数、失效网格占比(超过N根K线仍未成交的挂单比例)。
6.2 三组模拟结果怎么评判优劣
三组场景各有侧重。expansion模拟波动率逐步放大,固定网格会出现成交过于密集、仓位快速堆积,自适应网格应该通过扩大间距来降低成交频率;shrink模拟波动率萎缩,固定网格会出现大量沉寂挂单,自适应网格的间距会收缩到min_spacing附近,但不应出现挂单数超过max_grids的情况;jump模拟一次跳空,重点看撤单重挂流程能不能扛住,账户里是否留下方向性偏差。
我习惯把每组结果的口径做成表格来对比:最大回撤、成交次数、重挂次数、失效网格占比。如果自适应网格在expansion场景里重挂次数比固定网格还多,说明冷却期或阈值设小了;如果在shrink场景里间距始终顶着min_spacing,说明ATR窗口太长,对波动率收缩反应太慢。这个压测方法不需要真实历史数据,五分钟就能跑完,但能把策略的问题暴露得比较充分。
有段时间我在某个模拟项目X里反复调k值,总觉得收益曲线不够漂亮是参数没调对,后来把波动率突变的模拟场景加进去才发现,真正决定策略生死的是撤单重挂的时序和资金占用的边界,而不是那零点几的倍数。那个教训让我养成了一个习惯:任何网格策略上线前,先做三组波动率压测,看它在极端行情下会不会失格。这个习惯我一直留到现在,希望帮到你。
本文还有配套的精品资源,点击获取