BULLINV DATA
API down

SYSTEM ATLAS · BULLINV-QUANT-DATA

一筆 tick 的完整旅程

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

1 條 shioaji 連線5+2 個服務5 個 kafka topics28 張 questdb 表8.9k 檔基金追蹤 (sitemap)34 檔全球市場標的

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 延遲。

基本面回補的觸發架構

dash 可以一鍵觸發 TWSE 基本面回補,但 API 從不親自抓——抓取很慢(年報可能跑數小時)、而且住在另一個 repo(bullinv-fundamentals)。用一張共享的 QuestDB 表把兩者解耦:API 只排隊,由專職 worker 執行。跟 kbars 的 backfill_runs 佇列同一套路。

enqueue, don't block

POST /v1/fundamentals/jobs 依 kind 驗證參數(年度區間、月數、股號白名單),落一列 'requested' 到 data_jobs,立刻回傳 job_id。request 執行緒完全不碰 TWSE。需要 admin key——跟 POST /v1/admin/backfill 同一道門。

single worker, append-only state

fund-worker 每 JOB_POLL_SEC=5 秒撿最舊一件 'requested',用續寫 'running' 列來認領,跑抓取,最後續寫 done|failed 帶筆數。每個 job 的現況 = LATEST ON ts PARTITION BY job_id。沒有原子 claim,所以只能跑一個實例——多開會重複執行。

four kinds, one queue

snapshot(OpenAPI 最新一季全上市 + 產業對照)、backfill(MOPS 財報逐年,民國)、revenue(月營收逐月)、reports(年報 PDF → MinIO)。每種 kind 帶自己的 params JSON;worker 依 kind 分派,完成時記錄寫入筆數。

the dash side

Fundamentals 頁有四張觸發卡 + 一張即時任務表,有任務在跑時每 4 秒輪詢 GET /v1/fundamentals/jobs(閒置放慢到 20 秒)。POST 走的是跟 kbars 回補按鈕同一條 admin-key BFF proxy——API key 永遠不進瀏覽器。

已知限制

  • 基金(fund)回補不走這條佇列——fund-nav 子系統有自己的觸發(POST /v1/fund/track → fund_backfill_runs)和自己的排程 loop。
  • 一個長時間的 reports job 會卡住單一 worker 直到跑完,排在後面的 snapshot/revenue 得等。可接受——回補不在意延遲。

基金資料面

基金是嫁接在同一個 QuestDB 上的日頻批次世界:兩個供應商(Yahoo 供淨值歷史、基富通供可交易目錄)、兩套互不相通的代碼、一個 fund-nav loop 排程所有事。這裡完全不走 Kafka——量級是每天幾千列,不是每秒幾千筆。

two code spaces, no mapping here

Yahoo 用 Morningstar 式代碼(F0GBR067HA:FO);基富通用數字業務鍵(002008)。平台刻意兩邊都存但不對照——fundrich_id ↔ yahoo_code 的對照是消費端業務邏輯(super-funds-trade 自己維護 fund_mappings 表)。fund_* 與 fundrich_* 兩組表永遠不 join。

Yahoo's sitemap is the enumerator

Yahoo 沒有公開的「全基金清單」API——但 Yahoo 自家的 sitemap(sitemap_funds{0,1}.xml.gz,每日更新)枚舉了所有基金頁:2 個 HTTP 請求換 8,881 檔代碼。每日同步只對 fund_watchlist「新增」(requested_by='sitemap');代碼從 sitemap 消失只記錄、絕不自動 untrack。低於 5,000 檔代表 Yahoo 端有變——寧可中止也不信殘缺清單。

dual-tier scheduling

Yahoo 的禮貌下限是全域 0.6s/請求(超速回 HTTP 999),所以 ~8,900 檔跑一輪要 4–6 小時——一天三輪不可能。分層解法:priority 層(手動/API track)維持一天 3 輪;sitemap 層每天 10:20 掃一輪 bulk。分層就藏在 requested_by 欄位——不動 schema,而 POST /v1/fund/track 重寫 watchlist 列就自然把 bulk 檔升級。

