BULLINV DATA
API down

SYSTEM ATLAS · BULLINV-QUANT-DATA

一筆 tick 的完整旅程

一筆台指期成交如何變成圖上的一根 K 棒:一條 Shioaji 連線餵進 Kafka、writer 把串流變成 QuestDB 的列、backfill loop 守住歷史完整性、一支 API 統一往外供。這一頁是完整的系統描述——線路、schema、以及背後的每個設計決策。

1 條 shioaji 連線4+1 個服務5 個 kafka topics8 張 questdb 表2 個 symbols · TXFR1 TXFR2

LIVE MAP

資料管線

滑過任一節點可以追它的接線;點擊直接跳到 schema。即時 tick 走 Kafka;歷史修補直寫 QuestDB——兩條路在 bars_1m 會合,由 DEDUP UPSERT 讓它們收斂一致。

ticks + 五檔1 分 K歷史修補ops / 控制面API 讀取

跟著一筆 TICK

同一筆成交,在每一站的樣子

2026-07-02 10:31:07.123,台指期 7 月合約在 23,145 成交 3 口。這筆事件在每一站長什麼樣子——欄位、時間戳,一個不少。

01 · 10:31:07.123 · 在交易所

Shioaji tick callback

quote v1 · acct 2code = real month contract
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 事件

