questdb について
questdbは、暗号資産の市場データを中心に、QuestDBのデータパイプラインとGrafanaダッシュボードを構築するスキルです。すぐに使えるSQL、スキーマ、取り込みコード、ダッシュボードクエリと、一般的な導入手順を提供します。
主な機能
- 01QuestDBのスキーマとSQLのガイド
- 02暗号資産フィード取り込みテンプレート
- 03Grafanaのデータソースとダッシュボード設定
- 04時系列クエリと結合のリファレンス
- 05シナリオ別のパイプライン実行手順
活用シーン
- 01暗号資産市場データのパイプライン構築
- 02GrafanaでOHLCや指標のダッシュボード作成
- 03新しいパイプライン向けのQuestDBとGrafana設定
- 04QuestDB固有の構文による時系列クエリ作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ questdb/ skills --skill questdbインストール後、Claude Code で/questdbを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/common-mistakes.mdcookbook.mdenterprise.mdgrafana-advanced.mdindicators.mdsql-grammar.md詳細
QuestDB スキル
このスキルの使い方
重要 — 往復回数を最小限にしてください:
- ライブラリのソースコード(cryptofeed、questdb など)を調べないでください
- ライブラリのバージョンを確認したり、コールバックのシグネチャを検証したりしないでください
- API を「理解する」ために、インストール済みパッケージのファイルを読まないでください
- インフラストラクチャ(Docker コンテナ、Grafana の稼働状況)が実行中か確認しないでください — ユーザーのプロンプトを信頼してください
02_ingest.pyを個別に起動しないでください —03_dashboard.pyが起動し、データを自動的に検証します- このスキルファイルですでに扱われているトピックについて、追加のリファレンスファイルを読まないでください
- 該当するトピックのリファレンスファイルは読んでください(例:認証については enterprise.md、複雑なパネルについては grafana-advanced.md)
- 単純なビルドではタスク追跡(TaskCreate/TaskUpdate)を使用しないでください
- データを待ったりバックグラウンドプロセスを確認したりするために、
sleepコマンドを追加しないでください(デプロイスクリプトが処理します) 03_dashboard.pyが取り込みプロセスを起動した後は、Ctrl+C で中断したり、再起動したり、再起動し直したりしないでください- VWAP、Bollinger、RSI を別々の時系列パネルに配置しないでください — これらは OHLC ローソク足パネルの refID です
fieldConfig.overridesを省略したり空にしたりしないでください — RSI を右側の Y 軸(0-100%)に、スプレッドを右側の軸に配置します。これがないと、スケールの違いによってチャートが平坦につぶれてしまいます- ダッシュボードの更新間隔を
"5s"に設定しないでください — デフォルトは"250ms"です - ブラウザーでダッシュボードの URL を開くときは、必ず URL の末尾に
?refresh=250msを追加してください。追加しないと、Grafana は JSON の更新間隔設定を無視します - cryptofeed、QuestDB の取り込み、Grafana に関する API の詳細は以下に記載されています — そのまま使用してください
- 既知の Python 環境の問題は、以下のテンプレートですでに解決されています:
uvloop: 新しい venv を作成すると、pip が Python 3.10 以降でクラッシュする uvloop のバージョンをインストールする場合があります
(
RuntimeError: no current event loop)。新しい venv でのみ、pip install の後にアンインストールしてください。 ユーザーの既存の venv から uvloop をアンインストールしないでください — その環境はすでに正常に動作しています。 macOS の SSL: Homebrew Python にはシステムの CA 証明書がありません。certifi がないと、外部への HTTPS/WSS 接続(取引所の WebSocket、API 呼び出し)は失敗します。ネットワーク接続を行うスクリプトの先頭では、必ず certifi を使ってSSL_CERT_FILEを設定してください。 これらの修正は、以下の pip コマンドと Python テンプレートに組み込まれています — そのままコピーしてください。
このスキルには、すぐに使える SQL、スキーマ、取り込みコード、Grafana クエリが含まれています。**ファイルを作成して実行してください。**一般的なパイプラインは 3 つのファイル(スキーマ設定、取り込みスクリプト、ダッシュボードのデプロイ)で構成されます — ファイルを作成して実行すれば完了です。
実行シナリオ
ユーザーのリクエストに合ったシナリオを選び、記載されているコマンドをそのまま実行してください。 以下の各 Bash コードブロックは、それぞれ 1 回の Bash ツール呼び出しです。すぐに実行し、シェル構文について考え込まないでください。 同じステップにあるブロックは、並列のツール呼び出しとして実行できます。
シナリオ A — すべてをゼロからセットアップする(Docker + venv + パイプライン): 並列化するため、個別の Bash ツール呼び出し(呼び出しごとにコマンド 1 つ)を使って実行します。
docker run -d --name questdb -p 9000:9000 -p 9009:9009 -p 8812:8812 questdb/questdb:latest
docker run -d --name grafana -p 3000:3000 -e GF_INSTALL_PLUGINS=questdb-questdb-datasource -e GF_SECURITY_ADMIN_PASSWORD=admin -e GF_DASHBOARDS_MIN_REFRESH_INTERVAL=250ms grafana/grafana:latest
python3 -m venv .venv
続けて、順番に実行します。
.venv/bin/pip install -q cryptofeed questdb 'psycopg[binary]' requests numpy certifi
.venv/bin/pip uninstall uvloop -y 2>/dev/null
次に、両方のサービスが起動するまで待ちます(それぞれ個別の Bash 呼び出しで実行します)。
curl -sf --retry 30 --retry-delay 1 --retry-all-errors -o /dev/null "http://localhost:9000/exec?query=SELECT+1"
curl -sf --retry 30 --retry-delay 1 --retry-all-errors -o /dev/null http://localhost:3000/api/health
続いて、データソースを設定します。
curl -s -X POST http://localhost:3000/api/datasources -u admin:admin -H "Content-Type: application/json" -d '{"name":"QuestDB","type":"questdb-questdb-datasource","access":"proxy","jsonData":{"server":"host.docker.internal","port":8812,"username":"admin","tlsMode":"disable","timeout":"120","queryTimeout":"60"},"secureJsonData":{"password":"quest"}}'
データソースのフィールド(QuestDB Grafana プラグインでは標準の url フィールドではなく jsonData を使用します):
server:ホスト名のみ。ポートやプロトコルは含めません(例:host.docker.internal)port:整数。server とは分けて指定します(例:8812)tlsMode:ローカルの Docker では"disable"にする必要があります。省略すると TLS が有効になり、接続に失敗します。username/password:QuestDB のデフォルト値はadmin/questです。 続いて 3 つのファイル(スキーマ、取り込み、ダッシュボード)を作成し、実行します。
シナリオ B — コンテナは起動済みで、venv が必要:
python3 -m venv .venv
.venv/bin/pip install -q cryptofeed questdb 'psycopg[binary]' requests numpy certifi
.venv/bin/pip uninstall uvloop -y 2>/dev/null
続いて 3 つのファイルを作成し、実行します。
シナリオ C — ユーザーが既存の venv のパスを指定している:
ユーザーの venv をそのまま使用します。pip install やアンインストールは実行しないでください。
3 つのファイルを作成し、実行します。すべての python コマンドで、ユーザーの venv のパスを使用してください。
シナリオ D — すべて起動済みで、パイプラインスクリプトだけが必要: 3 つのファイルを作成し、実行します。インフラストラクチャのセットアップは不要です。
references/ ディレクトリにある追加資料は、ユーザーのリクエストがこのファイルの説明範囲を超える場合に限り参照してください。
common-mistakes.md— 誤ったパターンと、正しい QuestDB 相当のパターン(以下のテンプレートにない新しい SQL を書く場合に参照)grafana-advanced.md— Plotly の板注文深度チャート、または以下のダッシュボードテンプレートにない高度な機能の場合に限り参照indicators.md— OHLC/VWAP/Bollinger/RSI 以外のインジケーター(MACD、ATR、Stochastic、OBV、Drawdown、Keltner、Donchian など)をユーザーが求めた場合に参照cookbook.md— 公式 QuestDB クックブックレシピのインデックス。金融(スリッページ、マークアウト、IS、ECN スコアカード、ラストルック、インジケーター、出来高/オーダーフロー、リスク)および時系列パターン(FILL 戦略、セッションウィンドウ、パーティションごとの最新 N 件、疎なセンサーの結合)が含まれます。実行品質/TCA メトリクス、名称が指定されたインジケーター、またはインラインで説明されていない時系列パターンをユーザーが求めた場合に参照sql-grammar.md— QuestDB のキーワード、関数(カテゴリ別、シグネチャ付き)、データ型、演算子の完全なインデックス。「QuestDB に関数 X はあるか」を確認する場合や、正しいシグネチャが必要な場合に参照してください。 ネイティブの金融組み込み関数(vwap()、twap()、spread()、mid()、wmid()、l2price())、統計関数、配列/行列演算などが含まれます。enterprise.md— QuestDB で認証、HTTPS、トークン、または ACL を使用する場合に参照してください(オープンソースの場合は不要)。
重要なルール
QuestDB は PostgreSQL ではありません。 クエリには PostgreSQL ワイヤプロトコルをサポートしていますが、時系列操作には独自の SQL 拡張機能を使用します。time_bucket()、DISTINCT ON、HAVING、generate_series() などの標準的な PostgreSQL パターンは存在しません。
ドキュメントへのライブアクセス
QuestDB のすべてのドキュメントページには、同じ URL に .md を追加したプレーン Markdown 版があります。 curl で取得すれば、高速かつ読みやすく、LLM に適した形式で参照できます。HTML の除去やスクレイピングは不要です。キーワード、構文の詳細、またはここに記載されていない特殊なケースが必要な場合は、推測するよりも公式ドキュメントを取得してください。
# Any doc page — just append .md to the URL
curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/sql/horizon-join.md"
curl -sH "Accept: text/markdown" "https://questdb.com/docs/cookbook/sql/finance.md"
# Full documentation index for LLMs — lists every page with its .md URL
curl -s "https://questdb.com/docs/llms.txt"
正確なパスが分からない場合は、llms.txt を使ってページを探してください。すべてのドキュメントとクックブックレシピを網羅した、公式の一覧です。
SQL リファレンス
CREATE TABLE
CREATE TABLE IF NOT EXISTS trades (
ts TIMESTAMP,
symbol SYMBOL,
side SYMBOL,
price DOUBLE,
amount DOUBLE
) TIMESTAMP(ts) PARTITION BY DAY WAL
DEDUP UPSERT KEYS(ts, symbol);
主なルール:
TIMESTAMP(col)は時刻列を指定します。SAMPLE BY、LATEST ON、ASOF JOIN で必須です- 繰り返し使用される文字列(ティッカー、カテゴリ、ステータスコード)には
SYMBOL型を使用します。VARCHAR よりはるかに高速です PARTITION BY DAY|MONTH|YEAR|HOURには、最も一般的なクエリの粒度を指定しますWALによって同時書き込みが可能になります(ILP 取り込みで必須です)DEDUP UPSERT KEYS(ts, symbol)は (timestamp, symbol) をキーに重複を排除します。冪等な取り込みが可能です
列の型: BOOLEAN、BYTE、SHORT、INT、LONG、FLOAT、DOUBLE、CHAR、VARCHAR、
SYMBOL、TIMESTAMP、DATE、LONG256、GEOHASH、UUID、IPv4、DOUBLE[]、FLOAT[]、INT[]、
LONG[]、SHORT[]、UUID[] — DOUBLE[][] などの 2D 配列も使用できます
ALTER TABLE:
ALTER TABLE trades ADD COLUMN exchange SYMBOL;
ALTER TABLE trades DROP COLUMN exchange;
ALTER TABLE trades RENAME COLUMN amount TO qty;
ALTER TABLE trades ALTER COLUMN exchange SET TYPE SYMBOL;
SAMPLE BY(時系列の集計)
SAMPLE BY は QuestDB の時間バケット機能です。GROUP BY time_bucket() の代わりに使用します。
指定されたタイムスタンプが必要です。
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE ts > '2025-01-01'
SAMPLE BY 1h;
主なルール:
- 有効な間隔:
1s、5s、1m、15m、1h、1d、1M(月) ALIGN TO CALENDARはバケットを時刻の境界に揃えます(これがデフォルトのため、省略できます)FILL(PREV | NULL | LINEAR | value)は欠損を補完します。SAMPLE BY の後に指定します- Grafana では、ユーザーが異なるバーサイズを指定しない限り、デフォルトで
SAMPLE BY 5sを使用します first()、last()は各時間バケット内の最初と最後の値を返しますCOUNT(DISTINCT col)の代わりにcount_distinct(col)を使用しますHAVINGは使用できません。サブクエリを使用します:SELECT * FROM (... SAMPLE BY ...) WHERE volume > 1000
LATEST ON(グループごとの最新値)
グループごとに最も新しい行を返します。DISTINCT ON/ROW_NUMBER() の代わりに使用します。
SELECT * FROM trades
WHERE ts > dateadd('h', -1, now())
LATEST ON ts PARTITION BY symbol;
ルール:
LATEST ON tsは指定されたタイムスタンプを参照する必要がありますPARTITION BYは必須です- パフォーマンス向上のため、
WHEREで時間を絞り込みます
結合
QuestDB には、時系列向けに特化した結合が複数あります。質問に合ったものを選んでください。HORIZON や WINDOW が適切な場面で、デフォルトで ASOF JOIN を使おうとするのは、LLM によくある間違いです。
| パターン | 用途 |
|---|---|
| 時点での照合:「取引時点の値」 | ASOF JOIN(または LIST (0) を指定した HORIZON JOIN) |
| 取引時点より厳密に前の値との照合 | LT JOIN |
| 2 つの時系列を時系列順に交互に並べる | SPLICE JOIN |
| 取引時点および ±N のオフセット時点の価格(マークアウト、IS 分解、逆選択) | HORIZON JOIN |
| 各取引の前後 ±N の範囲内にある気配値を集計する(ローリング統計、気配値密度、取引前後の平均) | WINDOW JOIN |
| 外側の各行に対するサブクエリ:グループごとの上位 N 件、動的フィルター、行ごとの SAMPLE BY / LATEST ON / ASOF | LATERAL JOIN |
-- ASOF JOIN: nearest right row with ts ≤ left.ts
SELECT * FROM trades ASOF JOIN quotes ON (symbol);
-- LT JOIN: strictly ts < left.ts
SELECT * FROM trades LT JOIN quotes ON (symbol);
-- SPLICE JOIN: merge two time series interleaved
SELECT * FROM trades SPLICE JOIN quotes ON (symbol);
-- HORIZON JOIN: ASOF at a grid of time offsets, one parallel pass.
-- For each trade, computes metrics at -1m, -30s, 0, 30s, 1m, ...
-- Supports multiple right-hand tables (only the last carries RANGE/LIST/AS).
SELECT h.offset / 1_000_000_000 AS horizon_sec, t.symbol,
avg((m.best_bid + m.best_ask) / 2) AS avg_mid
FROM fx_trades t
HORIZON JOIN market_data m ON (symbol)
RANGE FROM -1m TO 5m STEP 30s AS h
WHERE t.timestamp IN '$yesterday'
ORDER BY t.symbol, horizon_sec;
-- Or with explicit offsets for implementation shortfall:
-- LIST (0, 1800s) AS h → at_fill and at_30m columns (via PIVOT)
-- WINDOW JOIN: aggregate right-table rows within a ±time window around each left row.
-- EXCLUDE PREVAILING omits the last-known value before the window start.
SELECT t.symbol, t.timestamp,
avg(c.bid_price) AS avg_bid_pm5s,
count() AS num_quotes
FROM fx_trades t
WINDOW JOIN core_price c ON (t.symbol = c.symbol)
RANGE BETWEEN 5 seconds PRECEDING AND 5 seconds FOLLOWING
EXCLUDE PREVAILING
WHERE t.symbol = 'EURUSD' AND t.timestamp IN '$now-1h..$now';
-- LATERAL JOIN: subquery evaluated per outer row, can reference outer columns.
-- Classic top-N per group — two largest fills for each order:
SELECT o.id, o.desk, t.qty
FROM orders o
JOIN LATERAL (
SELECT qty FROM fills
WHERE order_id = o.id
ORDER BY qty DESC LIMIT 2
) t
ORDER BY o.id, t.qty DESC;
結合のルール:
- すべての時系列結合(ASOF、LT、SPLICE、HORIZON、WINDOW)では、両方のテーブルに指定タイムスタンプが必要です。タイムスタンプは自動的に照合されます。
ON (symbol)はキー列を照合します。両方のテーブルで同じ列名を使う必要があります。列名が異なる場合は、ON (t.sym = q.sym)を使います。- 非時系列の結合では、標準の INNER JOIN と LEFT JOIN も使用できます。
- HORIZON JOIN:同じ階層で他の結合タイプを使用することはできません。SAMPLE BY や内部のウィンドウ関数も使用できず、WHERE フィルターの対象は左テーブルのみです。それ以外の処理が必要な場合は、サブクエリで囲みます。
- WINDOW JOIN:GROUP BY、ウィンドウ関数、他の結合タイプとは併用できません。結合を CTE で囲み、外側のクエリで集計または結合します。
- LATERAL JOIN:INNER、LEFT、CROSS の各バリエーションのみサポートされています。括弧で囲んだサブクエリが必要です。サブクエリ内では、外側の列を参照し、SAMPLE BY、LATEST ON、ASOF JOIN、ウィンドウ関数、UNION ALL などを使用できます。
HORIZON / WINDOW / LATERAL の完全な構文(精度の異なるタイムスタンプ、動的なウィンドウ境界、複数テーブルの HORIZON パターンを含む)を確認するには、以下を取得してください:
curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/sql/horizon-join.md"curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/sql/window-join.md"curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/sql/lateral-join.md"
HORIZON JOIN を使用した既成の執行分析クエリ(スリッページ、マークアウト、インプリメンテーション・ショートフォール、ラストルック、ECN スコアカード)については、references/cookbook.md または https://questdb.com/docs/cookbook/sql/finance.md を参照してください。
UNNEST(配列および JSON 配列から行への展開)
QuestDB は、配列、または VARCHAR として保存された JSON 配列を行に展開する UNNEST をサポートしています。FROM 句に記述し、テーブルとして動作します。
-- Native array: expand the sizes at level 2 of each order book row
SELECT t.symbol, u.vol
FROM market_data t, UNNEST(t.asks[2]) u(vol)
WHERE t.timestamp IN '$now-1m..$now' AND t.symbol = 'EURUSD';
-- WITH ORDINALITY gives a 1-based level index (resets per input row)
SELECT m.symbol, u.vol, u.level
FROM market_data m, UNNEST(m.asks[2]) WITH ORDINALITY u(vol, level)
WHERE m.timestamp IN '$now-1m..$now';
-- JSON array (VARCHAR) with typed columns — great for ingesting API payloads
SELECT u.trade_id, u.price, u.size, u.side, u.time
FROM UNNEST(
payload_varchar::VARCHAR
COLUMNS(trade_id LONG, price DOUBLE, size DOUBLE, side VARCHAR, time TIMESTAMP)
) u;
主なルール:
UNNESTはSELECTではなく、FROM(またはCROSS JOIN/ カンマの後)に記述する必要があります。- ネイティブ配列:現在は
DOUBLE[]のみです。DOUBLE[][]のUNNESTはDOUBLE[]の行を返します。完全にフラット化するには、2 回目のUNNESTを連結します。 - JSON:
COLUMNS(name TYPE, ...)で型付きフィールドを抽出します。サポートされる型は BOOLEAN、SHORT、INT、LONG、DOUBLE、VARCHAR、TIMESTAMP です。不正な JSON、NULL、空文字列はいずれもエラーにならず、0 行を返します。 ordinalityは予約語です。別名(u(val, pos))を付けるか、引用符で囲んでください。
完全なリファレンス:curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/sql/unnest.md"
ウィンドウ関数
-- ROWS frame (count-based)
AVG(close) OVER (ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS sma20
-- CUMULATIVE shorthand (QuestDB extension, equivalent to ROWS UNBOUNDED PRECEDING)
SUM(volume) OVER (ORDER BY ts CUMULATIVE) AS running_total
-- PARTITION BY
LAG(close) OVER (PARTITION BY symbol ORDER BY ts) AS prev_close
-- EMA (exponential moving average — QuestDB extension)
avg(price, 'period', 14) OVER (ORDER BY ts) AS ema14
サポート対象:ROW_NUMBER()、RANK()、DENSE_RANK()、LAG()、LEAD()、
FIRST_VALUE()、AVG()、SUM()、MIN()、MAX()、COUNT()
重要: stddev_samp() はウィンドウフレームで動作しない場合があります。標準偏差を求めるには、手動で計算してください:sqrt(avg(x*x) - avg(x)^2) — 下記のボリンジャーバンドのクエリを参照してください。
マテリアライズドビュー
新しいデータによってトリガーされ、自動更新される増分集計です:
CREATE MATERIALIZED VIEW IF NOT EXISTS candles_5s AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades SAMPLE BY 5s
) PARTITION BY DAY;
ルール:
- ソースクエリでは必ず SAMPLE BY を使用してください。
- 集計関数のみ使用できます(WHERE、JOIN、ウィンドウ関数は使用できません)。
- 再実行時のエラーを避けるには
IF NOT EXISTSを使用してください。 - 複数の解像度でビューを連鎖させます:
trades → 5s → 1m → 1h(下記のスキーマ設計を参照)。 - 無効化/再構築:
ALTER MATERIALIZED VIEW candles_5s INVALIDATE
ビュー(非マテリアライズド)
QuestDB は通常のビューもサポートしています。これは SELECT ステートメントで定義され、クエリのたびに評価される仮想テーブルです。増分事前計算が 不要な、再利用可能なクエリ抽象化に使用してください。
CREATE VIEW IF NOT EXISTS latest_prices AS (
SELECT * FROM trades
LATEST ON ts PARTITION BY symbol
);
-- Query it like a table
SELECT * FROM latest_prices WHERE symbol = 'BTC-USDT';
-- Modify or drop
ALTER VIEW latest_prices AS (SELECT ...); -- redefine
COMPILE VIEW latest_prices; -- recompile after schema changes
DROP VIEW latest_prices;
増分 SAMPLE BY ロールアップが必要な場合は、マテリアライズドビューを使用してください。それ以外(最新値の検索、フィルター済みサブセット、 結合の抽象化)には通常のビューを使用してください。
タイムスタンプのフィルタリング — TICK 構文(推奨)
時間フィルターには、dateadd() や BETWEEN よりも常に TICK を優先してください。TICK
(Temporal Interval Calendar Kit)は、QuestDB の時間間隔を表す宣言型構文です。読みやすく、
最適化された間隔スキャンを生成し、タイムゾーンや営業日を処理できます。また、通常なら UNION ALL や
アプリケーション側のロジックが必要となる複雑な複数間隔パターンも表現できます。
構文の順序: date [T time] @timezone #dayFilter ;duration
-- Date variables: resolve at query time
WHERE ts IN '$today' -- full day (midnight to midnight)
WHERE ts IN '$yesterday' -- previous full day
WHERE ts IN '$tomorrow' -- next full day
-- Ranges with $now (point-in-time, microsecond precision)
WHERE ts IN '$now - 1h..$now' -- last hour
WHERE ts IN '$now - 30m..$now' -- last 30 minutes
WHERE ts IN '$now - 5bd..$now' -- last 5 business days (skips weekends)
-- Duration suffix: extends forward from a point
WHERE ts IN '$now;1h' -- 1 hour starting now (forward)
WHERE ts IN '2025-01-15T09:30;6h30m' -- NYSE trading session
-- Imprecise dates: month-level and year-level (expand to all days)
WHERE ts IN '[2025-01]' -- all of January (no need for [01..31])
WHERE ts IN '[2025]T09:30' -- all of 2025 at 09:30
WHERE ts IN '2025-02T09:30' -- bare imprecise (brackets optional alone)
-- Bracket expansion: generates multiple intervals
WHERE ts IN '2025-01-[10..15]' -- days 10 through 15
WHERE ts IN '2025-01-[5,10..12,20]' -- specific days + ranges
WHERE ts IN '2025-[01,06]-[10,15]' -- Cartesian: Jan+Jun x 10th+15th
WHERE ts IN '2025-[01..03]T09:30' -- month-level range: all days Jan-Mar
-- Day-of-week filters
WHERE ts IN '[2025-01]#workday' -- weekdays in January
WHERE ts IN '[2025-01]#weekend' -- weekends in January
WHERE ts IN '[2025-01]#Mon,Wed,Fri' -- specific days in January
-- Timezone-aware (handles DST)
WHERE ts IN '2025-01-15T09:30@America/New_York;6h30m'
-- Combined: workdays at 09:30 New York time for all of January
WHERE ts IN '[2025-01]T09:30@America/New_York#workday;6h30m'
-- Mixed-precision date lists
WHERE ts IN '[2025-01-15, 2025-02]T09:30' -- one day + full month
-- Time lists: multiple intraday windows
WHERE ts IN '2025-01-15T[09:00,14:30];1h' -- two 1h windows on the same day
-- Date lists (brackets required for lists)
WHERE ts IN '[$today, $yesterday, 2025-01-15]'
-- ISO week dates
WHERE ts IN '2025-W01-[1..5]T09:00;8h' -- Mon-Fri of week 1
主なルール:
- 日付変数では大文字と小文字が区別されません(
$TODAY=$today) $today/$yesterday/$tomorrowは終日を含む間隔を生成し、$nowはある時点を生成します(使用するには範囲または期間を追加してください)- 算術単位:
yMwdbdhmsT(ms)u(us)n(ns)。bd(営業日)は算術演算でのみ有効で、期間には使用できません。 大文字と小文字が区別されます:M= 月、m= 分、T= ミリ秒。 - 不正確な日付:
YYYY-MMはその月の全日、YYYYはその年の全日に展開されます。'[2025-01]T09:30#workday;6h30m'を使うと、冗長な'2025-01-[01..31]T09:30#workday;6h30m'を置き換えられます。単独で使用する場合は角括弧を省略できます ('2025-02T09:30'='[2025-02]T09:30')。リストでは必須です。 - 次の場合は角括弧が必要です:リスト(
[$today, $yesterday])、接尾辞付きの範囲 ([$now - 2h..$now]@America/New_York)。単独の変数、 単独の範囲($now - 2h..$now)、角括弧なしの不正確な日付(2025-01T09:30)では省略できます。 - 角括弧の展開で重複する間隔は自動的に統合されます。
- 取引所カレンダー(Enterprise):
#workdayの代わりに#XNYS、#XLONなど(ISO 10383 MIC コード)を使用すると、実際の取引所の取引スケジュールでフィルタリングできます。祝日、早じまい、 昼休みは自動的に処理されます。WHERE ts IN '[2025-01]#XNYS'は NYSE の取引セッションのみを返します。 参照:curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/operators/exchange-calendars.md"
完全なリファレンス:curl -sH "Accept: text/markdown" "https://questdb.com/docs/query/operators/tick.md"
DECLARE(変数)
DECLARE @start := '2025-01-01T00:00:00Z', @end := now();
SELECT * FROM trades WHERE ts BETWEEN @start AND @end;
SQL の実行順序
FROM → WHERE → SAMPLE BY → SELECT → LIMIT
(SAMPLE BY では GROUP BY は不要です。HAVING は使用できません。)
主な関数
タイムスタンプ: now(), systimestamp(), dateadd('unit', n, ts),
datediff('unit', ts1, ts2), to_timestamp('str', 'fmt'),
timestamp_floor('unit', ts), timestamp_ceil('unit', ts),
hour(ts), day_of_week(ts), week_of_year(ts), year(ts), month(ts)
文字列: concat('a', 'b'), left(s, n), right(s, n), length(s),
starts_with(s, prefix), lcase(s), ucase(s), replace(s, old, new),
split_part(s, delim, idx), regexp_replace(s, pattern, replacement)
集計: first(x), last(x), count_distinct(x), sum(x), avg(x),
min(x), max(x), haversine_dist_deg(lat1, lon1, lat2, lon2)
配列: array_cum_sum(arr) — 配列要素の累積合計
データの取り込み
ILP(InfluxDB Line Protocol)— 主な方法
高スループットの取り込みにはすべて ILP を使用します。ストリーミングデータには INSERT INTO を決して使用しないでください。
from questdb.ingress import Sender, TimestampNanos
import numpy as np
# Open Source (no auth). For Enterprise: read references/enterprise.md Quick Start
# (admin creates service account + token via REST → ingestion script uses token)
conf = "tcp::addr=localhost:9009;protocol_version=2;"
with Sender.from_conf(conf) as sender:
sender.row(
'trades',
symbols={'symbol': 'BTC-USDT', 'side': 'buy'},
columns={'price': 42000.50, 'amount': 1.5},
at=TimestampNanos.now()
)
sender.flush()
主なパターン:
Sender.from_conf()は接続しません。withコンテキストマネージャーを使用するか、sender.establish()を呼び出してください- SYMBOL 列には
symbols={}、それ以外にはすべてcolumns={}を使用します - 配列は、Python リストではなく
np.float64の NumPy 配列でなければなりません - 配列をサポートするには、TCP の設定で
protocol_version=2が必要です - HTTP トランスポート:
http::addr=localhost:9000; - TCP トランスポート:
tcp::addr=localhost:9009;
2D 配列の取り込み(オーダーブック):
sender.row(
'order_book',
symbols={'symbol': 'BTC-USDT'},
columns={
'bids': [
np.array([41999.0, 41998.5, 41998.0], dtype=np.float64), # prices [1]
np.array([2.1, 5.3, 10.0], dtype=np.float64), # sizes [2]
],
'asks': [
np.array([42001.0, 42001.5, 42002.0], dtype=np.float64),
np.array([1.8, 4.2, 8.5], dtype=np.float64),
],
},
at=TimestampNanos.now()
)
その他のクライアントライブラリ: Go、Java、Rust、Node.js、C/C++、.NET — ingestion/clients/{language} を取得してください
INSERT INTO — 低ボリューム専用
INSERT INTO trades (ts, symbol, side, price, amount)
VALUES ('2025-02-09T10:00:00.000000Z', 'BTC-USDT', 'buy', 42000.50, 1.5);
PG Wire 経由でのクエリ
import psycopg as pg
conn = pg.connect("user=admin password=quest host=localhost port=8812 dbname=qdb")
HTTP REST API
- クエリ:
GET http://localhost:9000/exec?query=URL_ENCODED_SQL - 括弧を含むすべての特殊文字をURL エンコードしてください:
(=%28、)=%29。curl コマンド内の未エンコードの()はセキュリティ確認を促します。 例:count_distinct(symbol)ではなくcount_distinct%28symbol%29 - Enterprise:
references/enterprise.mdのクイックスタートを参照してください - exec エンドポイントでは POST はサポートされていません。GET のみを使用してください
- JSON を返します:
{ "columns": [...], "dataset": [...] }
スキーマ設計
主な原則:
- すべての時系列テーブルに、指定されたタイムスタンプが必要です
- 繰り返し使われる文字列(ティッカー、カテゴリ、ステータスコード)には SYMBOL を使用します
- 最も一般的なクエリの粒度に基づいてパーティション分割します
- 同時書き込みのワークロードには WAL テーブルを使用します
- 冪等な取り込みには DEDUP を使用します
金融市場データパイプラインのスキーマ(すぐに使用可能)
CREATE TABLE IF NOT EXISTS trades (
ts TIMESTAMP,
symbol SYMBOL,
side SYMBOL,
price DOUBLE,
amount DOUBLE
) TIMESTAMP(ts) PARTITION BY DAY WAL
DEDUP UPSERT KEYS(ts, symbol);
CREATE TABLE IF NOT EXISTS orderbook (
ts TIMESTAMP,
symbol SYMBOL,
bid_prices DOUBLE[],
bid_sizes DOUBLE[],
ask_prices DOUBLE[],
ask_sizes DOUBLE[]
) TIMESTAMP(ts) PARTITION BY DAY WAL
DEDUP UPSERT KEYS(ts, symbol);
-- Cascading materialized views: trades → 5s → 1m → 1h
CREATE MATERIALIZED VIEW IF NOT EXISTS candles_5s AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades SAMPLE BY 5s
) PARTITION BY DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS candles_1m AS (
SELECT ts, symbol,
first(open) AS open, max(high) AS high,
min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM candles_5s SAMPLE BY 1m
) PARTITION BY DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS candles_1h AS (
SELECT ts, symbol,
first(open) AS open, max(high) AS high,
min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM candles_1m SAMPLE BY 1h
) PARTITION BY MONTH;
以下の Grafana クエリは、これらのテーブル名/ビュー名でそのまま機能します。
デモとサンプルデータ
インフラストラクチャのセットアップコマンドは、上記の実行シナリオセクションにあります。 該当するシナリオを選び、記載されたコマンドをそのまま実行してから、ここに進んでください。
リアルタイムの暗号資産フィード(cryptofeed + QuestDB)
cryptofeed のソースコードを調べたり、バージョンを確認したりしないでください。必要な情報はすべてここにあります。 この取り込みスクリプトをそのままコピーしてください。certifi/SSL の修正とすべての import が必要です。どの行も省略しないでください:
import os
import certifi
os.environ['SSL_CERT_FILE'] = certifi.where() # Required: macOS lacks system CA certs for HTTPS/WSS
import numpy as np
from cryptofeed import FeedHandler
from cryptofeed.exchanges import OKX
from cryptofeed.defines import TRADES, L2_BOOK
from questdb.ingress import Sender, TimestampNanos
conf = "tcp::addr=localhost:9009;protocol_version=2;" # Enterprise: see references/enterprise.md
async def trade_cb(t, receipt_timestamp):
with Sender.from_conf(conf) as sender:
sender.row(
'trades',
symbols={'symbol': t.symbol, 'side': t.side},
columns={'price': float(t.price), 'amount': float(t.amount)},
at=TimestampNanos(int(t.timestamp * 1e9))
)
sender.flush()
async def book_cb(book, receipt_timestamp):
bids = book.book['bid']
asks = book.book['ask']
# Sort: bids descending by price, asks ascending
bid_prices = sorted(bids.keys(), reverse=True)[:25]
ask_prices = sorted(asks.keys())[:25]
with Sender.from_conf(conf) as sender:
sender.row(
'orderbook',
symbols={'symbol': book.symbol},
columns={
'bid_prices': np.array([float(p) for p in bid_prices], dtype=np.float64),
'bid_sizes': np.array([float(bids[p]) for p in bid_prices], dtype=np.float64),
'ask_prices': np.array([float(p) for p in ask_prices], dtype=np.float64),
'ask_sizes': np.array([float(asks[p]) for p in ask_prices], dtype=np.float64),
},
at=TimestampNanos(int(book.timestamp * 1e9))
)
sender.flush()
SYMBOLS = ['BTC-USDT', 'ETH-USDT', 'SOL-USDT'] # add more as needed
f = FeedHandler()
f.add_feed(OKX(
symbols=SYMBOLS,
channels=[TRADES, L2_BOOK],
callbacks={TRADES: trade_cb, L2_BOOK: book_cb}
))
f.run()
cryptofeed API リファレンス(完全版 — v2.4.x でテスト済み — 確認のためにソースコードを読まないでください):
t.symbol、t.side('buy'/'sell')、t.price、t.amount、t.timestamp(エポック秒の float)t.priceとt.amountはDecimal型です。QuestDB Sender に渡す前にfloat()でキャストしてくださいbook.book['bid']とbook.book['ask']は辞書です:{Decimal(price): Decimal(size), ...}book.symbol、book.timestamp(エポック秒の float)- 取引所:
OKX、Coinbase、Binance、Kraken、Bybitなど - チャンネル:
TRADES、L2_BOOK、L3_BOOK、TICKER、CANDLES、OPEN_INTEREST、FUNDING、LIQUIDATIONS - シンボル形式は取引所固有です:OKX は
'BTC-USDT'、Coinbase は'BTC-USD' - Python の互換性:
X | None型ヒントは使用しないでください(3.10 以降が必要です)。Optional[X]または通常の代入を使用してください。 - 依存関係(新しい venv):
pip install cryptofeed questdb 'psycopg[binary]' requests numpy certifiを実行してから、pip uninstall uvloop -yを実行してください(Bash 呼び出しは別々に行います)。 - macOS の SSL: 外部への HTTPS/WSS 接続を行う前に、必ず certifi を使って
SSL_CERT_FILEを設定してください(Homebrew の Python にはシステム CA 証明書がありません)。 Python コード内で設定してください(os.environ['SSL_CERT_FILE'] = certifi.where())。 シェルレベルでSSL_CERT_FILE=$(...)を前置する方法は絶対に使用しないでください。セキュリティプロンプトが表示されます。
パフォーマンスに関する注意: 上記の例では、分かりやすさのため、コールバックごとに Sender を開きます。 本番環境では、Sender を共有し、定期的に flush してください:
sender = Sender.from_conf(conf)
sender.establish()
async def trade_cb(t, receipt_timestamp):
sender.row('trades', symbols={...}, columns={...}, at=...)
# flush periodically or use auto_flush_interval in conf string
実行順序(必須 — 順序を変更しないでください):
01_schema.pyを実行します — テーブルとビューを作成します03_dashboard.pyを実行します — このスクリプトは取り込みを自動的に開始し、データを待ってからダッシュボードをデプロイし、ブラウザーを開きます。02_ingest.pyは別途起動しないでください。
- 取り込みプロセスの開始後は、Ctrl+C を押したり、再起動したり、その他の操作を行ったりしないでください。
- 完了の定義: ダッシュボードがブラウザーで開いており、取り込みプロセスが引き続き実行中であること。
運用上の注意:
- cryptofeed は stdout ではなく stderr にログを出力します。stdout が空でも、失敗したことを意味するわけではありません。
- ダッシュボードのデプロイスクリプトの最後で
open(macOS)またはxdg-open(Linux)を実行し、ブラウザーを自動的に起動してください。正しい更新頻度でダッシュボードを開くため、URL に?refresh=250msを含めてください:url = f"{GRAFANA_URL}{resp.json()['url']}?refresh=250ms&from=now-5m&to=now" subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", url])
QuestDB デモインスタンス
QuestDB のライブデモ(demo.questdb.io)には FX と暗号資産のデータセットがあります。
スキーマリファレンスを取得してください:curl -sH "Accept: text/markdown" "https://questdb.com/docs/cookbook/demo-data-schema/"
Grafana との統合
QuestDB には専用の Grafana データソースプラグイン(questdb-questdb-datasource)があります。
ポート 8812 の PG wire 経由で接続します。
データソース API の設定(QuestDB プラグインは標準の url フィールドではなく jsonData を使用します):
jsonData.server:ホスト名のみ。ポートやプロトコルは含めません(例:host.docker.internal)jsonData.port:整数。server とは別に指定します(例:8812)jsonData.tlsMode:ローカル Docker では"disable"を指定します — 省略時は TLS が有効になり、接続に失敗しますjsonData.username+secureJsonData.password:QuestDB のデフォルトはadmin/questですurlフィールドは使用しないでください。QuestDB プラグインはこれを無視します。
以下のダッシュボードデプロイテンプレートには、データソースの作成または検索を行うロジックが含まれています。これを使用してください。
主なマクロ:
$__timeFilter(ts)— Grafana の時間範囲ピッカーで選択した時間範囲- SAMPLE BY のデフォルト間隔:
5s。ユーザーが別のバーサイズを指定した場合にのみ変更してください。
シンボルのドロップダウン変数:SELECT DISTINCT symbol FROM trades
マルチクエリパネル、軸のオーバーライド、パネルの繰り返し、板の深度チャートなど、高度な Grafana のパターンについては、references/grafana-advanced.md を参照してください。
すぐに使える Grafana クエリ
一般的な金融パネル用の、完全でテスト済みの SQL です。そのまま使用できます。
Grafana クエリのパターン規則: Grafana には time 列が必要なため、最後の SELECT で ts AS time とエイリアスを付けます。ts AS time とエイリアスを付ける SELECT で OVER() 句を使用しないでください。 すべてのウィンドウ関数は、ts がまだ ts のままである CTE に記述し、最後の SELECT でのみエイリアスを付けます。以下のクエリはすべてこのパターンに従っています。
OHLC ローソク足(生の取引データから):
SELECT ts AS time,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5s;
OHLC ローソク足(マテリアライズドビューから — 長い時間範囲では高速):
SELECT ts AS time,
first(open) AS open, max(high) AS high,
min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM candles_1m
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5m;
VWAP(累積出来高加重平均価格):
WITH ohlc AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5s
),
vwap AS (
SELECT ts, close,
sum((high + low + close) / 3 * volume) OVER (ORDER BY ts CUMULATIVE)
/ sum(volume) OVER (ORDER BY ts CUMULATIVE) AS vwap
FROM ohlc
)
SELECT ts AS time, close, vwap FROM vwap;
ボリンジャーバンド(20 期間 SMA ± 2σ):
手動で分散を計算します。ウィンドウフレームでは stddev_samp より互換性があります。
WITH ohlc AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5s
),
stats AS (
SELECT ts, close,
AVG(close) OVER (
ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS sma20,
AVG(close * close) OVER (
ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS avg_close_sq
FROM ohlc
)
SELECT ts AS time, close, sma20,
sma20 + 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS upper_band,
sma20 - 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS lower_band
FROM stats;
RSI(14 期間、SMA 平滑化):
このバージョンの Grafana では、実運用で検証済みの ROWS BETWEEN を使った SMA を使用します。
単独で使う EMA 平滑化 RSI については、references/indicators.md を参照してください。
WITH ohlc AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5s
),
changes AS (
SELECT ts, close,
close - LAG(close) OVER (ORDER BY ts) AS change
FROM ohlc
),
gains_losses AS (
SELECT ts, close,
CASE WHEN change > 0 THEN change ELSE 0 END AS gain,
CASE WHEN change < 0 THEN ABS(change) ELSE 0 END AS loss
FROM changes
),
avg_gl AS (
SELECT ts, close,
AVG(gain) OVER (ORDER BY ts
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_gain,
AVG(loss) OVER (ORDER BY ts
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_loss
FROM gains_losses
)
SELECT ts AS time,
CASE WHEN avg_loss = 0 THEN 100
ELSE 100 - (100 / (1 + avg_gain / NULLIF(avg_loss, 0)))
END AS rsi
FROM avg_gl;
注:EMA 平滑化 RSI(標準)を使うには、AVG...ROWS BETWEEN を
avg(gain, 'period', 14) OVER (ORDER BY ts)(QuestDB ネイティブ EMA)に置き換えます。
Wilder の平滑化(α=1/N)には、avg(gain, 'period', 27) を使用します。
VWAP + ボリンジャーバンドの組み合わせ(単一パネル、複数の系列):
WITH ohlc AS (
SELECT ts, symbol,
first(price) AS open, max(price) AS high,
min(price) AS low, last(price) AS close,
sum(amount) AS volume
FROM trades
WHERE $__timeFilter(ts) AND symbol = '$symbol'
SAMPLE BY 5s
),
indicators AS (
SELECT ts, close,
AVG(close) OVER (
ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS sma20,
AVG(close * close) OVER (
ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS avg_close_sq,
sum((high + low + close) / 3 * volume) OVER (ORDER BY ts CUMULATIVE)
/ sum(volume) OVER (ORDER BY ts CUMULATIVE) AS vwap
FROM ohlc
)
SELECT ts AS time, close, sma20,
sma20 + 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS upper_band,
sma20 - 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS lower_band,
vwap
FROM indicators;
API 経由でのダッシュボードのデプロイ
完全に動作するデプロイスクリプトです。このダッシュボード JSON はテスト済みで、正常に動作します — すべてのパネルで、構造を正確にコピーしてください。パネルを分割したり、並べ替えたりしないでください。
パネルレイアウト規則: VWAP、ボリンジャーバンド、RSI は、必ず OHLC ローソク足パネルに追加の refID(B、C、D)として重ねて表示し、includeAllFields: true を指定します。
パネルタイプは必ず candlestick にしてください。timeseries ではありません。
これらのインジケーター用に別個の timeseries パネルを作成するのは誤りです。ローソク足パネルに表示します。
クエリパターン: refId A は OHLC 列(open、high、low、close、volume)を返します。
refIds B、C、D はそれぞれ ts AS time とインジケーター列のみを返します。OHLC は返しません。
Grafana は共有の time 列を使ってこれらを重ねて表示します。インジケーターのクエリも OHLC 列を返すと、ローソク足の描画が崩れます。
オーバーライドは必須であり、見た目だけの設定ではありません。 RSI(0~100)を価格(約 97,000)と同じ Y 軸にすると、ローソク足が見えなくなります。スプレッド(約 0.01)をビッド/アスク(約 97,000)と同じ軸にすると、スプレッドの線が見えなくなります。テンプレートの fieldConfig.overrides 配列を正確にコピーしてください。特に、RSI の右軸(0~100%)とスプレッドの右軸は必ずコピーしてください。
ダッシュボードのデフォルト値(正確にコピー):
"refresh": "250ms"—"5s"ではありません。リアルタイムデータ向けに、意図的に 250ms 間隔で更新します。"liveNow": false"time": {"from": "now-5m", "to": "now"}"timepicker": {"refresh_intervals": ["250ms", "500ms", "1s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]}- URL を開く際は、末尾に
?refresh=250ms&from=now-5m&to=nowを追加します
重要:Grafana の min_refresh_interval のデフォルト値は 5s です。 Grafana の起動時に GF_DASHBOARDS_MIN_REFRESH_INTERVAL=250ms を設定しないと、サブ秒間隔(250ms、500ms、1s)での更新はサーバー側でブロックされます。この設定は、上記の実行シナリオにある Docker の実行コマンドですでに指定されています。
指定しない場合、ダッシュボード JSON の "refresh": "250ms" と URL の ?refresh=250ms は警告なしに無視され、ドロップダウンに 5 秒未満の選択肢が表示されません。
すべてのパネルクエリで使用する対象の構造:
{
"refId": "A",
"datasource": {"uid": "QUESTDB_UID", "type": "questdb-questdb-datasource"},
"format": 1,
"rawSql": "SELECT ts AS time, ... WHERE $__timeFilter(ts) ..."
}
重要: "format" は文字列 "table" ではなく、整数 1 でなければなりません。QuestDB Grafana プラグインは Go の整数 enum(sqlutil.FormatQueryOption)を使用します。
文字列値を指定すると、json: cannot unmarshal string into Go struct field Query.format が発生します。Grafana の JSON エクスポートでは "table"(文字列)と表示されますが、API の POST では 1(整数)が必要です。
import json, subprocess, sys, os, requests, time
QUESTDB_URL = "http://localhost:9000"
GRAFANA_URL = "http://localhost:3000"
GRAFANA_AUTH = ("admin", "admin")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# --- Start ingestion if not already running ---
already_running = False
try:
resp = requests.get(f"{QUESTDB_URL}/exec",
params={"query": "SELECT count() FROM trades WHERE ts > dateadd('s', -10, now())"})
already_running = resp.json()["dataset"][0][0] > 0
except Exception:
pass
if already_running:
print("Ingestion already running — skipping launch")
else:
ingest_log = open(os.path.join(SCRIPT_DIR, "ingest.log"), "w")
ingest_proc = subprocess.Popen(
[sys.executable, "-u", os.path.join(SCRIPT_DIR, "02_ingest.py")],
stdout=ingest_log, stderr=ingest_log,
start_new_session=True, # detach from parent — survives Ctrl+C and script exit
)
print(f"Ingestion started: PID {ingest_proc.pid}")
# --- Gate: wait for data before deploying dashboard ---
for i in range(15): # up to 15s
time.sleep(1)
if ingest_proc.poll() is not None:
ingest_log.close()
print("ERROR: Ingestion process died. Log:")
print(open(os.path.join(SCRIPT_DIR, "ingest.log")).read())
sys.exit(1)
try:
resp = requests.get(f"{QUESTDB_URL}/exec", params={"query": "SELECT count() FROM trades"})
count = resp.json()["dataset"][0][0]
if count > 0:
print(f"Data gate passed: {count} rows in trades (waited {i+1}s)")
break
except Exception:
pass
else:
print("ERROR: No data after 15s. Check ingest.log")
sys.exit(1)
# --- Create or find QuestDB datasource ---
# QuestDB plugin uses jsonData fields, NOT the standard url field.
# server = hostname only (no port, no protocol), port = integer, tlsMode = "disable"
ds_list = requests.get(f"{GRAFANA_URL}/api/datasources", auth=GRAFANA_AUTH).json()
existing = [d for d in ds_list if d["type"] == "questdb-questdb-datasource"]
if existing:
questdb_uid = existing[0]["uid"]
else:
resp = requests.post(f"{GRAFANA_URL}/api/datasources", auth=GRAFANA_AUTH,
json={
"name": "QuestDB", "type": "questdb-questdb-datasource", "access": "proxy",
"jsonData": {
"server": "host.docker.internal",
"port": 8812,
"username": "admin",
"tlsMode": "disable",
"timeout": "120",
"queryTimeout": "60",
},
"secureJsonData": {"password": "quest"},
})
questdb_uid = resp.json()["datasource"]["uid"]
DS_REF = {"uid": questdb_uid, "type": "questdb-questdb-datasource"}
dashboard = {
"dashboard": {
"title": "Crypto Real-Time Market Data",
"uid": "crypto-realtime",
"timezone": "browser",
"refresh": "250ms", # Default refresh rate — do NOT change to 5s
"liveNow": False,
"schemaVersion": 38,
"time": {"from": "now-5m", "to": "now"},
"timepicker": {
"refresh_intervals": ["250ms", "500ms", "1s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
},
"tags": ["crypto", "questdb", "realtime"],
"templating": {"list": [{
"name": "symbol", "type": "query", "label": "Symbol",
"query": "SELECT DISTINCT symbol FROM trades ORDER BY symbol;",
"datasource": DS_REF,
"refresh": 1, "sort": 1,
"current": {"text": "BTC-USDT", "value": "BTC-USDT"},
}]},
"panels": [
{
"id": 1, "type": "candlestick",
"title": "OHLC - $symbol",
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 0},
"datasource": DS_REF,
"fieldConfig": {
"defaults": {"custom": {"axisBorderShow": False, "axisPlacement": "auto"}},
"overrides": [{"matcher": {"id": "byName", "options": "volume"},
"properties": [{"id": "custom.axisPlacement", "value": "hidden"}]}],
},
"options": {
"mode": "candles+volume", "includeAllFields": False,
"candleStyle": "candles", "colorStrategy": "open-close",
"colors": {"up": "green", "down": "red"},
"fields": {"open": "open", "high": "high", "low": "low",
"close": "close", "volume": "volume"},
},
"targets": [{
"refId": "A", "datasource": DS_REF, "format": 1,
"rawSql": "SELECT ts AS time, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close, sum(amount) AS volume FROM trades WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s;",
}],
},
{
"id": 2, "type": "candlestick",
"title": "OHLC + Indicators - $symbol",
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 10},
"datasource": DS_REF,
"fieldConfig": {
"defaults": {"custom": {"axisBorderShow": False, "axisPlacement": "auto"}},
# CRITICAL: these overrides are NOT cosmetic — without them:
# - RSI (0-100) shares Y-axis with price (~97000) → candlesticks appear flat
# - Volume distorts the price axis
# - Bollinger bands have no visual distinction from price lines
# Copy ALL overrides below exactly. NEVER use "overrides": []
"overrides": [
{"matcher": {"id": "byName", "options": "volume"},
"properties": [{"id": "custom.axisPlacement", "value": "hidden"}]},
{"matcher": {"id": "byName", "options": "vwap"},
"properties": [{"id": "color", "value": {"fixedColor": "orange", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2}]},
{"matcher": {"id": "byName", "options": "sma20"},
"properties": [{"id": "color", "value": {"fixedColor": "yellow", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2}]},
{"matcher": {"id": "byName", "options": "upper_band"},
"properties": [{"id": "color", "value": {"fixedColor": "light-blue", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 10]}},
{"id": "custom.fillBelowTo", "value": "lower_band"},
{"id": "custom.fillOpacity", "value": 8}]},
{"matcher": {"id": "byName", "options": "lower_band"},
"properties": [{"id": "color", "value": {"fixedColor": "light-blue", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 10]}}]},
{"matcher": {"id": "byFrameRefID", "options": "D"},
"properties": [{"id": "color", "value": {"fixedColor": "purple", "mode": "fixed"}},
{"id": "custom.axisPlacement", "value": "right"},
{"id": "min", "value": 0}, {"id": "max", "value": 100},
{"id": "unit", "value": "percent"}]},
],
},
"options": {
"mode": "candles+volume", "includeAllFields": True,
"candleStyle": "candles", "colorStrategy": "open-close",
"colors": {"up": "green", "down": "red"},
"fields": {"open": "open", "high": "high", "low": "low",
"close": "close", "volume": "volume"},
},
# --- Overlay pattern: refId A = OHLC columns, B/C/D = one indicator each ---
# A MUST return: open, high, low, close, volume (for the candlestick)
# B/C/D MUST return: ts AS time + indicator columns ONLY (not OHLC)
# Grafana overlays B/C/D on A using the shared time column
"targets": [
{ # A: OHLC + volume — the candlestick data
"refId": "A", "datasource": DS_REF, "format": 1,
"rawSql": "SELECT ts AS time, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close, sum(amount) AS volume FROM trades WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s;",
},
{ # B: VWAP only — overlays on candlestick
"refId": "B", "datasource": DS_REF, "format": 1,
"rawSql": "WITH ohlc AS (SELECT ts, symbol, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close, sum(amount) AS volume FROM trades WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s), vwap AS (SELECT ts, sum((high + low + close) / 3 * volume) OVER (ORDER BY ts CUMULATIVE) / sum(volume) OVER (ORDER BY ts CUMULATIVE) AS vwap FROM ohlc) SELECT ts AS time, vwap FROM vwap;",
},
{ # C: Bollinger Bands only (sma20, upper_band, lower_band)
"refId": "C", "datasource": DS_REF, "format": 1,
"rawSql": "WITH ohlc AS (SELECT ts, symbol, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close, sum(amount) AS volume FROM trades WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s), stats AS (SELECT ts, close, AVG(close) OVER (ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS sma20, AVG(close * close) OVER (ORDER BY ts ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS avg_close_sq FROM ohlc) SELECT ts AS time, sma20, sma20 + 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS upper_band, sma20 - 2 * sqrt(avg_close_sq - (sma20 * sma20)) AS lower_band FROM stats;",
},
{ # D: RSI only — right Y-axis 0-100%
"refId": "D", "datasource": DS_REF, "format": 1,
"rawSql": "WITH ohlc AS (SELECT ts, symbol, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close, sum(amount) AS volume FROM trades WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s), changes AS (SELECT ts, close, close - LAG(close) OVER (ORDER BY ts) AS change FROM ohlc), gains_losses AS (SELECT ts, close, CASE WHEN change > 0 THEN change ELSE 0 END AS gain, CASE WHEN change < 0 THEN ABS(change) ELSE 0 END AS loss FROM changes), avg_gl AS (SELECT ts, close, AVG(gain) OVER (ORDER BY ts ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_gain, AVG(loss) OVER (ORDER BY ts ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_loss FROM gains_losses) SELECT ts AS time, CASE WHEN avg_loss = 0 THEN 100 ELSE 100 - (100 / (1 + avg_gain / NULLIF(avg_loss, 0))) END AS rsi FROM avg_gl;",
},
],
},
{
"id": 3, "type": "timeseries",
"title": "Bid-Ask Spread - $symbol",
"gridPos": {"h": 6, "w": 24, "x": 0, "y": 20},
"datasource": DS_REF,
# CRITICAL: overrides REQUIRED — without them spread (~0.01) is invisible
# next to bid/ask (~97000). Spread MUST use right Y-axis.
"fieldConfig": {
"defaults": {"custom": {"lineWidth": 1, "fillOpacity": 15, "spanNulls": True, "pointSize": 1}},
"overrides": [
{"matcher": {"id": "byName", "options": "spread"},
"properties": [{"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}},
{"id": "custom.axisPlacement", "value": "right"}]},
{"matcher": {"id": "byName", "options": "best_bid"},
"properties": [{"id": "color", "value": {"fixedColor": "green", "mode": "fixed"}}]},
{"matcher": {"id": "byName", "options": "best_ask"},
"properties": [{"id": "color", "value": {"fixedColor": "orange", "mode": "fixed"}}]},
],
},
"targets": [{
"refId": "A", "datasource": DS_REF, "format": 1,
"rawSql": "SELECT ts AS time, avg(ask_prices[1] - bid_prices[1]) AS spread, avg(bid_prices[1]) AS best_bid, avg(ask_prices[1]) AS best_ask FROM orderbook WHERE $__timeFilter(ts) AND symbol = '$symbol' SAMPLE BY 5s;",
}],
},
],
},
"overwrite": True,
}
resp = requests.post(f"{GRAFANA_URL}/api/dashboards/db", auth=GRAFANA_AUTH,
headers={"Content-Type": "application/json"}, json=dashboard)
url = f"{GRAFANA_URL}{resp.json().get('url', '')}?refresh=250ms&from=now-5m&to=now"
print(f"Dashboard: {resp.status_code} - {url}")
# Open in browser — data gate already confirmed rows are flowing
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", url])
データソースは必ず UID とタイプを使って参照し、表示名では参照しないでください。
追加の sleep コマンドを追加しないでください。待機処理はデータゲートのループがすべて行います。