FundRich rounds are all-or-nothing

同一輪掃描的所有列共用同一個 ts(該輪開始時間),讀取端從不取 max(ts)——一律跟著 fundrich_scans 最新 done 列的 catalog_ts。中途掛掉或不完整的輪(抓到數 < 官網宣告 90%)不寫任何 catalog 列、標 failed:讀取端繼續供昨天的完整輪。過時的目錄勝過殘缺的目錄。

exact timestamps or nothing

基富通第一輪正式掃描查回 catalog count = 0:int(dt.timestamp() * 1e9) 在 1e18 量級的 float 誤差可達 ~128ns,截斷後 designated ts 比 client 走精確整數路徑寫入的 TIMESTAMP 欄位低 1 微秒。現在所有「designated ts 必須與存值相等」的寫入(淨值日、交易日、掃描輪)一律走 exact_ts_nanos()——純整數運算。

已知限制

  • bulk 輪會把 fund-nav loop 卡住數小時:fund_backfill_runs 撿單和其他時段都要等它跑完。心跳照常(每 25 檔回報進度),dash 仍看得到活著。
  • 部署順序很重要:分層只存在於 2026-07-13 之後的 fund-nav。對舊版部署跑 sitemap-sync,會讓它把 8,881 檔用一天 3 輪掃(~18 小時)——先部署、再同步。
  • 消費端切換(super-funds-trade 的基金篩選器改吃 /v1/fund/fundrich/catalog、棄用自家 SQLite 掃描)在規格書 Phase 2 §474 有列,尚未執行。

全球市場面(StockQ)

