QuestDB สำหรับการซื้อขายแบบอัลกอริทึม: ส่วนขยาย SQL ที่เปลี่ยนกฎของเกม
ตอนที่ 2 จาก 3 — อ่านได้ในภาษา RU · ZH
ข้อจำกัดความรับผิดชอบ: ข้อมูลในบทความนี้มีวัตถุประสงค์เพื่อการศึกษาและให้ข้อมูลเท่านั้น ไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการซื้อขาย การซื้อขายสกุลเงินดิจิทัลมีความเสี่ยงในการสูญเสียอย่างมีนัยสำคัญ
ยินดีต้อนรับสู่ตอนที่ 2 ของซีรีส์ QuestDB ของเรา ใน ตอนที่ 1 เราได้ครอบคลุมสถาปัตยกรรมการจัดเก็บข้อมูลสามชั้นและหลักการออกแบบ schema แล้ว ตอนนี้เราจะเข้าสู่ชุดฟีเจอร์ที่สร้างความแตกต่างอย่างแท้จริง — ส่วนขยาย SQL ที่ทำให้ QuestDB รู้สึกราวกับว่าถูกออกแบบโดยนักเทรด เพื่อนักเทรด
SQL มาตรฐานถูกสร้างขึ้นในช่วงทศวรรษ 1970 สำหรับข้อมูลเชิงสัมพันธ์ มันไม่รู้จักเวลาในฐานะแนวคิดหลัก การดำเนินการ time-series ทุกครั้งใน PostgreSQL หรือ MySQL ต้องการ workaround ที่ยืดยาว — window functions, lateral joins, CTE ซ้อนกันสามชั้น ส่วนขยายของ QuestDB บีบอัด query หลายย่อหน้าเหล่านี้ให้เป็นคำสั่งเดียวที่สื่อความหมายชัดเจน
มาดูทีละตัวพร้อมตัวอย่างการซื้อขายจริง

SAMPLE BY: การรวมข้อมูลตามช่วงเวลาแบบ Native
ถ้ามี query หนึ่งที่ทุกระบบการซื้อขายรันบ่อยกว่าสิ่งอื่นใด นั่นคือการรวมข้อมูล OHLCV — การแปลง raw trades เป็นข้อมูลแท่งเทียน ใน SQL มาตรฐาน คุณจะเขียนบางอย่างเช่น:
-- Standard SQL: verbose and slow
SELECT
date_trunc('minute', timestamp) AS bucket,
symbol,
(array_agg(price ORDER BY timestamp))[1] AS open,
max(price) AS high,
min(price) AS low,
(array_agg(price ORDER BY timestamp DESC))[1] AS close,
sum(quantity) AS volume
FROM trades
WHERE timestamp >= now() - interval '1 hour'
GROUP BY bucket, symbol
ORDER BY bucket;
ใน QuestDB จะกลายเป็น:
SELECT timestamp, symbol,
first(price) AS open,
max(price) AS high,
min(price) AS low,
last(price) AS close,
sum(quantity) AS volume
FROM trades
WHERE timestamp IN today()
SAMPLE BY 1m;
แค่นั้นเอง SAMPLE BY 1m บอก QuestDB ให้แบ่งข้อมูลเป็นช่วง 1 นาที และ first() / last() เป็นฟังก์ชันการรวมข้อมูลแบบ native ที่เคารพลำดับเวลาภายในแต่ละช่วง ไม่มี date_trunc, ไม่มีกายกรรม array_agg, ไม่มี GROUP BY แบบชัดเจน
ช่วงเวลาที่ใช้ได้มีความยืดหยุ่น: 1s, 5s, 15m, 1h, 1d, 7d — ผสมผสานหน่วยเวลาได้ทุกแบบ สำหรับตลาด crypto ที่ไม่เคยหลับ คุณสามารถจัดแนวตามขอบเขตปฏิทินพร้อมรับรู้ timezone:
SAMPLE BY 1d ALIGN TO CALENDAR TIME ZONE 'UTC';
FILL: การจัดการกับช่องว่างในข้อมูล
ตลาดจริงมีช่องว่าง — คู่ที่มี liquidity ต่ำอาจไม่มีการซื้อขายเป็นเวลานาทีหรือชั่วโมง SAMPLE BY รองรับกลยุทธ์ FILL หลายแบบ:
-- Fill with previous value (forward fill — common in finance)
SAMPLE BY 15m FILL(PREV);
-- Fill with linear interpolation
SAMPLE BY 15m FILL(LINEAR);
-- Fill with a constant
SAMPLE BY 15m FILL(0);
-- No fill — return NULL (default)
SAMPLE BY 15m FILL(NONE);
FILL(PREV) คือวิธีหลักสำหรับ dashboard การซื้อขาย — ถ้าไม่มีการซื้อขายในช่วง 15 นาที ให้นำราคาล่าสุดที่ทราบมาใช้ต่อ FILL(LINEAR) เหมาะกว่าสำหรับสัญญาณต่อเนื่อง เช่น funding rate หรืออัตราดอกเบี้ย

