从一个开发工程师的角度,聊聊“什么是 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 元数据:
tickSize、priceScale、lot、multiplier、currency。
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,windowStartCandleState: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 算出来,而是:
- 任意时刻说得清“为什么是这个值”
- 在异常和修正下仍然可追溯、可重放、可幂等
- 在高峰时段稳稳地按时吐出每一根
如果你正准备做这件事,先把时区、交易日历、事件顺序、去重与修正理顺,再谈性能优化;这样第二天早上看监控的时候,心里会更踏实。
进阶之路,神挡杀神佛挡杀佛,欢迎大家一起加QQ群共同讨论成长,群号:620095084
欢迎搜索关注微信公众号 基础全知道 :JavaBasis ,第一时间阅读最新文章
欢迎搜索关注微信公众号 基础全知道 :JavaBasis ,第一时间阅读最新文章

浙公网安备 33010602011771号