topic market.ticks.v1key shioaji:TXFR1acks=all
{
  "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 列

ILP over HTTPexplicit buffer · auto_flush=off
# 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 回應

GET /v1/bars/TXFR1?timeframe=1mX-API-Key
{
  "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 的連續代碼,不是真合約。近月一路跑到結算日(當月第三個週三);結算日的夜盤就已經在交易新的近月。接續不做價格調整——每次換月都有跳空。

reverse map before subscribe

ingestor 用連續代碼訂閱,但 callback 的 tick.code 是實際月合約(TXF202607)。code→symbol 反查表必須在掛 callback 之前整份建好:等 contracts ready → resolve → 建表 → 掛 callback → 訂閱。順序錯了,搶先抵達的 tick 會以原始月代碼入庫,永久汙染下游。

map contents

反查表同時收 R1 別名自身的 code 和所有同 delivery_month 的實際合約 code(掃同類別群組找出來)——實測兩種都會出現在 callback 裡。

refresh on reconnect only

反查表只在每次(重)登入時整份替換——不逐鍵改、盤中不刷新。換月後要等下一次重連才更新。對不上的 code 會以原始代碼入庫並警告一次:這是要處理的資料分裂,不是可以忽略的雜訊。

identity is preserved

每筆 TickEvent 都帶 contract = 實際月合約,一路寫進 ticks.contract 與 bars_1m.contract(bar 取 bucket 內最後一筆 tick 的 contract——換月當下可稽核)。kbars 回補的列 contract=NULL,真實身分靠 join contract_map 補回。

contract_map: live beats rule

一交易日一列。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。

late ticks → revision

bar finalize 後 120 秒內的遲到 tick 會重新計算:bar 修正後以 revision+1 重發(QuestDB DEDUP 冪等覆蓋;Kafka 下游留最高 revision)。超過窗口就記進 dropped_late 計數,交給 kbars 修補。

buy/sell split

buy_volume 累計 tick_type=1(外盤)、sell_volume 累計 tick_type=2(內盤);tick_type=0 兩邊都不算。

vol_check_delta

每根 bar 的交叉核對:用單調遞增的 total_volume 差推出的期望量 vs 實際聚合量。0 = 完整;非 0 = 漏了 tick(品質旗標,不是錯誤)。跨盤 total_volume 歸零時自動跳過。

simtrade never aggregates

試撮永遠不進 bar。要不要存進 ticks 表由 TICKS_STORE_SIMTRADE 決定(預設存);API 預設濾掉,除非帶 include_simtrade=true。

restart invariant

crash 後從最早仍開著的 bucket 的第一個 offset 重播。last-finalized watermark 擋住已 finalize bar 的重播尾巴、不讓它重建成殘缺 bar;關機也不強制 finalize——資料庫裡的殘缺 bar 比缺一根更糟,replay 會重建。

覆蓋率與缺口修復

三層防線:heartbeat 缺口事件即時抓斷線、排程缺口掃描撿漏、逐交易日的 kbars verify 對 vendor 全帳覆核。

expected bars

日盤期望 300 根 open-stamp 分鐘(08:45–13:44)、夜盤 840 根(15:00–04:59)。13:45 / 05:00 的收盤集合競價 bar「可有可無」:有就算覆蓋、沒有不算缺口。coverage_pct 上限鎖 100,因為競價 bar 會讓 actual 超過 expected。

trading calendar

交易日 = 平日扣掉手動維護的休市表(2024H2–2026,含颱風假;可用 TAIFEX_CLOSED_DAYS env 增補)。夜盤歸開盤那天:05:01 之前的時間戳屬於前一個交易日。

gap scanner

每 300 秒:期望分鐘 − 實存分鐘 → 相鄰缺漏合併成段 → 段長 ≥ GAP_MIN_MINUTES=2 才立案(夜盤單一分鐘沒成交是常態,不是缺口)。掃描只看 now−3 分鐘之前完整結束的分鐘,留出 grace + flush 的緩衝。

lifecycle

detected → kbars 修補只寫缺漏的分鐘 → filled。如果 kbars 在那些分鐘也沒資料、但該盤別其他時間有資料,就以 note=tradeless_confirmed_by_kbars 直接結案為 filled。整個盤別全空則標 unfillable——每 6 小時重試一次,以防 vendor 只是晚到。

verify: audit vs the 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,中間任何一層都不信任本地時鐘。

two stamps per event

每個 Kafka 事件帶兩個時間:ts(交易所時間,ISO +08:00,給人看)與 ts_ns(權威 UTC epoch 奈秒——下游一律以它為準)。

one session definition

盤別窗是「當日第幾分鐘」的集合:日盤 [08:45, 13:46)、夜盤 [15:00, 05:01)——多出的那一分鐘容納可有可無的收盤競價。Python 與 SQL 的 minute-of-day 運算共用同一份定義,過濾條件不可能漂移。

1d bars respect the trading day

高 timeframe 用 SAMPLE BY … ALIGN TO CALENDAR TIME ZONE 'Asia/Taipei' 現算;1d 再加 WITH OFFSET '05:01',讓夜盤 05:00 的收盤 bar 落回自己的交易日——與 trading_date_of 的 05:01 切點完全一致。

kbars are END-stamped

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。所有重試、重播、修補路徑都壓在這一個性質上。

the order of operations

每個 poll 批次:寫入 ILP buffer → flush(唯一的 durability barrier)→ 發 BarEvent(盡力而為)→ commit Kafka offset。任何一點 crash 都會重送;DEDUP 吸收重播。

CommitGate

每個 partition 可提交的 offset = 所有仍開著的 bar bucket 中最早的起始 offset。試撮、DLQ、遲到修正的 tick 都不把關。重啟時就從最舊未完成 bar 的開頭精準重播。

explicit ILP buffer

auto_flush=off + 顯式 Buffer,flush 用 clear=False、成功才手動 clear。官方 client 失敗時會先清掉 internal buffer 再丟例外——若用 auto-flush,重試等於 flush 空 buffer、假成功,資料就蒸發了。

flush failure = pause, not crash

flush 失敗時 writer 暫停消費、以 1→30 秒退避重試,靠對暫停的 partition poll(0) 保住 group membership;心跳在最後一次失敗後 5 分鐘內標 degraded。rebalance 被收走 partition 時丟掉開著的 bar 並重置 gate——由 replay 重建,避免雙倍量的 bar。

repair writes only what's missing

DEDUP 是整列覆蓋,而 kbars 沒有 vwap/tick_count/buy/sell——盲目覆寫會把即時聚合的加值欄位洗成 NULL。所以缺口修補與 catch-up 只寫真正缺的分鐘(only_minutes / after_ts);全量覆蓋只留給明示的 seed/repair,並在 backfill_runs.requested_by 標注 [full-overwrite]。

dead letters don't block

驗證失敗的訊息帶著 reason + 原始 bytes 進 market.dlq.v1,offset 立即可提交——一則壞訊息永遠不會卡死 partition。DLQ 告警每分鐘最多一次。

帳號分工、額度與背壓

兩個 Shioaji 帳號嚴格分工:帳號 2 撐唯一一條常駐串流連線、帳號 1 專跑歷史 kbars。串流與回補永遠不會搶同一條連線或同一份流量額度(券商同一人約 5 條連線上限,還要留給下單系統)。

quota guard

ingestor 每小時查 api.usage() 並上報 ops topic;backfill 在剩餘額度 < USAGE_MIN_REMAINING_PCT=20% 時中止分段任務,並印出精確的續跑指令——續跑無害,因為每筆寫入都 DEDUP 冪等。

bounded queue, no disk spool

ingestor 唯一的背壓點是 20 萬筆的有界佇列。溢出就丟、把窗口記成 gap——刻意不做磁碟 spool(Zeabur 磁碟是 ephemeral),反正 kbars 修補才是權威恢復路徑。五檔沒有回補來源:丟了就永遠沒了,這是接受的取捨。

clean shutdown

關機時先停訂閱、再以 10 秒 deadline 汲取佇列,逐筆 try/except——一筆送失敗不會拖累其他;所有損失都記進 gap 窗口。

startup resilience

建表、聚合器 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 事件——除此之外什麼都不做。

Shioaji quote (acct 2)Kafka (producer)
  • 行情執行緒 → 有界佇列(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.main

Kafka → QuestDB。落地原始 tick 與五檔、聚合 1 分 K,並把 finalize 後的 bar 重新發回 Kafka。

Kafka (group quant-writer + producer)QuestDB (ILP/HTTP)
  • 消費 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 的旁路。

Shioaji kbars REST (acct 1)QuestDB (ILP/HTTP)
  • 常駐 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 讀取層——平台唯一對外的門面,底下全是私有管線。

QuestDB (SQL /exec)consumers via HTTP :8080
  • 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,瀏覽器永遠拿不到。

quant-data-api via /api/data/* proxy
  • 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。

inquant-ingestoroutquant-writer
schema / event_typestringmarket.tick.v1 / market.tick
sourcestringshioaji
symbolstringcontinuous code, e.g. TXFR1
exchangestringTAIFEX
contractstring?real month contract, e.g. TXF202607
tsISO +08:00exchange time
ts_nsint64authoritative UTC epoch ns
recv_tsISO Zingestor receive time
payload.closedecimal-str
payload.volume / total_volumeint
payload.tick_typeint1 outer · 2 inner · 0 n/a
payload.bid/ask_side_total_volint?
payload.open/high/low/avg_pricedecimal-str?
payload.amount / total_amountdecimal-str?
payload.price_chg / pct_chg / chg_typemixed?
payload.underlying_pricedecimal-str?
payload.simtradeboolpre-open simulated matches

market.bidask.v1

key {source}:{symbol}

五檔快照(L1–L5)。五個檔位的長度在解析層就鎖死(min/max 5)——壞訊息會進 DLQ,而不是在寫入層炸出 IndexError。

inquant-ingestoroutquant-writer
schema / event_typestringmarket.bidask.v1 / market.bidask
source / symbol / exchangestring
contractstring?real month contract
ts / ts_ns / recv_tsmixedsame convention as ticks
payload.bid_prices[5]decimal-str[]L1 → L5
payload.bid_volumes[5]int[]
payload.ask_prices[5]decimal-str[]
payload.ask_volumes[5]int[]
payload.bid/ask_total_volint
payload.underlying_pricedecimal-str?
payload.simtradebool

market.bars.1m.v1

3 partitionskey {source}:{symbol}

聚合完成後重新發布的 1 分 K。遲到 tick 會讓同一根 bucket 以 revision+1 重發——下游只留最高 revision。

inquant-writerout(future consumers — strategy engines, alerting)
schema / event_typestringmarket.bar.1m.v1 / market.bar.1m
symbol / sourcestring
tsISO +08:00bucket OPEN-stamp
ts_nsint64
open / high / low / closedecimal-str
volumeint
buy_volume / sell_volumeintfrom tick_type
amount / vwapdecimal-str?
tick_countint
vol_check_deltaint0 = total_volume cross-check passed
bar_sourceenumrt · backfill · seed
revisionint0 = first finalize

market.ops.v1

1 partitionkey {service}

控制面:心跳、掉線報告、Shioaji 額度用量。由 writer 轉寫成 ingest_heartbeats 與 data_gaps 的列。

inquant-ingestor (+ any service)outquant-writer → QuestDB ops tables
schemastringmarket.ops.v1
event_typeenumops.heartbeat · ops.gap · ops.usage
servicestringingestor · writer · backfill · data-api
tsISO
payloadobjectevent-specific detail

market.dlq.v1

1 partitionkey

死信箱。writer 解析不了的訊息帶著原始 bytes 進來,不會卡住 partition。告警 60 秒節流一次。

inquant-writeroutmanual inspection
event_typestringmarket.dlq
reasonstring
rawstringoriginal message payload

STORAGE

QuestDB:八張表、全 UTC

寫入走 ILP/HTTP、顯式 buffer、auto_flush 關閉——durability 的時點完全由 writer 控制。ticks 放 180 天、五檔 90 天;bars_1m 永久保存、也是唯一儲存的 timeframe。台北時間只存在於 API 邊界。

ticks

PARTITION BY DAYTTL 180 daysWAL

DEDUP UPSERT KEYS(ts, symbol, total_volume, price, volume, tick_type)

原始成交,一筆 tick 一列。dedup 鍵含單調遞增的 total_volume:同一微秒的兩筆真成交能區分,Kafka 重播的同一訊息則乾淨去重。日分區 180 天過期——永久紀錄是 bars_1m。

writequant-writer (ILP)readGET /v1/ticks · /v1/live
tsTIMESTAMPdesignated · UTC exchange time
recv_tsTIMESTAMP
symbolSYMBOL(256)
contractSYMBOL(1024)TXF202607
sourceSYMBOL(16)
priceDOUBLE
volumeLONG
total_volumeLONG
tick_typeBYTE
chg_typeBYTE
bid_side_total_volLONG
ask_side_total_volLONG
avg_priceDOUBLE
amountDOUBLE
total_amountDOUBLE
openDOUBLE
highDOUBLE
lowDOUBLE
underlying_priceDOUBLE
price_chgDOUBLE
pct_chgDOUBLE
simtradeBOOLEAN

bidask

PARTITION BY DAYTTL 90 daysWAL

DEDUP UPSERT KEYS(ts, symbol, bid_p1, ask_p1, bid_v1, ask_v1)

五檔快照。dedup 鍵含 L1 價量——同一微秒的相異快照幾乎必在 L1 有差。TTL 只有 90 天:五檔沒有任何歷史回補來源,放久沒有意義。

writequant-writer (ILP)readGET /v1/bidask
tsTIMESTAMPdesignated
recv_tsTIMESTAMP
symbolSYMBOL(256)
contractSYMBOL(1024)
sourceSYMBOL(16)
bid_p1 … bid_p5DOUBLE ×5
bid_v1 … bid_v5LONG ×5
ask_p1 … ask_p5DOUBLE ×5
ask_v1 … ask_v5LONG ×5
bid_total_vol / ask_total_volLONG
underlying_priceDOUBLE
simtradeBOOLEAN

bars_1m

PARTITION BY MONTHWAL

DEDUP UPSERT KEYS(ts, symbol)

永久紀錄——只存 1m,5m…1d 都在查詢時 SAMPLE BY 現算。(ts, symbol) 的 DEDUP 讓即時聚合與 kbars 回補互相冪等收斂,而不是打架。

writequant-writer (rt) + quant-backfill (backfill/seed)readGET /v1/bars · /v1/symbols · /v1/coverage
tsTIMESTAMPdesignated · bucket OPEN-stamp, UTC
symbolSYMBOL(256)
contractSYMBOL(1024)last tick's contract · NULL from kbars
sourceSYMBOL(16)
openDOUBLE
highDOUBLE
lowDOUBLE
closeDOUBLE
volumeLONG
amountDOUBLE
vwapDOUBLErt only — NULL from kbars
tick_countINTrt only
buy_volumeLONGrt only
sell_volumeLONGrt only
vol_check_deltaLONG
srcSYMBOL(8)rt · backfill · seed
ingested_atTIMESTAMP

contract_map

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, symbol)

換月對照表:一交易日一列,連續代碼 → 實際月合約。recorded_by='live'(backfill loop 每輪從 Shioaji Contracts 讀)是權威;'rule' 是 rollcal 子命令按第三個週三結算規則回推的歷史。

writequant-backfill (live) + rollcal (rule)readjoins recovering real contract identity
tsTIMESTAMPdesignated · trading day 00:00 Taipei → UTC
symbolSYMBOL(256)TXFR1 / TXFR2
contractSYMBOL(1024)short code, e.g. TXFG6
delivery_monthSYMBOL(512)202607
recorded_bySYMBOL(8)live (authoritative) · rule
recorded_atTIMESTAMP

coverage_checks

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, symbol)

每 (symbol, 交易日) 一列的覆核紀錄:bars_1m 對 kbars(權威)逐日比對、補寫缺漏後落此列。status='ok' 表示實存 ≥ vendor;'mismatch' 表示補完仍少於 vendor,需要人工看。

writequant-backfill verifyreadGET /v1/coverage (verified badge)
tsTIMESTAMPdesignated · trading day 00:00 Taipei → UTC
symbolSYMBOL(256)
expected_barsINTcalendar full session = 1140
vendor_barsINTshifted kbars count (authority)
stored_barsINTbars_1m count after repair
repaired_rowsINT
statusSYMBOL(8)ok · mismatch
checked_atTIMESTAMP

ingest_heartbeats

PARTITION BY DAYTTL 14 daysWAL

各服務每 30 秒的自我回報。Overview 的健康卡片——還有上面那張圖的小綠點——讀的就是每個服務的最新一列。

writequant-writer (from market.ops.v1)readGET /v1/status
tsTIMESTAMPdesignated · event time
serviceSYMBOL(16)ingestor · writer · backfill · data-api
symbolSYMBOL(256)'USAGE' row carries Shioaji quota
statusSYMBOL(8)ok · degraded · stalled
lag_msLONG
ticks_1mLONG
detailVARCHAR

data_gaps

PARTITION BY MONTHTTL 12 monthsWAL

append-only 的缺口事件流;目前狀態 = LATEST ON ts PARTITION BY gap_id。實際會寫入的生命週期:detected →(kbars 修補後)filled | unfillable(kbars 確認該時段本來就沒成交也直接結案為 filled)。

writequant-writer (heartbeat) + gap scannerreadGET /v1/status · quant-backfill
tsTIMESTAMPdesignated
gap_idSYMBOL(8192){symbol}:{gap_start}
symbolSYMBOL(256)
gap_startTIMESTAMP
gap_endTIMESTAMPexclusive
expected_barsINT
actual_barsINT
statusSYMBOL(8)detected · filled · unfillable
detected_bySYMBOL(8)heartbeat · gap_scanner
noteVARCHARe.g. tradeless_confirmed_by_kbars

backfill_runs

PARTITION BY MONTHTTL 12 monthsWAL

回補工作佇列本人。POST /v1/admin/backfill 落一列 'requested';backfill loop 輪詢撿單、跑 kbars 修補、續寫進度列。卡在 'running' 超過 30 分鐘的 run 會被回收重新認領([reaped])。

writequant-data-api (requested) + quant-backfill (progress)readGET /v1/backfill-runs · quant-backfill loop
tsTIMESTAMPdesignated
run_idSYMBOL(16384)
symbolSYMBOL(256)
range_startTIMESTAMP
range_endTIMESTAMP
statusSYMBOL(8)requested · running · done · failed
rows_writtenLONG
requested_byVARCHAR
errorVARCHAR

生態系

在 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。

ecb-auto-backfill輪詢策略狀態、比對成交signal.push_orderecb-trade-linebotLINE Flex 推播 · 一鍵下單(5 分鐘有效)send_orderbullinv-order-system驗證 OrderMessage → 下單Shioaji → TAIFEX成交

端點與憑證都放在各服務的 env(KAFKA_BOOTSTRAP_SERVERS、QUESTDB_HTTP_URL、SHIOAJI_*)——刻意不印在這頁。請求/回應細節請看 API reference。 API reference →