第 6 章 · 01 BarGenerator:Tick 到 K 线的合成器 本节摘要:本节精读 的第一职责——把逐笔 Tick 合成 1 分钟 K 线。这是国内期货 CTA 策略的入口:CTP 推送的是高频 Tick(每秒多个),但绝大多数趋势策略基于 1 分钟(或更大周期)K 线决策,所以需要"降频"。BarGenerator 的 updatetick 方法接收 Tick,按"交易所时钟面的分钟切片"判定跨分钟,跨分钟时推送上一根并起一根新的;同分钟内累加 high/low/close,并用 算增量成交量。本节逐行拆解这套逻辑,并讲清为什么 volume 必须做增量而非直接用累计值。 内容来源:原项目源码 (BarGenerator 类 L166-485),精读并套用体系化模板。
本节摘要:本节精读
BarGenerator的第一职责——把逐笔 Tick 合成 1 分钟 K 线。这是国内期货 CTA 策略的入口:CTP 推送的是高频 Tick(每秒多个),但绝大多数趋势策略基于 1 分钟(或更大周期)K 线决策,所以需要"降频"。BarGenerator 的 update_tick 方法接收 Tick,按"交易所时钟面的分钟切片"判定跨分钟,跨分钟时推送上一根并起一根新的;同分钟内累加 high/low/close,并用tick.volume - last_tick.volume算增量成交量。本节逐行拆解这套逻辑,并讲清为什么 volume 必须做增量而非直接用累计值。
内容来源:原项目源码
vnpy/trader/utility.py(BarGenerator 类 L166-485),精读并套用体系化模板。
阅读完本节,你应当能够:
__init__ 的五个参数(on_bar/window/on_window_bar/interval/daily_end)。BarGenerator 干两件事(本节讲第一件,第 02 节讲第二件):
update_tick(tick) 接收逐笔 Tick,跨分钟时通过 on_bar(bar) 回调推送 1 分钟 Bar。update_bar(bar) 接收 1 分钟 Bar,按 window/interval 配置合成更大周期,通过 on_window_bar(bar) 回调推送。一个典型的 CTA 策略用法:
class MyStrategy(CtaTemplate): def __init__(self): # 1 分钟 Bar 回调:喂给 15 分钟合成器 self.bg = BarGenerator(self.on_bar, 15, self.on_15min_bar, Interval.MINUTE) # 15 分钟 Bar 的 ArrayManager self.am = ArrayManager() def on_tick(self, tick): self.bg.update_tick(tick) # Tick → 1 分钟 def on_bar(self, bar): self.bg.update_bar(bar) # 1 分钟 → 15 分钟 def on_15min_bar(self, bar): self.am.update_bar(bar) # 15 分钟 → 数组 if self.am.inited: ma = self.am.sma(10) # 算指标
utility.py:176-202:
176 def __init__( 177 self, 178 on_bar: Callable, 179 window: int = 0, 180 on_window_bar: Callable | None = None, 181 interval: Interval = Interval.MINUTE, 182 daily_end: time | None = None 183 ) -> None: 184 self.bar: BarData | None = None # 当前正在合成的 1 分钟 bar 185 self.on_bar: Callable = on_bar # 1 分钟 bar 完成回调 186 187 self.interval: Interval = interval # 合成目标:MINUTE/HOUR/DAILY 188 self.interval_count: int = 0 # 小时 bar 的窗口计数 189 190 self.hour_bar: BarData | None = None # 合成小时 bar 的中间态 191 self.daily_bar: BarData | None = None # 合成日 bar 的中间态 192 193 self.window: int = window # 窗口大小(N) 194 self.window_bar: BarData | None = None # 合成 N 分钟/N 小时 bar 的中间态 195 self.on_window_bar: Callable | None = on_window_bar 196 197 self.last_tick: TickData | None = None # 上一个 tick,用于算 volume 增量 198 199 self.daily_end: time | None = daily_end 200 if self.interval == Interval.DAILY and not self.daily_end: 201 raise RuntimeError(_("合成日K线必须传入每日收盘时间"))
关键参数:
on_bar(必填):1 分钟 Bar 完成时的回调,策略在这里收到分钟 Bar。window:窗口大小,比如 15 表示合成 15 分钟。0 表示不做窗口合成(只产出 1 分钟 Bar)。on_window_bar:窗口 Bar(N 分钟/N 小时/日)完成时的回调。interval:合成目标的周期枚举,默认 MINUTE。daily_end:合成日 K 线时必须传每日收盘时间(否则报错),因为日 Bar 完成要靠"到收盘时间"判定。核心方法 utility.py:204-260(节选关键):
204 def update_tick(self, tick: TickData) -> None: 205 new_minute: bool = False 206 207 # 过滤 last_price 为 0 的异常 tick 208 if not tick.last_price: 209 return 210 211 if not self.bar: 212 new_minute = True 213 elif ( 214 (self.bar.datetime.minute != tick.datetime.minute) 215 or (self.bar.datetime.hour != tick.datetime.hour) 216 ): 217 self.bar.datetime = self.bar.datetime.replace(second=0, microsecond=0) 218 self.on_bar(self.bar) 219 new_minute = True 220 221 if new_minute: 222 # 新一根 1 分钟 bar,OHLC 全部初始化为当前 last_price 223 self.bar = BarData( 224 symbol=tick.symbol, exchange=tick.exchange, 225 interval=Interval.MINUTE, datetime=tick.datetime, 226 gateway_name=tick.gateway_name, 227 open_price=tick.last_price, 228 high_price=tick.last_price, 229 low_price=tick.last_price, 230 close_price=tick.last_price, 231 open_interest=tick.open_interest 232 )
逐段解读:
1. 过滤异常 tick(208-209 行):last_price 为 0 通常是开盘前的废 tick,直接丢弃。
2. 跨分钟判定(211-219 行):两种情况算"新的一分钟"——
self.bar 为 None(第一根,还没起 bar);minute/hour 与 self.bar 的不同(跨分钟了)。跨分钟时,先把上一根 bar 的 datetime 的秒/微秒抹零(217 行,让它代表"这一分钟起点"),再 on_bar(self.bar) 推送(218 行)。
💡 核心心法:跨分钟判定看的是交易所时钟面的 minute/hour,而非"距上个 tick 60 秒"。这是因为真实交易里 tick 到达间隔不均匀(可能 5 秒没 tick 然后 1 秒来 10 个),按钟面切片才能保证每根 Bar 严格对应一个完整分钟。
3. 新 Bar 初始化(221-232 行):跨分钟了,起一根新 bar,OHLC 全部初始化为当前 last_price(开盘价 = 最高 = 最低 = 收盘 = 第一笔成交价)。
4. 同分钟内更新(继续往下):
233 elif self.bar: 234 # 同一分钟内,更新 high/low/close 235 self.bar.high_price = max(self.bar.high_price, tick.last_price) 236 if self.last_tick and tick.high_price > self.last_tick.high_price: 237 self.bar.high_price = max(self.bar.high_price, tick.high_price) 238 239 self.bar.low_price = min(self.bar.low_price, tick.last_price) 240 if self.last_tick and tick.low_price < self.last_tick.low_price: 241 self.bar.low_price = min(self.bar.low_price, tick.low_price) 242 243 self.bar.close_price = tick.last_price 244 self.bar.open_interest = tick.open_interest 245 self.bar.datetime = tick.datetime
high/low 的更新有个细节——同时参考 last_price 和 tick 自带的 high_price/low_price(236-237 行)。因为有些交易所的 tick 里直接带"累计最高/最低价",取更宽松者保证不漏极值。close 永远是最新 last_price,datetime 也更新到最新 tick 时间。
247 # volume/turnover 是累计值,要靠与上一个 tick 求差得到增量 248 if self.last_tick and self.bar: 249 volume_change: float = tick.volume - self.last_tick.volume 250 self.bar.volume += max(volume_change, 0) 251 252 turnover_change: float = tick.turnover - self.last_tick.turnover 253 self.bar.turnover += max(turnover_change, 0) 254 255 self.last_tick = tick
⚠️ 重要说明:这是 Tick 合成 Bar 最容易踩的坑。交易所推送的
tick.volume通常是当日累计成交量(从开盘到现在总共成交了多少手),不是"这一个 tick 成交了多少"。如果直接self.bar.volume = tick.volume,得到的 Bar volume 就是"到这根 Bar 为止的累计量",而非"这根 Bar 内的成交量"——毫无意义。
正确做法是与上一个 tick 求差:volume_change = tick.volume - last_tick.volume,得到"从上个 tick 到这个 tick 之间成交了多少",累加进 bar。max(..., 0) 是防御跨日重置(开盘第一笔 tick.volume 可能小于昨日最后一笔)导致的负数。
最后一行 self.last_tick = tick 把当前 tick 存起来,供下一个 tick 求差。
utility.py:474-485:
474 def generate(self) -> BarData | None: 475 bar: BarData | None = self.bar 476 if bar: 477 bar.datetime = bar.datetime.replace(second=0, microsecond=0) 478 self.on_bar(bar) 479 self.bar = None 480 return bar
用途:盘中如果一直有 tick 进来,跨分钟时会自然推送。但收盘后没有新 tick,最后一根未走完的 1 分钟 Bar 永远等不到"跨分钟"触发,会残留在 self.bar 里。
策略或引擎在合适时机(如收盘、断开连接、查询周期结束)手动调 generate(),把残留 Bar 按当前状态推送出去,避免丢最后一根。这就是为什么实盘里"15:00 收盘后还能收到最后一根 Bar"——靠的是收盘逻辑调 generate。
假设 10:30:00 到 10:31:00 之间的 tick 流(简化):
10:30:15 last=100.0 volume=1000 → 新 bar(open=high=low=close=100.0) 10:30:30 last=101.5 volume=1050 → 更新 high=101.5, close=101.5, vol+=50 10:30:45 last=100.5 volume=1080 → 更新 low=100.5, close=100.5, vol+=30 10:31:05 last=102.0 volume=1120 → 跨分钟!推送 bar(O100 H101.5 L100.5 C100.5 V80) 起新 bar(open=high=low=close=102.0)
最终 10:30 这根 Bar:open=100.0, high=101.5, low=100.5, close=100.5, volume=80(1050-1000 + 1080-1050 = 80)。
tick.volume - last_tick.volume,因为 tick.volume 是当日累计值。下一节,我们看 1 分钟 Bar 怎么合成 N 分钟/N 小时/日 Bar——update_bar 的三个分支,以及为什么 N 分钟必须整除 60。