从一个开发工程师的角度,聊聊“什么是 K 线”

先把话挑明:K 线不是“画出来”的,而是“算出来”的。它是对一段时间内价格与成交的压缩表示:Open、High、Low、Close、Volume(常加 Turnover、交易笔数),按某种“窗口”聚合而成。听起来像个普通的聚合任务,但真要把它在交易系统里做稳、做快、做准,会踩很多坑。

K 线到底指什么

  • 时间维度:常见有 1s、1m、5m、15m、1h、日/周/月 K。不是自然时间,而是“交易所时间”。比如 A 股有午休,美股有夏令时,国内期货有夜盘。
  • 事件来源:一般从成交(Trade)事件聚合,也有用最新成交价驱动的 Quote K(更接近行情视角)。有的市场会有撤销/更正(Cancel/Correct)事件。
  • 数值字段:价格精度、最小变动价位、合约乘数、成交量单位(股/手/张)、货币与汇率,这些元数据必须进来一起算。
  • 类型变体:时间 K(最常见)、成交量 K(每 X 手出一根)、价格区间 K(Range Bar)、平均 K(Heikin Ashi/VWAP 相关)。业务要说清楚要哪种。

如果要在生产系统里做,这些是会“咬你”的点

1) 时间边界和交易日历

  • 不同市场的开闭市/午休/夜盘/节假日/临时停牌,导致“窗口”不是整齐的自然时间。
  • 夏令时切换会出现 61 分钟或 59 分钟的“钟”,不要按本地时区算,要用交易所时区。
  • 周/月 K 的边界不是自然周/月,遇到长假要正确收口。

解决思路

  • 维护“交易日历服务”,给出某标的在某天的有效交易片段,预先切好每个周期的窗口。
  • 一律用 java.time 下的 ZonedDateTime 并固定为交易所时区,禁止用系统默认时区。
  • 对跨日/夜盘的窗口,用 sessionId(如 20250809-Night)做键的一部分。

2) 数据顺序、重复与迟到

  • 实时流里常见乱序、重复、延迟到达。极端情况下还会收到撤销/更正(对某笔成交)。
  • 分区策略不当会把同一标的打到不同分区,顺序直接没了。

解决思路

  • 流分区按 symbol(甚至 symbol+venue)哈希,保证单分区内顺序;同一 symbol 聚合必须单线程。
  • 设置可接受的最大乱序窗口(比如 3 秒),窗口内迟到更新允许回补,窗口外生成“更正事件”异步修正历史。
  • 去重用“交易所侧唯一键”(tradeId/matchNumber),每个活跃窗口挂一个 LRU Set 或 BloomFilter,窗口关了再销毁。
  • 对 Cancel/Correct,保留事件日志或增量计数,支持对受影响窗口重算。

3) 无成交时如何“出 K”

  • 这一分钟没人成交,要不要出一根 K?出的话 open/high/low/close 是啥?
  • 国内股票常见规则是用上一成交价填充 O=H=L=C,Volume=0;有的业务要求“不出空 K”。

解决思路

  • 把规则提前固化到配置里,按交易所/品种/周期做矩阵开关。
  • 即使不出空 K,窗口边界也要推动 close 更新(否则下游指标会串)。

4) 价格精度与溢出

  • BigDecimal 慢,double 有误差,long 容易溢出,turnover(成交额)乘合约乘数后尤甚。
  • 汇率转换会引入更多精度与溢出风险。

解决思路

  • 价格、数量都用“整数化”的 long 存(如价格按最小价位或 1e4 缩放),只在边界 I/O 处做格式化。
  • 成交额用 128 位(Java 里 BigDecimal,但复用对象与避免频繁创建),或分币种分桶存 long 后离线汇总。
  • 提前加载 instrument 元数据:tickSizepriceScalelotmultipliercurrency

5) 聚合策略与层级

  • 从 tick 直接聚到所有周期 vs 先聚 1 分钟再二次聚合到 5 分钟/15 分钟?
  • 直接从 tick 聚到所有周期,CPU 压力大;二次聚合可能引入边界误差(跨窗口的高低点)。

解决思路

  • 统一以“最小基础周期”(一般 1S 或 1M)做底座,其他周期用基础周期二次聚合,且二次聚合以“max(high)min(low)、开=首根 open、收=末根 close、量/额累加”的严格规则,避免误差。
  • 仅对需要的周期开启实时聚合,其他由查询侧即时拼装。

6) 修正与复权

  • 日线以上要处理分红、配股、拆合股,对历史 K 进行前/后复权。
  • 期货连续合约、主力切换,怎么拼接不会断层。

解决思路

  • 复权系数维护为“日期->factor”的时间序列,原始 K 只存不复权,查询时按前/后复权在线转换:adjPrice = raw * factor(t) / factor(now)
  • 连续合约策略分主力/指数/近月滚动,边界日给出映射表,生成“连续映射事件”,驱动重算或查询期融合。

7) 性能与 GC

  • 高频市场每秒几十万条 tick,分钟边界瞬时抖动明显。
  • 大量对象创建会引发 GC 抖动,延迟尾部很难看。

