先把話挑明: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 算出來,而是:
- 任意時刻説得清“為什麼是這個值”
- 在異常和修正下仍然可追溯、可重放、可冪等
- 在高峯時段穩穩地按時吐出每一根
如果你正準備做這件事,先把時區、交易日曆、事件順序、去重與修正理順,再談性能優化;這樣第二天早上看監控的時候,心裏會更踏實。