交易系统开发笔记 · 记录一次CTP接口的K线聚合改造过程
实盘交易中,CTP接口推上来的是逐笔Tick数据(约0.5秒一笔)。但交易策略基于的通常是分钟级甚至日级K线。让行情“自动成柱”,是本地化策略引擎的基础能力。
通过CTP获取市场数据只有实时的深度数据OnRtnDepthMarketData,没有K线。没有缓存。没有历史。
这意味着每次想看MA5、MACD这类指标,只能自己实时算——每个Tick都重新遍历所有历史数据,计算量大且重复。
于是决定动手改造:在行情流中嵌入K线聚合器,让各类策略直接消费OHLCV结构。
首先定义K线的存储模型(之前文章提供可以从掘金API获取历史K线,所以这个是参考了掘金的API,简化的BarData方便结合掘金的历史数据构建实时的K线):
设计思路:OpenInterest单独保留,因为期货策略常需要观察持仓变化;IsCompleted区分“正在形成的K线”和“已固定的历史K线”,避免后续算法误用半成品数据。
不同周期的K线,本质是对时间轴做等距切割。以分钟级为例,M5表示“每5分钟切一块”。
给定任意DateTime t 和周期 f,起始时间的计算规则:
t.Year, t.Month, t.Day, t.Hour, t.Minute, 0t.Year, t.Month, t.Day, t.Hour, (t.Minute / 5) * 5, 0t.Year, t.Month, t.Day, t.Hour, 0, 0t.Date更一般地,设分钟数为 m,切片函数可表示为:其中 Δ 为周期长度,T₀ 为当日零点。
实际编码中不需要这么复杂——直接对DateTime做整数除法再乘回去即可。
代码中的实现(以M5为例):
new DateTime(time.Year, time.Month, time.Day, time.Hour, (time.Minute / 5) * 5, 0);用两个ConcurrentDictionary来管理内存:
_currentBars:存储正在构建中的K线(未完结)_bars:存储已完结的历史K线(只读追加)
结构是 Symbol → Frequency → List<BarData>的三层嵌套。
每次Tick到来时:
这样每个Tick只更新当前K线,时间复杂度O(1),而不是每次都重建。
分时K线(Tick Bar)不是按时间切,而是按笔数切。代码中单独维护了计数器_tickCounters:
_tickCounters.AddOrUpdate(symbol, 1, (_, count) => count + 1);if (tickCount >= config.TickBarSize) { CompleteBar(...); _tickCounters[symbol] = 0;}TickBarSize通常设为100或200——太小噪声大,太大延迟高。
行情推送的频率很高,K线更新的动作必须足够轻。需要注意几个点:
两层字典的读写使用了ConcurrentDictionary,内部已做分段锁。更新Single Bar时直接修改对象属性(double赋值是原子操作),不加额外锁。
唯一加锁的地方是List
lock (bars) { bars.Add(bar);if (bars.Count > maxCount) bars.RemoveAt(0);}因为历史K线列表可能需要动态截断,List本身不是线程安全的。
万一某段时间没有新Tick(比如夜盘收盘前最后一分钟),当前K线可能永远不会完结。解决方案:一个60秒的Timer,定期扫描所有半成品K线,对比当前时间与BarTime,若超出周期则强制完结。
if (currentBar.BarTime != expectedBarTime) { CompleteBar(symbol, freqKey, currentBar);}行情回调OnRtnDepthMarketData原本只做存储,现在增加一行调用:
_mdAdapter.OnRtnDepthMarketData += p => { _marketDepths[p.InstrumentID] = p; _messageService.Send(p); ProcessMarketDataForBar(p); // 新增};这样对原有逻辑零侵入,K线聚合作为一个旁路模块独立运行。
数据量测试:订阅10个合约,同时启用M1/M5/M15/H1四个周期,运行一整天。内存占用稳定在50MB以内(每个合约约1000条历史K线)。
精度问题:CTP推送的UpdateTime是字符串“HH:mm:ss”,UpdateMillisec是单独字段。最初用DateTime.Parse直接拼,发现毫秒级精度丢失。改为:
new DateTime(now.Year, now.Month, now.Day, hour, minute, second, millisec);夜盘跨日:GetBarStartTime需要知道交易时段边界。目前的简化版本直接按自然日切,对于夜盘合约(如黄金、原油)会有问题。改进方案是将TradingDay纳入判断——这个留到下一版本。
最终暴露三个核心方法:
// 获取历史K线(供指标计算用)List<BarData> GetBars(string symbol, BarFrequency freq);// 获取当前实时K线(供盘中监控用)BarData GetCurrentBar(string symbol, BarFrequency freq);// 订阅K线完结事件voidSubscribeBar(Action<BarData> onBarClosed);策略层使用时,只需在OnBarClosed里拿到完整K线,计算MACD或均线即可,不必关心底层Tick细节。
后记:这套聚合逻辑在模拟盘跑了三周,没有发现漏K线或错位的情况。代码量不大,但把CTP原始行情和上层策略彻底解耦了。如果你也在做类似的事情,建议先把时间切片函数单测写好——边界条件(23:59、夜盘收盘前最后一分钟)最容易出错。
以上内容为个人开发过程记录,不构成投资建议。