解决思路

  • 单 symbol 单线程聚合,事件循环用 Disruptor 或 Chronicle Queue/RingBuffer,减少锁。
  • 对象池化,Candle、事件包装重用;primitive 集合(fastutil/HPPC),尽量零装箱。
  • 延迟敏感路径避免 BigDecimal 运算,批量落盘,内存对齐。
  • 分时“削峰”:分钟收口延后几百毫秒出 K(业务可接受范围内),换吞吐量。

8) 存储与接口

  • 历史查询高 QPS,实时订阅低延迟,冷热数据分层。
  • 一致性:用户拉历史与订阅实时,不能看到“撕裂”的最后一根。

解决思路

  • 实时内存状态 + 周期性落盘历史库(如 ClickHouse/QuestDB/TimescaleDB/Parquet)。
  • 历史 REST 拉取采用“快照点”概念,快照后的增量通过 WebSocket 推送,给最后一根带版本号或校验码。
  • 分区按交易日/标的,压缩列式存储,支持前缀查询。

一个精简版的聚合器结构(删繁就简)

数据模型long 代表已整数化的价格与量):

  • CandleKey: symbol, interval, sessionId, windowStart
  • CandleState: open, high, low, close, volume, turnover, tradeCount, version

核心流程

  • 事件进入(按 symbol 分区、单线程消费)
  • 根据交易日历找到它属于哪个窗口
  • 如窗口不存在则创建并初始化 open/high/low/close
  • 应用成交事件更新高低收、量额
  • 检查窗口是否到期,到期则封口输出并落盘;同时滚动到下一窗口
  • 迟到事件:若还在“可回补期”,直接更新状态并广播修正;否则记录更正任务

非常小的一段 Java 伪代码(仅示意,真实实现要更多边界判断)

class Candle {

long open = Long.MIN\_VALUE;

long high = Long.MIN\_VALUE;

long low = Long.MAX\_VALUE;

long close = Long.MIN\_VALUE;

long volume;

long turnover;

int trades;

void applyTrade(long px, long qty) {
if (open == Long.MIN_VALUE) open = px;
if (px > high) high = px;
if (px < low) low = px;
close = px;
volume += qty;
turnover += px * qty; // 注意溢出与缩放
trades++;
}

boolean isEmpty() {
return open == Long.MIN_VALUE;
}

}

class Aggregator {

final Map map = new HashMap<>();

void onTrade(Trade t) {
CandleKey key = calendar.locateWindow(t.symbol, t.exchangeTime, t.session);
Candle c = map.computeIfAbsent(key, k -> new Candle());
if (t.isCancelOrCorrect()) {
// 查找原事件并回滚/重算(需要事件日志)
return;
}
c.applyTrade(t.price, t.qty);
}

void onTickBoundary(Instant now) {
// 找到到期的窗口,封口输出,落盘并清理
}
}

实现时别忘了这些“坑口提示”

  • 集合竞价:开盘/收盘集合竞价形成的价量要算进对应窗口,尤其是收盘价定义要跟业务确认(最后成交价 vs 收盘价)。
  • 交易所回补:链路断开后的回补数据顺序可能与实时不同,回放时要沿用同一聚合逻辑,且要可幂等。
  • 标的元数据变更:停复牌、最小变动价位与合约乘数变化(再遇见就是真实世界),要有生效时间点。
  • 跨市场合并:港股/美股币种不同,turnover 汇总别悄悄相加。
  • 压力测试:用历史 tick 回放到 Kafka,按真实峰值放大 2 倍,观察分钟边界延迟尾部和丢包率。
  • 监控:窗口实时数量、乱序比率、迟到修正次数、分钟边界 99.9 延迟、落盘滞后、GC 暂停、每标的事件速率。

我会怎么落地

  • 入口:Kafka/NATS/ChronicleQueue,按 symbol 分区,消费端单线程聚合。
  • 时间:交易日历服务(内置规则 + 可热更新),所有时间用交易所时区。
  • 聚合:1 秒或 1 分钟作为基础周期,其他周期二次聚合。迟到容忍窗口 3 秒,超时转“修正任务队列”。
  • 去重:每窗口维护 LRU tradeId 集合;全局 Bloom 限制内存。
  • 存储:实时 Redis/内存快照,分钟/日线落 ClickHouse;写前批量,按 symbol+date 分区;历史修正以 upsert。
  • 对外:REST 历史 + WebSocket 订阅;订阅流包含“完整 K”“修正 K”两类事件;最后一根携带 version。
  • 性能:Disruptor 事件环,预分配对象;fastutil LongOpenHashSet 做去重;少用 BigDecimal,必要处汇总线程集中转换。
  • 测试:重放历史包,属性测试校验 O/H/L/C 不变量,边界日(节假日、夏令时、夜盘)专项用例。

写在最后

K 线是“低级需求,高级实现”。从产品视角它只是几根柱子,从工程视角它是时间、数据质量、并发与业务规则的交叉地带。真正难的不是把 open/high/low/close 算出来,而是:

  • 任意时刻说得清“为什么是这个值”
  • 在异常和修正下仍然可追溯、可重放、可幂等
  • 在高峰时段稳稳地按时吐出每一根

如果你正准备做这件事,先把时区、交易日历、事件顺序、去重与修正理顺,再谈性能优化;这样第二天早上看监控的时候,心里会更踏实。

posted @ 2025-12-02 16:06  十月南城  阅读(0)  评论(0)    收藏  举报