全球指數、商品、匯率跟基金共用同一個批次 loop——從 StockQ 的靜態 HTML 抓取、直寫 QuestDB、由 /v1/market/* 供應。有趣的問題在歷史:StockQ 只給近 ~20 個交易日,所以平台自己把歷史養出來。

history that grows on its own

每輪 detail 掃描把指數的近 20 日表 upsert 進 market_daily(DEDUP ts+symbol)。第一天有 20 日,之後每天多一日;重掃冪等、漏日自癒。商品/匯率沒有 detail 頁,改由每 30 分的 quote 掃描每個台北日落一列。消費端(jsmart 全球市場頁)的走勢圖從 20 日起步,悄悄長成 60 日。

the registry is the contract

market/registry.py 寫死 34 檔與其 StockQ 連結路徑——首頁列一律用 href 對照,絕不用顯示名稱(首頁有兩個「韓國」:KOSPI 與 MSCI 韓國)。加一檔 = registry 加一行;掃描、表、API 全自動跟上,不動 schema。

vendor numbers, stored verbatim

StockQ 每檔指數已算好 5–260 日均線、偏離與期間報酬;平台原樣保存(market_index_stats),不用自己還沒長齊的歷史重算。等 market_daily 累積超過 260 日後可以改為自算——刻意先不做。

same all-or-nothing rounds as FundRich

兩種掃描都在 market_scans 落 running/done/failed;讀取端從最新 done quote 輪的 quote_ts 進場。StockQ 改版會觸發防呆(regex 解析下限、|漲跌%| < 30、命中 ≥90%)讓該輪 failed——昨天的資料繼續供應。

已知限制

  • 快照新鮮度受 30 分鐘 quote 週期限制——這是市場總覽,不是交易行情源。即時路徑仍只有 tick 面(Shioaji/Kafka)。
  • USDTWD 不在 StockQ 首頁匯率表(只有 detail 頁)——v1 刻意先不收。

程序

六個部署單元,外加一個基本面 worker

bullinv-quant-data 出五個 console scripts——各自成為一個 Zeabur service、共用同一份 env。你正在看的這個 console 是第六個。fund-worker 是第七個,來自另一個 repo bullinv-fundamentals,對同一個 QuestDB 執行 dash 觸發的基本面回補。

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
  • 平行跑一份 1 秒聚合器(BAR_1S_ENABLED、grace 2 秒)寫 bars_1s——秒級 timeframe 的唯一來源;成本太高可用 env 關掉
  • 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 撿走

fund-nav

bullinv_quant_data.fund_nav.main

批次資料面:日頻的 Yahoo 淨值 + 基富通全目錄 + StockQ 全球市場掃描(指數/商品/匯率),直寫 QuestDB——完全不走 Kafka。一個常駐 loop 排程所有事。

Yahoo TW fund API (+ SSR fallback)Yahoo sitemap CDN (s.yimg.com)FundRich FRSDataCenterStockQ (www.stockq.org)QuestDB (ILP/HTTP)
  • 雙層 watchlist:priority 層(手動/API track)一天 3 輪(18:10/22:10/08:10);bulk 層(sitemap)每天 10:20 掃一輪——約 8,900 檔 × 0.6s 節流 ≈ 4–6 小時
  • 10:00 sitemap 同步:Yahoo 官方 sitemap_funds{0,1}.xml.gz 枚舉所有基金頁(8,881 檔、只要 2 個請求)。只加不刪、requested_by='sitemap';低於 5,000 檔視為 Yahoo 端異常,整次中止
  • 07:40 基富通全掃:一輪約 240 頁 / 4,800 檔;抓到數低於官網宣告的 90% → 整輪 failed、一列 catalog 都不寫
  • 對 sitemap 層基金 POST /v1/fund/track 會重寫 watchlist 列 → 升級 priority 層;新基金在下一輪 snapshot 內建全歷史回補
  • StockQ 全球市場(MARKET_ENABLED):首頁快照每 30 分一輪(1 個請求、34 檔);指數 detail 掃描(均線/期間報酬/近20日)於 06:30 與 14:30(約 20 頁 × 2s 節流)。regex 解析 + 90% 完整性守門——不引入 HTML parser 依賴
  • 對供應商的禮貌:Yahoo 全域最小間隔 0.6s(超速回 HTTP 999);基富通每頁 0.35s + 壞憑證降級重試;StockQ 每頁 2s
  • 子命令:loop · snapshot --tier · backfill · verify · track/untrack · sitemap-sync · fundrich-scan · market-quote · market-detail

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 上

fund-worker

bullinv_fundamentals.worker

基本面回補的執行者——另一個 repo(bullinv-fundamentals)、寫進同一個 QuestDB。輪詢 data_jobs 佇列,執行 dash 觸發的 TWSE 財報/年報抓取,讓慢工的爬取永遠不會卡到 API。

QuestDB (SQL /exec + ILP)TWSE OpenAPI · MOPS · doc.twseMinIO (年報 PDF)
  • 常駐 loop:每 JOB_POLL_SEC=5 秒撿最舊一列 'requested'(LATEST ON ts PARTITION BY job_id),一次一件
  • 四種 kind:snapshot · backfill(MOPS 財報,民國年)· revenue(月營收逐月)· reports(年報 PDF → MinIO)
  • 狀態機 requested → running → done|failed,續寫回 data_jobs 讓 dash 輪詢進度與筆數
  • 只能跑「單一」實例——佇列無原子 claim,多開會重複執行同一 job
  • 年報下載:指數退避 + 抖動 + 冷卻(doc.twse 很容易擋);MinIO object_exists 提供續傳

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

bars_1s

PARTITION BY DAYTTL 90 daysWAL

DEDUP UPSERT KEYS(ts, symbol)

1 秒 K,與 1 分鐘路徑平行聚合(獨立的 BarAggregator 實例、grace 2 秒)。秒級 timeframe 的唯一來源——15s/30s 對本表 SAMPLE BY 現算,1m 不可能反推回秒級。沒有 vendor 回補來源(Shioaji kbars 最細只到 1 分鐘),所以沒有 coverage/修補機制:TTL 90 天、日分區,比照 ticks。

writequant-writer (rt aggregation only)readGET /v1/bars (1s · 15s · 30s)
tsTIMESTAMPdesignated · bucket OPEN-stamp, UTC
symbolSYMBOL(256)
contractSYMBOL(1024)
sourceSYMBOL(16)
open / high / low / closeDOUBLE ×4
volume · amount · vwap · tick_countLONG · DOUBLE ×2 · INT
buy_volume / sell_volumeLONG
srcSYMBOL(8)rt only — no 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

api_requests

PARTITION BY DAYTTL 30 daysWAL

API request log,一請求一列——append-only、無 DEDUP(每筆請求都是獨立事件)。api_key 存的是身分「標籤」(具名 key / admin-N / invalid / anonymous),絕不存原始 secret;query string 截斷 512 字。Usage 頁的資料源。

writequant-data-api (request-log middleware)readGET /v1/usage/summary · /v1/usage/requests
tsTIMESTAMPdesignated · request start, UTC
methodSYMBOL(8)
routeSYMBOL(128)FastAPI template · NULL = no match (404)
path / queryVARCHARquery 截 512 字
api_keySYMBOL(64)label only, never the secret
status_codeINT
duration_msDOUBLE
client_ipSYMBOL(1024)

data_jobs

PARTITION BY MONTHTTL 12 monthsWAL

基本面回補的工作佇列本人。POST /v1/fundamentals/jobs 落一列 'requested';fund-worker(另一個 repo)輪詢撿單、跑抓取、續寫 running → done|failed。append-only 事件流——目前狀態 = LATEST ON ts PARTITION BY job_id。對齊 backfill_runs,只是驅動 TWSE 財報/年報抓取而非 kbars。

writequant-data-api (requested) + fund-worker (progress)readGET /v1/fundamentals/jobs · fund-worker loop
tsTIMESTAMPdesignated · state-change time, UTC
job_idSYMBOL(16384)
kindSYMBOL(16)snapshot · backfill · revenue · reports
statusSYMBOL(8)requested · running · done · failed
workerSYMBOL(32)which worker claimed it
paramsVARCHARJSON: year range / months / symbols…
rows_writtenLONGon done
requested_byVARCHARnamed API-key label
detailVARCHARprogress / summary
errorVARCHARon failed

service_settings

PARTITION BY YEARWAL

服務設定覆寫的事件流。dash 排程編輯每次修改落一列;fund-nav 每輪 loop(≤60s)重讀生效值——改排程不必重啟。本表永遠只是 env/Settings 預設之上的「覆寫」:讀不到或值非法一律退回預設,空字串 = 清除覆寫。現況 = LATEST ON ts PARTITION BY key。

writequant-data-api (POST /v1/schedule)readGET /v1/schedule · fund-nav loop(每輪重讀)
tsTIMESTAMPdesignated · change time, UTC
keySYMBOL(64)白名單見 schedule.py SCHEDULE_KEYS
valueVARCHAR'' = 清除覆寫
updated_byVARCHARnamed API-key label

fin_income

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, company_id, published_at)

季損益表,point-in-time。ts = 財報期末;published_at = 可知日。dedup 鍵「含」published_at——重編列成第二個版本而非覆蓋,API 帶 as_of 就能重建某日當下已知的樣貌(防前視偏誤的關鍵)。金額為 TWSE 原始揭露的仟元。

writefund-worker (OpenAPI snapshot + MOPS backfill)readGET /v1/fundamentals/income · /metrics
tsTIMESTAMPdesignated · fiscal period end
published_atTIMESTAMPPIT knowability
company_id / company_nameSYMBOL(8192)
industrySYMBOL(64)
sourceSYMBOL(8)openapi · mops_hist
year / quarterINT
revenue · gross_profit · operating_incomeDOUBLE ×3累計制(仟元)
pretax_income · net_income · comprehensive_income · epsDOUBLE ×4
raw / ingested_atVARCHAR / TIMESTAMP

fin_balance

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, company_id, published_at)

季資產負債表,PIT 版本語意與 fin_income 完全相同(dedup 含 published_at、支援 as_of 重建)。金額仟元;book_value_ps 為元。

writefund-workerreadGET /v1/fundamentals/balance · /metrics
ts / published_atTIMESTAMPperiod end / knowability
company_id / company_name / industry / sourceSYMBOL
year / quarterINT
total_assets · current_assets · noncurrent_assetsDOUBLE ×3
total_liabilities · current_liabilities · noncurrent_liabilitiesDOUBLE ×3
equity · share_capital · retained_earnings · book_value_psDOUBLE ×4
raw / ingested_atVARCHAR / TIMESTAMP

fin_revenue

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, company_id, published_at)

每月營收揭露。ts = 月末、published_at = 出表日——同一套 PIT 語意。同時帶單月數(MoM/YoY)與年累計對。

writefund-worker (monthly revenue crawl)readGET /v1/fundamentals/revenue · /metrics
ts / published_atTIMESTAMPmonth end / filing date
company_id / company_name / industry / sourceSYMBOL
year / monthINT
revenue_month · revenue_last_month · revenue_yoy_monthDOUBLE ×3仟元
mom_pct / yoy_pctDOUBLE
revenue_cum · revenue_cum_last · cum_yoy_pctDOUBLE ×3
raw / ingested_atVARCHAR / TIMESTAMP

company_info

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, company_id)

上市公司目錄快照;現況 = LATEST ON ts PARTITION BY company_id。raw 欄保留 TWSE 完整揭露列(董事長、資本額、簽證會計師⋯),/v1/fundamentals/profile 就是吃這個。

writefund-worker snapshotreadGET /v1/fundamentals/companies · /profile
tsTIMESTAMPdesignated · snapshot time
company_id / company_name / short_nameSYMBOL(8192)
industrySYMBOL(64)industry code
address / tax_id / rawVARCHAR
ingested_atTIMESTAMP

industry_map

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, code)

產業代碼 → 名稱對照,由 company_info(代碼)× fin_revenue(名稱)依 company_id 推導——TWSE 兩邊是分開揭露的。

writefund-worker (derived)readGET /v1/fundamentals/industries · /companies
tsTIMESTAMPdesignated
code / nameSYMBOL(128)
marketSYMBOL(8)
company_countINT
ingested_atTIMESTAMP

annual_reports

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, company_id, filename)

年報 PDF 索引。檔案本體在 MinIO——這張表存 object_key + sha256(絕不存本機路徑);API 需要時現簽 1 小時效期的 presigned 下載連結。status 逐檔記錄 downloaded | failed。

writefund-worker (reports jobs)readGET /v1/reports/{symbol} · /fundamentals/reports-coverage
tsTIMESTAMPdesignated
company_idSYMBOL(8192)
yearINT
doc_typeSYMBOL(16)
filenameSYMBOL(131072)
bucket / object_key / sha256SYMBOL · VARCHAR ×2
size_bytes / content_typeLONG / SYMBOL(8)
source_urlVARCHAR
statusSYMBOL(8)downloaded · failed
downloaded_atTIMESTAMP

fund_nav

PARTITION BY YEARWAL

DEDUP UPSERT KEYS(ts, fund_id)

每檔基金的每日淨值,ts = 淨值日 00:00 UTC。(ts, fund_id) 的 DEDUP 讓 snapshot 重跑、回補、verify 修復全部冪等收斂。漲跌額不入庫——API 讀取時用相鄰列現算(含分頁 start 前一列當基準)。

writefund-nav (snapshot / backfill / verify repair)readGET /v1/fund/nav · /v1/fund/status
tsTIMESTAMPdesignated · NAV date 00:00 UTC
fund_idSYMBOL(8192)Yahoo code, e.g. F0GBR067HA:FO
navDOUBLE
change_pctDOUBLEYahoo-native value
srcSYMBOL(8)yahoo_api · yahoo_ssr
ingested_atTIMESTAMP

fund_profile

PARTITION BY YEARWAL

append-only 的基本資料快照;現況 = LATEST ON ts PARTITION BY fund_id。currency 保留 Yahoo 中文原文(消費端靠它運作)、正規化 ISO 另存一欄——凍結合約的刻意設計,勿「整理」。

writefund-nav snapshotreadGET /v1/fund/profile · /v1/fund/catalog
tsTIMESTAMPdesignated · snapshot time
fund_idSYMBOL(8192)
nameVARCHAR
currency / currency_rawSYMBOL(32) / VARCHARISO · Yahoo 中文原文
category · inception · rating · family · isinVARCHAR ×5
mgmt_fee · min_purchase · websiteVARCHAR ×3
net_asset / net_asset_dateVARCHAR
latest_price_date · latest_nav · price_change(_pct)VARCHAR · DOUBLE ×3
holdings_json / raw_jsonVARCHARtop holdings / full vendor payload

fund_watchlist

PARTITION BY YEARWAL

追蹤清單的事件流;現況 = LATEST ON ts PARTITION BY fund_id。requested_by 兼任排程分層:'sitemap' 列是 bulk 層(一天 1 輪),其餘都是 priority 層(一天 3 輪)。對 sitemap 檔重新 track 即升級。

writefund-nav (sitemap sync / track) + POST /v1/fund/trackreadfund-nav loop (tier split) · GET /v1/fund/status
tsTIMESTAMPdesignated · event time
fund_idSYMBOL(8192)
activeBOOLEAN
requested_byVARCHAR'sitemap' = bulk tier · key label/ops = priority
noteVARCHAR

fund_backfill_runs

PARTITION BY MONTHTTL 12 monthsWAL

基金淨值回補佇列——狀態機對齊 backfill_runs(requested → running → done|failed),但以基金為鍵、由 Yahoo 歷史抓取驅動。POST /v1/fund/track 在無資料時排單;卡死的 running 列 30 分鐘後回收重跑。

writequant-data-api (requested) + fund-nav (progress)readfund-nav loop (picks 'requested' rows)
tsTIMESTAMPdesignated · state-change time
run_idSYMBOL(16384)
fund_idSYMBOL(8192)
range_start / range_endVARCHARYYYY-MM-DD · NULL = full history
statusSYMBOL(8)requested · running · done · failed
rows_writtenLONG
requested_byVARCHAR
errorVARCHAR

fundrich_catalog

PARTITION BY DAYTTL 90 daysWAL

DEDUP UPSERT KEYS(ts, fund_id)

基富通全基金總覽(約 4,800 檔),一輪掃描一份完整快照——同輪所有列共用同一 ts。讀取端從不取 max(ts):一律跟著最新 done 輪的 catalog_ts 走(見 fundrich_scans),失敗/不完整輪等於不存在。這裡的 fund_id 是基富通數字代碼——與 Yahoo 代碼是兩個空間。

writefund-nav fundrich-scanreadGET /v1/fund/fundrich/catalog
tsTIMESTAMPdesignated · scan-round ts (shared)
fund_idSYMBOL(16384)FundRich numeric code, e.g. 002008
name / companyVARCHAR
currencySYMBOL(32)
dividend_rateVARCHAR
risk / morningstarSYMBOL(16)
beginning_dateVARCHAR
data_jsonVARCHARfull vendor row (raw=true 才回)

fundrich_scans

PARTITION BY MONTHTTL 12 monthsWAL

掃描輪狀態機(running → done|failed);每輪現況 = LATEST ON ts PARTITION BY scan_id。done 列的 catalog_ts 就是讀 fundrich_catalog 的入口。完整性守門:抓到數 < 宣告數 90% → failed、不寫 catalog。

writefund-nav fundrich-scanreadGET /v1/fund/fundrich/status · catalog reads (entry point)
tsTIMESTAMPdesignated · state-change time
scan_idSYMBOL(16384)
statusSYMBOL(8)running · done · failed
catalog_tsTIMESTAMPread entry point
declared_total / fetched / pagesINT ×3
requested_byVARCHARschedule · bootstrap · cli
errorVARCHAR

market_quote

PARTITION BY DAYTTL 30 daysWAL

DEDUP UPSERT KEYS(ts, symbol)

StockQ 首頁快照:每輪 quote 掃描每檔一列,同輪所有列共用該輪 ts。讀取端跟著最新 done quote 輪的 quote_ts 走(見 market_scans)——失敗輪等於不存在。34 檔:指數 20、商品 6、匯率 8,定義於 market/registry.py。

writefund-nav market-quote (every 30 min)readGET /v1/market/summary
tsTIMESTAMPdesignated · quote-round ts (shared)
symbolSYMBOL(256)canonical id, e.g. SPX / GOLD / USDJPY
category / regionSYMBOL(8) ×2index|commodity|fx · asia|europe|americas|…
price / change / change_pctDOUBLE ×3
local_time_rawVARCHARStockQ 原文(16:44 或 07/30)
scan_idSYMBOL(16384)

market_daily

PARTITION BY MONTHTTL 5 yearsWAL

DEDUP UPSERT KEYS(ts, symbol)

日頻累積主表——「歷史自己長出來」的關鍵。StockQ 每檔指數只給近 ~20 個交易日,但每輪 detail 掃描都 upsert 這 20 筆(DEDUP 讓重掃冪等、自動回補缺日),累積兩三個月後 60 日以上自然存在。商品/匯率沒有 detail 頁:由 quote 掃描每個台北日落一列(source='quote'),當日內重複掃描覆寫。

writefund-nav market-detail (indices) · market-quote (commodity/fx)readGET /v1/market/summary (series) · GET /v1/market/history/{symbol}
tsTIMESTAMPdesignated · trading day 00:00 UTC
symbolSYMBOL(256)
price / change_pctDOUBLE ×2
sourceSYMBOL(8)detail · quote(detail 覆寫 quote)

market_index_stats

PARTITION BY MONTHTTL 12 monthsWAL

DEDUP UPSERT KEYS(ts, symbol)

每檔指數的技術統計——直接採 StockQ 算好的 5/10/20/60/120/260 日均線、偏離、期間報酬(一日~一年、YTD、距今年高低點)。平台原樣保存供應商數字、不重算;現況 = 每 symbol 最新一列。

writefund-nav market-detail (06:30 / 14:30)readGET /v1/market/summary (LATEST ON ts PARTITION BY symbol)
tsTIMESTAMPdesignated · detail-round ts
symbolSYMBOL(256)
priceDOUBLE近 20 日表最新收盤
ma5 … ma260DOUBLE ×6
deviation / deviation_pctDOUBLE ×2
ret_1d … ret_from_lowDOUBLE ×10
scan_idSYMBOL(16384)

market_scans

PARTITION BY MONTHTTL 12 monthsWAL

market 兩種掃描(quote/detail)的輪次狀態機(running → done|failed)。done quote 列的 quote_ts 就是讀 market_quote 的入口。完整性守門:命中檔數 < registry 的 90% → failed、不寫任何資料列——與 fundrich_scans 同一套 all-or-nothing 語意。

writefund-nav market-quote / market-detailreadGET /v1/market/status · summary reads (entry point)
tsTIMESTAMPdesignated · state-change time
scan_idSYMBOL(16384)
kindSYMBOL(8)quote · detail
statusSYMBOL(8)running · done · failed
quote_tsTIMESTAMPread entry point
expected / fetchedINT ×2
requested_byVARCHARschedule · bootstrap · cli
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 →