ASOF JOIN: "ทำในสิ่งที่ฉันหมาย" สำหรับการจัดแนวข้อมูลตลาด
นี่คือมงกุฎแห่งอัญมณีของ QuestDB และถ้าคุณเคยทำงานกับข้อมูลตลาด คุณจะเข้าใจทันทีว่าทำไม
ปัญหาพื้นฐาน: คุณมี trades ในตารางหนึ่งและ quotes (bid/ask) ในอีกตารางหนึ่ง คุณต้องการรู้ว่า quote ที่มีผลในขณะที่แต่ละ trade ถูกดำเนินการคืออะไร ใน database ทั่วไป timestamp แทบจะไม่เรียงตรงกันพอดี — trade ที่ 12:00:00.123 ต้องจับคู่กับ quote ที่ 12:00:00.098 ไม่ใช่ 12:00:00.201
ASOF JOIN แก้ปัญหานี้ในบรรทัดเดียว:
SELECT trades.*, quotes.bid, quotes.ask
FROM trades
ASOF JOIN quotes ON (symbol);
สำหรับแต่ละแถวใน trades QuestDB จะหาแถวใน quotes ที่มี timestamp ล่าสุดที่ น้อยกว่าหรือเท่ากับ timestamp ของ trade โดยจับคู่กับคอลัมน์ symbol ไม่มี correlated subqueries ไม่มี window functions ไม่มี logic ในระดับ application
นี่คือรากฐานของ Transaction Cost Analysis (TCA) — การเปรียบเทียบราคาที่คุณดำเนินการกับตลาดที่มีผลในขณะที่ดำเนินการ ใน PostgreSQL ต้องใช้ LATERAL JOIN พร้อม ORDER BY และ LIMIT 1 สำหรับ แต่ละแถว ซึ่งช้ากว่าหลายเท่าบนชุดข้อมูลขนาดใหญ่
TOLERANCE: การป้องกัน Join ข้อมูลเก่า
นี่คือความละเอียดอ่อนที่แยกการใช้งานเพื่อทดลองออกจากระบบ production จริง จะเกิดอะไรขึ้นถ้า quote สำหรับสินทรัพย์ที่ซื้อขายน้อยมีอายุ 5 นาทีในขณะที่มี trade? ในตลาดที่ผันผวน quote ที่เก่า 5 นาทีนั้นแทบจะไม่มีประโยชน์ ASOF JOIN แบบปกติก็ยังจะใช้มัน — มันหา การจับคู่ล่าสุด ไม่ว่าจะเก่าแค่ไหน
clause TOLERANCE ของ QuestDB แก้ปัญหานี้:
SELECT trades.*, quotes.bid, quotes.ask
FROM trades
ASOF JOIN quotes ON (symbol)
TOLERANCE 1s;
ตอนนี้ ถ้าไม่มี quote ที่ตรงกันภายใน 1 วินาทีของ trade join จะคืนค่า NULL แทนข้อมูลเก่า นี่เป็นสิ่งสำคัญสำหรับ TCA ที่แม่นยำและสำหรับ analytics ใดก็ตามที่ความสดของข้อมูลสำคัญ
เป็นโบนัส TOLERANCE สามารถปรับปรุงประสิทธิภาพ query อย่างมีนัยสำคัญ หากไม่มี engine อาจสแกนย้อนกลับไปไกลในตาราง quotes เพื่อหาการจับคู่ ด้วย TOLERANCE มันสามารถยุติการสแกนย้อนหลังได้เร็วขึ้นเมื่อ records เก่าเกินไปที่จะผ่านเงื่อนไข
LT JOIN และ SPLICE JOIN
มีสองรูปแบบที่ควรรู้: LT JOIN เหมือน ASOF JOIN แต่จับคู่ ก่อน timestamp อย่างเคร่งครัด (ไม่เท่ากัน) สิ่งนี้มีประโยชน์เมื่อคุณต้องการหลีกเลี่ยงอคติ look-ahead ในการทดสอบย้อนหลัง — คุณต้องการ quote ที่มีอยู่ ก่อน การซื้อขายของคุณ ไม่ใช่ที่มาถึงในไมโครวินาทีเดียวกัน
SPLICE JOIN เป็น ASOF เต็มรูปแบบในสองทิศทาง: สำหรับแต่ละ record ในตารางซ้าย มันหา record ในตารางขวาที่มีผล และสำหรับแต่ละ record ในตารางขวา มันหา record ในตารางซ้ายที่มีผล ผลลัพธ์คือ timeline รวมที่สลับกันของทั้งสองแหล่งข้อมูล สิ่งนี้มีประโยชน์อย่างยิ่งสำหรับการสร้าง event timeline รวมจากหลาย data streams
HORIZON JOIN: การวิเคราะห์หลัง Trade ในการ Query เดียว
เปิดตัวใน QuestDB 9.3.3, HORIZON JOIN ถูกสร้างขึ้นโดยเฉพาะสำหรับการวิเคราะห์ markout — รากฐานของการประเมินคุณภาพการดำเนินการและการวิจัยโครงสร้างจุลภาคตลาด
คำถามที่มันตอบ: "หลังจาก trade ถูกดำเนินการ ราคาเปลี่ยนแปลงอย่างไรใน N วินาทีถัดไป?" ตามปกติต้องใช้ self-joins, UNION ALL ใน ASOF queries หลายรายการ หรือส่ง logic ไปยัง application code HORIZON JOIN บีบอัดทั้งหมดนี้:
SELECT h.offset / 1000000 AS offset_sec,
avg(mid.price - fill.price) AS avg_markout
FROM fills
HORIZON JOIN mid_prices ON (symbol)
RANGE BETWEEN 0 AND 60s STEP 1s;
สิ่งนี้คำนวณการเคลื่อนไหวของราคาเฉลี่ยที่ช่วง 1 วินาทีสูงสุด 60 วินาทีหลังจากแต่ละ fill engine จัดการการคำนวณ offset เวลา การจับคู่ ASOF ที่แต่ละจุด horizon และการรวมข้อมูล — ทั้งหมดในรอบเดียว
สำหรับ horizons ที่ไม่สม่ำเสมอ — หรือเพื่อดู ก่อน event — ใช้ syntax LIST:
HORIZON JOIN mid_prices ON (symbol)
LIST (-5s, -1s, 0, 1s, 5s, 30s, 60s);
สิ่งนี้ให้คุณทั้ง markout curves ก่อนและหลัง trade รวมกับการกรองตาม venue, กลยุทธ์ หรือขนาด order คุณสามารถสร้างกรอบการประเมินคุณภาพการดำเนินการที่สมบูรณ์ภายใน database
cookbook ของ QuestDB มีรูปแบบการวิเคราะห์หลัง trade ห้าแบบที่สร้างบน HORIZON JOIN: การวิเคราะห์ slippage (การดำเนินการเทียบกับราคากลาง), markout curves, implementation shortfall (การแยกส่วน Perold), venue scoring สำหรับ smart order routing และการตรวจจับ flow toxicity (VPIN) แต่ละแบบรันบน live demo ของพวกเขาด้วยข้อมูลจริง
WINDOW JOIN: การเชื่อมโยง Events กับข้อมูลโดยรอบ
WINDOW JOIN ถูกเปิดตัวใน QuestDB 9.3 ช่วยให้แต่ละแถวจากตารางหลักสามารถ join กับช่วงเวลาของแถวจากอีกตารางหนึ่ง พร้อมการรวมข้อมูลที่คำนวณจากแถวที่จับคู่
พิจารณาสถานการณ์การซื้อขาย FX ที่คุณต้องการเชื่อมโยงแต่ละ trade กับราคา bid และ ask เฉลี่ยใน 10 วินาทีหลังจาก trade:
SELECT
trades.timestamp,
trades.symbol,
trades.price,
avg(quotes.bid) AS avg_bid,
avg(quotes.ask) AS avg_ask
FROM trades
WINDOW JOIN quotes ON (symbol)
RANGE BETWEEN 0 AND 10s FOLLOWING
INCLUDE PREVAILING;
clause INCLUDE PREVAILING ช่วยให้คุณได้ราคาล่าสุดแม้ไม่มีการจับคู่ที่แน่นอนที่ขอบเขต window สิ่งนี้ขจัด subqueries หลายหน้าที่ analytics ระยะสั้นมักต้องการ
กรณีการใช้งานในการซื้อขาย: การคำนวณสภาพตลาดเฉลี่ยรอบแต่ละการดำเนินการ, การตรวจจับพฤติกรรมราคาที่ผิดปกติในช่วงเวลาก่อน order ขนาดใหญ่, การเชื่อมโยง IoT/infrastructure events (network latency spikes) กับคุณภาพการดำเนินการ
LATEST ON: สถานะปัจจุบันทันที
ส่วนขยายที่ดูเรียบง่ายแต่มีประโยชน์อย่างไม่น่าเชื่อ LATEST ON คืนค่าแถวสุดท้ายสำหรับแต่ละค่าของคอลัมน์ที่แบ่งพาร์ติชัน:
SELECT * FROM trades
LATEST ON timestamp PARTITION BY symbol, side
ORDER BY timestamp DESC;
สิ่งนี้ให้คุณ trade ล่าสุดสำหรับแต่ละคู่ (symbol, side) — โดยพื้นฐานคือ snapshot "สถานะปัจจุบัน" แบบ real-time ใน database ทั่วไป ต้องใช้ correlated subquery หรือ window function พร้อม ROW_NUMBER()
สำหรับ dashboard การซื้อขายที่แสดงราคาล่าสุดในหลายร้อย symbols, LATEST ON ทำงานได้ทันที รวมกับ materialized views (ซึ่งเราจะครอบคลุมในตอนที่ 3) มันกลายเป็นรากฐานสำหรับ portfolio snapshots แบบ sub-millisecond
TWAP: ค่าเฉลี่ยถ่วงน้ำหนักตามเวลาแบบ Native
aggregate twap(price, timestamp) ถูกเพิ่มใน QuestDB 9.3.3 ต่างจาก VWAP ที่ถ่วงน้ำหนักตามปริมาณ TWAP ถ่วงน้ำหนักตามระยะเวลา — แต่ละราคาคงอยู่จนถึงการสังเกตการณ์ครั้งถัดไป และผลลัพธ์คือพื้นที่ใต้ step function หารด้วยเวลาทั้งหมด
SELECT symbol,
twap(price, timestamp) AS twap_value,
vwap(price, quantity) AS vwap_value
FROM trades
WHERE timestamp IN today()
SAMPLE BY 1h;
TWAP คือ benchmark การดำเนินการมาตรฐานสำหรับ algorithmic orders การมีเป็น native aggregate ที่รองรับ parallel GROUP BY และ SAMPLE BY พร้อม FILL modes ทั้งหมดหมายความว่าคุณไม่ต้องการ integration ฝั่ง client — การคำนวณรันทั้งหมดภายใน query engine
Window Functions: รากฐานของการวิเคราะห์ทางเทคนิค
QuestDB รองรับ SQL window functions มาตรฐาน ซึ่งเป็นกระดูกสันหลังของการคำนวณ technical indicators:
-- Bollinger Bands using materialized OHLC view
WITH stats AS (
SELECT timestamp, close,
AVG(close) OVER (
ORDER BY timestamp
ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS sma20,
AVG(close * close) OVER (
ORDER BY timestamp
ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS avg_close_sq
FROM trades_OHLC_15m
WHERE timestamp BETWEEN dateadd('h', -24, now()) AND now()
AND symbol = 'BTC-USDT'
)
SELECT timestamp,
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;
QuestDB 9.3.3 ยังได้เปิดตัว clause WINDOW ตามมาตรฐาน SQL — กำหนด window specification ครั้งเดียว อ้างอิงตามชื่อในหลายฟังก์ชัน ไม่ต้องทำซ้ำ PARTITION BY และ ORDER BY เดิมในทุก expression อีกต่อไป:
SELECT timestamp, symbol,
avg(price) OVER w AS avg_price,
stddev(price) OVER w AS std_price,
min(price) OVER w AS min_price,
max(price) OVER w AS max_price
FROM trades
WINDOW w AS (PARTITION BY symbol ORDER BY timestamp ROWS BETWEEN 99 PRECEDING AND CURRENT ROW);
Query ที่สะอาดกว่า ลด copy-paste ประสิทธิภาพการดำเนินการเท่าเดิม
รูปแบบ Query ในโลกจริง
มาดูรูปแบบบางอย่างที่เกิดขึ้นตลอดในระบบการซื้อขาย production:
Correlation ข้ามสินทรัพย์
-- Rolling hourly and daily correlation between ETH and another asset
WITH data AS (
SELECT ETHUSD.timestamp,
corr(ETHUSD.price, asset.price) AS corr
FROM ETHUSD
ASOF JOIN asset
SAMPLE BY 1m
)
SELECT timestamp,
avg(corr) OVER (ORDER BY timestamp
RANGE BETWEEN 1 HOUR PRECEDING AND CURRENT ROW) AS hourly_corr,
avg(corr) OVER (ORDER BY timestamp
RANGE BETWEEN 24 HOUR PRECEDING AND CURRENT ROW) AS daily_corr
FROM data;
RSI ทั่วทุก Symbols
-- 14-day RSI for all USDT pairs
WITH gains_losses AS (
SELECT timestamp, symbol,
CASE WHEN close > lag(close) OVER (PARTITION BY symbol ORDER BY timestamp)
THEN close - lag(close) OVER (PARTITION BY symbol ORDER BY timestamp)
ELSE 0 END AS gain,
CASE WHEN close < lag(close) OVER (PARTITION BY symbol ORDER BY timestamp)
THEN lag(close) OVER (PARTITION BY symbol ORDER BY timestamp) - close
ELSE 0 END AS loss
FROM trades_latest_1d
WHERE symbol LIKE '%-USDT'
)
SELECT timestamp, symbol,
100 - 100 / (1 + avg_gain / NULLIF(avg_loss, 0)) AS rsi
FROM (
SELECT timestamp, symbol,
AVG(gain) OVER (PARTITION BY symbol ORDER BY timestamp
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_gain,
AVG(loss) OVER (PARTITION BY symbol ORDER BY timestamp
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS avg_loss
FROM gains_losses
);
ATR (Average True Range)
-- 14-period ATR from 15-minute OHLC bars
WITH tr AS (
SELECT timestamp, symbol,
GREATEST(
high - low,
ABS(high - lag(close) OVER (PARTITION BY symbol ORDER BY timestamp)),
ABS(low - lag(close) OVER (PARTITION BY symbol ORDER BY timestamp))
) AS true_range
FROM trades_OHLC_15m
)
SELECT timestamp, symbol,
AVG(true_range) OVER (PARTITION BY symbol ORDER BY timestamp
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS atr_14
FROM tr;
ข้อได้เปรียบของ SQL เหนือ Code แบบกำหนดเอง
คุณอาจสงสัย: ทำไมต้องคำนวณ indicators เหล่านี้ภายใน database ทั้งที่คุณสามารถดึง raw data ไปยัง Python และใช้ ta-lib หรือ pandas?
มีสามเหตุผล ประการแรก data locality — การย้าย tick data หลาย terabyte ผ่าน network เพื่อคำนวณ rolling average นั้นสิ้นเปลือง การคำนวณควรอยู่ที่ที่ข้อมูลอยู่ ประการที่สอง parallelism — vectorized engine ของ QuestDB พร้อม JIT compilation สามารถประมวลผล query เหล่านี้ใน multiple cores ด้วยคำสั่ง SIMD มักเร็วกว่า Python แบบ single-threaded ประการที่สาม consistency — เมื่อ dashboards, strategies และระบบ monitoring หลายอย่างต้องการ indicators เดียวกัน การมีแหล่งความจริงเดียวใน database ขจัด synchronization bugs
อย่างไรก็ตาม QuestDB ไม่ได้พยายามแทนที่ analytics stack ทั้งหมดของคุณ Parquet interop หมายความว่าคุณยังสามารถดึงข้อมูลประวัติศาสตร์ไปยัง ML pipeline ของคุณโดยตรงโดยไม่ต้องผ่าน database สำหรับ batch workloads
สิ่งที่จะมาในตอนที่ 3
ใน ตอนสุดท้าย เราจะครอบคลุม materialized views สำหรับ OHLC แบบ real-time (รวมถึง cascaded views ที่หลาย timeframes), 2D arrays สำหรับ native order book analytics และสถาปัตยกรรมอ้างอิงสำหรับแพลตฟอร์มการซื้อขายแบบอัลกอริทึมที่ขับเคลื่อนด้วย QuestDB อย่างสมบูรณ์
การอ้างอิง
@software{soloviov2025questdb_algotrading_p2,
author = {Soloviov, Eugen},
title = {QuestDB for Algorithmic Trading: SQL Extensions That Change the Game},
year = {2025},
url = {https://marketmaker.cc/th/blog/post/questdb-algotrading-sql},
version = {0.1.0},
description = {เจาะลึก SQL extensions ของ QuestDB สำหรับ time-series: SAMPLE BY, ASOF JOIN, HORIZON JOIN, WINDOW JOIN, LATEST ON และรูปแบบ query สำหรับการซื้อขายจริง}
}
ผู้เขียน
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.