SYSTEM ATLAS · BULLINV-QUANT-DATA
一筆 tick 的完整旅程
一筆台指期成交如何變成圖上的一根 K 棒:一條 Shioaji 連線餵進 Kafka、writer 把串流變成 QuestDB 的列、backfill loop 守住歷史完整性、一支 API 統一往外供。這一頁是完整的系統描述——線路、schema、以及背後的每個設計決策。
LIVE MAP
資料管線
滑過任一節點可以追它的接線;點擊直接跳到 schema。即時 tick 走 Kafka;歷史修補直寫 QuestDB——兩條路在 bars_1m 會合,由 DEDUP UPSERT 讓它們收斂一致。
跟著一筆 TICK
同一筆成交,在每一站的樣子
2026-07-02 10:31:07.123,台指期 7 月合約在 23,145 成交 3 口。這筆事件在每一站長什麼樣子——欄位、時間戳,一個不少。
01 · 10:31:07.123 · 在交易所
Shioaji tick callback
Exchange.TAIFEX, TickFOPv1(
code="TXF202607",
datetime=2026-07-02 10:31:07.123,
close=Decimal("23145"),
volume=3,
total_volume=48213,
tick_type=1, # outer (lifted ask)
bid_side_total_vol=25133,
ask_side_total_vol=23080,
simtrade=0, ...
)台指期 7 月合約在 23,145 成交 3 口。callback 在 ingestor 的行情執行緒觸發後立刻進佇列,絕不在原地處理。
02 · + 幾毫秒 · 在線路上
Kafka 事件
{
"schema": "market.tick.v1",
"event_type": "market.tick",
"source": "shioaji",
"symbol": "TXFR1",
"exchange": "TAIFEX",
"contract": "TXF202607",
"ts": "2026-07-02T10:31:07.123+08:00",
"ts_ns": 1782959467123000000,
"recv_ts": "2026-07-02T02:31:07.161Z",
"payload": {
"close": "23145", "volume": 3,
"total_volume": 48213, "tick_type": 1,
"bid_side_total_vol": 25133,
"ask_side_total_vol": 23080,
"simtrade": false, ...
}
}月合約代碼映射成連續代碼 TXFR1;價格轉成字串(杜絕浮點漂移);ts_ns 帶權威 UTC 奈秒。同 key → 同 partition → 保住每個 symbol 的順序。
03 · + 約 500 毫秒 · 落地
QuestDB 列
# ticks — one row per print ticks,symbol=TXFR1,contract=TXF202607,source=shioaji price=23145.0,volume=3i,total_volume=48213i, tick_type=1i,simtrade=f 1782959467123000000 # bars_1m — after 10:32:00 + 5s grace ts 2026-07-02T02:31:00Z (open-stamp) o/h/l/c 23142 / 23146 / 23141 / 23145 volume 187 buy/sell 104/83 contract TXF202607 src rt tick_count 96
writer 先 flush ILP buffer、確認落地後才 commit Kafka offset——任何一點 crash 都會重送這筆 tick,DEDUP 吸收重播。
04 · 任何時候 · 被讀取
Data API 回應
{
"code": 200,
"message": "ok",
"data": {
"symbol": "TXFR1",
"timeframe": "1m",
"tz": "Asia/Taipei",
"results": [{
"ts": "2026-07-02T10:31:00+08:00",
"open": 23142, "high": 23146,
"low": 23141, "close": 23145,
"volume": 187, "tick_count": 96,
"buy_volume": 104, "sell_volume": 83, ...
}], ...
}
}在邊界換回台北時間。要 15m 或 1d?API 現場 SAMPLE BY——庫裡永遠只有 1m。
設計筆記
管線背後的設計決策
連續合約怎麼處理、K 棒的語意、覆蓋率怎麼稽核、傳遞保證怎麼做——這些不會出現在 schema 裡、卻決定資料行為的選擇。已知限制也誠實列出。
連續合約
TXFR1/TXFR2 是 Shioaji 的連續代碼,不是真合約。近月一路跑到結算日(當月第三個週三);結算日的夜盤就已經在交易新的近月。接續不做價格調整——每次換月都有跳空。
ingestor 用連續代碼訂閱,但 callback 的 tick.code 是實際月合約(TXF202607)。code→symbol 反查表必須在掛 callback 之前整份建好:等 contracts ready → resolve → 建表 → 掛 callback → 訂閱。順序錯了,搶先抵達的 tick 會以原始月代碼入庫,永久汙染下游。
反查表同時收 R1 別名自身的 code 和所有同 delivery_month 的實際合約 code(掃同類別群組找出來)——實測兩種都會出現在 callback 裡。
反查表只在每次(重)登入時整份替換——不逐鍵改、盤中不刷新。換月後要等下一次重連才更新。對不上的 code 會以原始代碼入庫並警告一次:這是要處理的資料分裂,不是可以忽略的雜訊。
每筆 TickEvent 都帶 contract = 實際月合約,一路寫進 ticks.contract 與 bars_1m.contract(bar 取 bucket 內最後一筆 tick 的 contract——換月當下可稽核)。kbars 回補的列 contract=NULL,真實身分靠 join contract_map 補回。
一交易日一列。backfill loop 每輪用 live 的 Shioaji Contracts 記今天的對應(權威);rollcal 子命令按結算規則回推歷史。recorded_by 欄位區分兩者。
已知限制
- rule 路徑沒有處理第三個週三撞假日時 TAIFEX 的順延——那幾天只有 live 列是準的。
- 結算日夜盤已經換月,但 rule 路徑以「日」為粒度——已知的簡化。
- 接續不調整:日內策略沒問題;跨多次換月的長期回測應該用 contract_map + 實際合約自行做 back-adjust。
K 棒語意
只存 1 分 K、open-stamp、在 writer 記憶體裡聚合。watermark 過了 bucket 收盤 +5 秒 grace 就 finalize——或同一 symbol 出現更晚 bucket 的 tick 時立刻 finalize。
bar finalize 後 120 秒內的遲到 tick 會重新計算:bar 修正後以 revision+1 重發(QuestDB DEDUP 冪等覆蓋;Kafka 下游留最高 revision)。超過窗口就記進 dropped_late 計數,交給 kbars 修補。
buy_volume 累計 tick_type=1(外盤)、sell_volume 累計 tick_type=2(內盤);tick_type=0 兩邊都不算。
每根 bar 的交叉核對:用單調遞增的 total_volume 差推出的期望量 vs 實際聚合量。0 = 完整;非 0 = 漏了 tick(品質旗標,不是錯誤)。跨盤 total_volume 歸零時自動跳過。
試撮永遠不進 bar。要不要存進 ticks 表由 TICKS_STORE_SIMTRADE 決定(預設存);API 預設濾掉,除非帶 include_simtrade=true。
crash 後從最早仍開著的 bucket 的第一個 offset 重播。last-finalized watermark 擋住已 finalize bar 的重播尾巴、不讓它重建成殘缺 bar;關機也不強制 finalize——資料庫裡的殘缺 bar 比缺一根更糟,replay 會重建。
覆蓋率與缺口修復
三層防線:heartbeat 缺口事件即時抓斷線、排程缺口掃描撿漏、逐交易日的 kbars verify 對 vendor 全帳覆核。
日盤期望 300 根 open-stamp 分鐘(08:45–13:44)、夜盤 840 根(15:00–04:59)。13:45 / 05:00 的收盤集合競價 bar「可有可無」:有就算覆蓋、沒有不算缺口。coverage_pct 上限鎖 100,因為競價 bar 會讓 actual 超過 expected。
交易日 = 平日扣掉手動維護的休市表(2024H2–2026,含颱風假;可用 TAIFEX_CLOSED_DAYS env 增補)。夜盤歸開盤那天:05:01 之前的時間戳屬於前一個交易日。
每 300 秒:期望分鐘 − 實存分鐘 → 相鄰缺漏合併成段 → 段長 ≥ GAP_MIN_MINUTES=2 才立案(夜盤單一分鐘沒成交是常態,不是缺口)。掃描只看 now−3 分鐘之前完整結束的分鐘,留出 grace + flush 的緩衝。
detected → kbars 修補只寫缺漏的分鐘 → filled。如果 kbars 在那些分鐘也沒資料、但該盤別其他時間有資料,就以 note=tradeless_confirmed_by_kbars 直接結案為 filled。整個盤別全空則標 unfillable——每 6 小時重試一次,以防 vendor 只是晚到。
quant-backfill verify 逐交易日把 bars_1m 對平移後的 kbars(權威)比對、補寫缺漏,然後落一列 coverage_checks(expected / vendor / stored / repaired、status ok|mismatch)。Coverage 頁的 verified 徽章讀的就是這張表。
已知限制
- data_gaps 定義了 'filling' 狀態,但目前沒有任何程式路徑會寫入——實作的狀態機是 detected → filled | unfillable,/v1/status 的 filling 計數恆為 0。已知的文件與實作落差。
時區與盤別
儲存一律 UTC;台北時間只存在於邊界——tick 進來標 +08:00、API 回應出去 +08:00,中間任何一層都不信任本地時鐘。
每個 Kafka 事件帶兩個時間:ts(交易所時間,ISO +08:00,給人看)與 ts_ns(權威 UTC epoch 奈秒——下游一律以它為準)。
盤別窗是「當日第幾分鐘」的集合:日盤 [08:45, 13:46)、夜盤 [15:00, 05:01)——多出的那一分鐘容納可有可無的收盤競價。Python 與 SQL 的 minute-of-day 運算共用同一份定義,過濾條件不可能漂移。
高 timeframe 用 SAMPLE BY … ALIGN TO CALENDAR TIME ZONE 'Asia/Taipei' 現算;1d 再加 WITH OFFSET '05:01',讓夜盤 05:00 的收盤 bar 落回自己的交易日——與 trading_date_of 的 05:01 切點完全一致。
Shioaji kbars 標記分鐘的結束;管線存 open-stamp,所以回補用 KBAR_TS_SHIFT_MINUTES=-1 平移。calibrate 子命令可逐帳號驗證:日盤第一根在 08:46 = END-stamp(維持 -1),在 08:45 = OPEN-stamp(改 0)。平移後明確轉型成 ns——pandas 3.x 預設 µs,會差一千倍。
傳遞保證與冪等
整條管線是 at-least-once;靠 QuestDB 的 DEDUP UPSERT 收斂成 effectively-once。所有重試、重播、修補路徑都壓在這一個性質上。
每個 poll 批次:寫入 ILP buffer → flush(唯一的 durability barrier)→ 發 BarEvent(盡力而為)→ commit Kafka offset。任何一點 crash 都會重送;DEDUP 吸收重播。
每個 partition 可提交的 offset = 所有仍開著的 bar bucket 中最早的起始 offset。試撮、DLQ、遲到修正的 tick 都不把關。重啟時就從最舊未完成 bar 的開頭精準重播。
auto_flush=off + 顯式 Buffer,flush 用 clear=False、成功才手動 clear。官方 client 失敗時會先清掉 internal buffer 再丟例外——若用 auto-flush,重試等於 flush 空 buffer、假成功,資料就蒸發了。
flush 失敗時 writer 暫停消費、以 1→30 秒退避重試,靠對暫停的 partition poll(0) 保住 group membership;心跳在最後一次失敗後 5 分鐘內標 degraded。rebalance 被收走 partition 時丟掉開著的 bar 並重置 gate——由 replay 重建,避免雙倍量的 bar。
DEDUP 是整列覆蓋,而 kbars 沒有 vwap/tick_count/buy/sell——盲目覆寫會把即時聚合的加值欄位洗成 NULL。所以缺口修補與 catch-up 只寫真正缺的分鐘(only_minutes / after_ts);全量覆蓋只留給明示的 seed/repair,並在 backfill_runs.requested_by 標注 [full-overwrite]。
驗證失敗的訊息帶著 reason + 原始 bytes 進 market.dlq.v1,offset 立即可提交——一則壞訊息永遠不會卡死 partition。DLQ 告警每分鐘最多一次。
帳號分工、額度與背壓
兩個 Shioaji 帳號嚴格分工:帳號 2 撐唯一一條常駐串流連線、帳號 1 專跑歷史 kbars。串流與回補永遠不會搶同一條連線或同一份流量額度(券商同一人約 5 條連線上限,還要留給下單系統)。
ingestor 每小時查 api.usage() 並上報 ops topic;backfill 在剩餘額度 < USAGE_MIN_REMAINING_PCT=20% 時中止分段任務,並印出精確的續跑指令——續跑無害,因為每筆寫入都 DEDUP 冪等。
ingestor 唯一的背壓點是 20 萬筆的有界佇列。溢出就丟、把窗口記成 gap——刻意不做磁碟 spool(Zeabur 磁碟是 ephemeral),反正 kbars 修補才是權威恢復路徑。五檔沒有回補來源:丟了就永遠沒了,這是接受的取捨。
關機時先停訂閱、再以 10 秒 deadline 汲取佇列,逐筆 try/except——一筆送失敗不會拖累其他;所有損失都記進 gap 窗口。
建表、聚合器 watermark 種子、ILP 連線全部用 1→30 秒退避重試,而不是 crash loop;watermark 種子讀 max(ts) 讀兩次、間隔 500ms,容忍 crash 後 QuestDB WAL 的 read-your-write 延遲。
程序
五個部署單元
bullinv-quant-data 出四個 console scripts——各自成為一個 Zeabur service、共用同一份 env。你正在看的這個 console 是第五個。
quant-ingestor
bullinv_quant_data.ingestor.main唯一一條常駐的 Shioaji 行情連線(帳號 2)。把 tick 與五檔 callback 轉成 Kafka 事件——除此之外什麼都不做。
- 行情執行緒 → 有界佇列(INGEST_BUFFER_MAX=200k)→ 批次汲取 → producer,每 INGEST_FLUSH_MS=500ms flush 一次
- acks=all、retries=3;佇列溢出就丟棄事件,並把掉線窗口以 ops.gap 上報(reason=buffer_overflow)
- 斷線由專職 daemon 重連,指數退避上限 60 秒;重連期間的窗口自動上報為 gap
- 心跳與每小時的 Shioaji 流量額度檢查 → market.ops.v1
- 永遠不碰 QuestDB
quant-writer
bullinv_quant_data.writer.mainKafka → QuestDB。落地原始 tick 與五檔、聚合 1 分 K,並把 finalize 後的 bar 重新發回 Kafka。
- 消費 market.ticks.v1 + market.bidask.v1 + market.ops.v1;offset 手動提交,由 CommitGate 把關(QuestDB flush 確認後才提交)
- BarAggregator:open-stamp 分桶,watermark 到點加 BAR_GRACE_SECONDS=5 秒才 finalize
- BAR_CORRECTION_WINDOW_SECONDS=120 秒內的遲到 tick 會修正重發同一根 bar,revision+1
- finalize 的 bar → market.bars.1m.v1;解析失敗的訊息 → market.dlq.v1
- 把 ops 事件轉寫成 ingest_heartbeats / data_gaps 列,用事件時間而非寫入時間(重播的心跳不會偽裝成新鮮的)
quant-backfill
bullinv_quant_data.backfill.main歷史資料的權威。用 Shioaji kbars(帳號 1)修補與灌檔 bars_1m、記錄換月對照表、覆核覆蓋率——一條完全不走 Kafka 的旁路。
- 常駐 loop:catch-up → 每 300 秒缺口掃描 → kbars 修補 → 記錄 contract_map;同時輪詢 backfill_runs 撿 API 發的回補單
- 子命令:loop · catchup · seed · repair · verify · rollcal · calibrate
- kbars 是 END-stamp → 用 KBAR_TS_SHIFT_MINUTES=-1 平移成 open-stamp(用 calibrate 驗證)
- kbars 單次查詢上限 30 天;seed 以 SEED_CHUNK_DAYS=25 分段(每段 +1 天蓋過跨午夜夜盤)
- 寫入 src='backfill'|'seed';DEDUP UPSERT 讓每次執行都冪等
quant-data-api
bullinv_quant_data.api.main架在 QuestDB 上的 FastAPI 讀取層——平台唯一對外的門面,底下全是私有管線。
- X-API-Key 認證,常數時間比對(admin key 是一般 key 的超集);所有回應都包 { code, message, data } envelope
- 1m 直接讀 bars_1m;5m…1d 用 SAMPLE BY … ALIGN TO CALENDAR TIME ZONE 'Asia/Taipei' 現算(1d 加 WITH OFFSET '05:01')
- GET /v1:bars · ticks · bidask · live · symbols · status · coverage · backfill-runs——外加 /health
- symbol 輸入經 _safe_ident 白名單(不合法直接 400,不會變成 QuestDB 錯誤);時間戳用 ts_lit 重建,杜絕字串拼接注入
- POST /v1/admin/backfill 只落一列 'requested' 到 backfill_runs;實際執行由 quant-backfill 撿走
quant-dash
bullinv-quant-dash (Next.js)就是這個 console。BFF proxy 把 API key 留在 server side,瀏覽器永遠拿不到。
- app/api/data/[...path] 是路徑白名單 proxy,不是開放轉發
- Overview / Coverage / Ticks 輪詢 /v1/status、/v1/coverage、/v1/backfill-runs、/v1/ticks
- Coverage 頁可以一鍵觸發 POST /v1/admin/backfill,並顯示回補執行紀錄
- DATA_API_MOCK=1 時整個 console 跑在確定性的 mock fixtures 上
TRANSPORT
Kafka:五個 topics、一份契約
所有訊息都是精簡 JSON(UTF-8、不做 ASCII escape);價格以字串傳輸、躲開浮點漂移;ts_ns 帶權威 UTC 奈秒。key 一律 {source}:{symbol},同一 symbol 的事件永遠同 partition、保序。
market.ticks.v1
6 partitionskey {source}:{symbol}每一筆成交,即時上車。以 symbol 為 key,同一 symbol 永遠落在同一 partition、保住順序——key 長得像 shioaji:TXFR1。
market.bidask.v1
key {source}:{symbol}五檔快照(L1–L5)。五個檔位的長度在解析層就鎖死(min/max 5)——壞訊息會進 DLQ,而不是在寫入層炸出 IndexError。
market.bars.1m.v1
3 partitionskey {source}:{symbol}聚合完成後重新發布的 1 分 K。遲到 tick 會讓同一根 bucket 以 revision+1 重發——下游只留最高 revision。
market.ops.v1
1 partitionkey {service}控制面:心跳、掉線報告、Shioaji 額度用量。由 writer 轉寫成 ingest_heartbeats 與 data_gaps 的列。
market.dlq.v1
1 partitionkey —死信箱。writer 解析不了的訊息帶著原始 bytes 進來,不會卡住 partition。告警 60 秒節流一次。
STORAGE
QuestDB:八張表、全 UTC
寫入走 ILP/HTTP、顯式 buffer、auto_flush 關閉——durability 的時點完全由 writer 控制。ticks 放 180 天、五檔 90 天;bars_1m 永久保存、也是唯一儲存的 timeframe。台北時間只存在於 API 邊界。
ticks
PARTITION BY DAYTTL 180 daysWALDEDUP UPSERT KEYS(ts, symbol, total_volume, price, volume, tick_type)
原始成交,一筆 tick 一列。dedup 鍵含單調遞增的 total_volume:同一微秒的兩筆真成交能區分,Kafka 重播的同一訊息則乾淨去重。日分區 180 天過期——永久紀錄是 bars_1m。
bidask
PARTITION BY DAYTTL 90 daysWALDEDUP UPSERT KEYS(ts, symbol, bid_p1, ask_p1, bid_v1, ask_v1)
五檔快照。dedup 鍵含 L1 價量——同一微秒的相異快照幾乎必在 L1 有差。TTL 只有 90 天:五檔沒有任何歷史回補來源,放久沒有意義。
bars_1m
PARTITION BY MONTHWALDEDUP UPSERT KEYS(ts, symbol)
永久紀錄——只存 1m,5m…1d 都在查詢時 SAMPLE BY 現算。(ts, symbol) 的 DEDUP 讓即時聚合與 kbars 回補互相冪等收斂,而不是打架。
contract_map
PARTITION BY YEARWALDEDUP UPSERT KEYS(ts, symbol)
換月對照表:一交易日一列,連續代碼 → 實際月合約。recorded_by='live'(backfill loop 每輪從 Shioaji Contracts 讀)是權威;'rule' 是 rollcal 子命令按第三個週三結算規則回推的歷史。
coverage_checks
PARTITION BY YEARWALDEDUP UPSERT KEYS(ts, symbol)
每 (symbol, 交易日) 一列的覆核紀錄:bars_1m 對 kbars(權威)逐日比對、補寫缺漏後落此列。status='ok' 表示實存 ≥ vendor;'mismatch' 表示補完仍少於 vendor,需要人工看。
ingest_heartbeats
PARTITION BY DAYTTL 14 daysWAL各服務每 30 秒的自我回報。Overview 的健康卡片——還有上面那張圖的小綠點——讀的就是每個服務的最新一列。
data_gaps
PARTITION BY MONTHTTL 12 monthsWALappend-only 的缺口事件流;目前狀態 = LATEST ON ts PARTITION BY gap_id。實際會寫入的生命週期:detected →(kbars 修補後)filled | unfillable(kbars 確認該時段本來就沒成交也直接結案為 filled)。
backfill_runs
PARTITION BY MONTHTTL 12 monthsWAL回補工作佇列本人。POST /v1/admin/backfill 落一列 'requested';backfill loop 輪詢撿單、跑 kbars 修補、續寫進度列。卡在 'running' 超過 30 分鐘的 run 會被回收重新認領([reaped])。
生態系
在 bullinv 全家桶裡的位置
兩個平面共用同一個 Kafka broker,但 topics 完全分開。本頁記錄的是行情資料平面;執行平面把策略訊號變成真實下單。
行情資料平面 · 本頁
Shioaji → Kafka market.* → QuestDB → data API。下游:這個 console,以及 trade_system——TXF 策略引擎現在改讀 QuestDB 供的 bars,不再直接打 Shioaji kbars(過渡期由 QUESTDB_FALLBACK_SHIOAJI 兜底)。
執行平面 · 獨立管線
策略訊號變成 LINE 推播,一鍵點擊就變成真實的台指期委託。它的狀態自己存 Postgres(state_minutes,逐分鐘指標 + 交易 JSONB),完全不碰 QuestDB 和 market.* topics。
端點與憑證都放在各服務的 env(KAFKA_BOOTSTRAP_SERVERS、QUESTDB_HTTP_URL、SHIOAJI_*)——刻意不印在這頁。請求/回應細節請看 API reference。 API reference →