QuestDB 算法交易实战:改变游戏规则的 SQL 扩展

MarketMaker.cc Team
量化研究与策略

MarketMaker.cc Team
量化研究与策略
免责声明:本文内容仅供教育和参考目的,不构成任何财务、投资或交易建议。加密货币交易存在重大亏损风险。
欢迎来到 QuestDB 系列的第 2 篇。在第 1 篇中,我们介绍了三层存储架构和模式设计原则。现在我们将深入探讨真正令 QuestDB 与众不同的特性集——那些让 QuestDB 感觉像是由交易员为交易员设计的 SQL 扩展。
标准 SQL 诞生于 20 世纪 70 年代,为关系型数据而生。它对时间作为一等公民的概念一无所知。在 PostgreSQL 或 MySQL 中进行每一次时序操作都需要冗长的变通方案——窗口函数、侧向连接、层层叠叠的 CTE。QuestDB 的扩展将这些多段式查询压缩为单一的、富有表现力的语句。
让我们通过真实的交易示例逐一了解每个扩展。
如果有一种查询是每个交易系统运行最频繁的,那就是 OHLCV 聚合——将原始成交数据转化为 K 线数据。在标准 SQL 中,你需要这样写:
-- 标准 SQL:冗长且缓慢
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() 是原生聚合函数,在每个桶内尊重时间排序。无需 date_trunc,无需 array_agg 变通,无需显式 GROUP BY。
可用的时间间隔非常灵活:1s、5s、15m、1h、1d、7d——任意时间单位的组合。对于永不停歇的加密市场,你可以通过时区感知对齐到日历边界:
SAMPLE BY 1d ALIGN TO CALENDAR TIME ZONE 'UTC';
真实市场存在缺口——低流动性的交易对可能数分钟甚至数小时都没有成交。SAMPLE BY 支持多种 FILL 策略:
-- 用前值填充(前向填充——金融领域的常规做法)
SAMPLE BY 15m FILL(PREV);
-- 用线性插值填充
SAMPLE BY 15m FILL(LINEAR);
-- 用常量填充
SAMPLE BY 15m FILL(0);
-- 不填充——返回 NULL(默认)
SAMPLE BY 15m FILL(NONE);
FILL(PREV) 是交易仪表板的标配——如果某个 15 分钟桶内没有成交,则沿用最后已知价格。FILL(LINEAR) 更适合资金费率或利率等连续型信号。
这是 QuestDB 的镇店之宝,如果你曾经处理过行情数据,你将立刻明白其价值所在。
根本问题在于:你在一张表里有成交数据,在另一张表里有报价数据(买卖盘)。你想知道每笔成交执行时市场上的现行报价是多少。在普通数据库中,时间戳几乎从不完全对齐——12:00:00.123 的成交需要匹配 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 中找到时间戳小于或等于成交时间戳的最新行,并按 symbol 列进行匹配。没有关联子查询,没有窗口函数,没有应用层逻辑。
这是交易成本分析(TCA)的基础——将你的执行价格与执行时的市场现行价格进行比较。在 PostgreSQL 中,等价实现需要对每一行进行带 ORDER BY 和 LIMIT 1 的 LATERAL JOIN,在大数据集上性能相差几个数量级。
这是区分玩具实现与生产系统的细节。如果一个成交量稀少资产的报价在成交时已经过时 5 分钟怎么办?在波动市场中,5 分钟前的报价基本上是垃圾数据。默认的 ASOF JOIN 仍然会使用它——它找到最新的匹配,无论多么陈旧。
QuestDB 的 TOLERANCE 子句解决了这个问题:
SELECT trades.*, quotes.bid, quotes.ask
FROM trades
ASOF JOIN quotes ON (symbol)
TOLERANCE 1s;
现在,如果在成交时间 1 秒内不存在匹配报价,连接将返回 NULL 而非陈旧数据。这对于精确的 TCA 以及任何数据新鲜度至关重要的分析场景都是关键所在。
额外的好处是,TOLERANCE 还能显著提升查询性能。没有它时,引擎可能会深入扫描报价表寻找匹配项。有了 TOLERANCE,一旦记录太旧不符合条件,它就可以提前终止向后扫描。
两个值得了解的变体:LT JOIN 类似 ASOF JOIN,但匹配时间戳严格早于(而非等于)的记录。在回测中需要避免前瞻偏差时非常有用——你需要的是成交之前存在的报价,而非同一微秒到达的报价。
SPLICE JOIN 是双向的完整 ASOF:对左表的每条记录,找到当时最新的右表记录;对右表的每条记录,也找到当时最新的左表记录。结果是两个数据源交错融合的统一时间线。这对于从多个数据流创建统一事件时间线特别有用。
HORIZON JOIN 于 QuestDB 9.3.3 引入,专为标记分析(markout analysis)而生——这是执行质量评估和市场微观结构研究的基石。
它回答的问题是:"成交执行后,价格在接下来的 N 秒内是如何演变的?"传统上,这需要自连接、跨多个 ASOF 查询的 UNION ALL,或者将逻辑推送至应用层代码。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;
这在每笔成交后最多 60 秒、以 1 秒为间隔计算平均价格变动。引擎负责处理时间偏移计算、每个时间点的 ASOF 匹配以及聚合——全部在单次扫描中完成。
对于非均匀时间点,或者需要查看事件之前的情况,可以使用 LIST 语法:
HORIZON JOIN mid_prices ON (symbol)
LIST (-5s, -1s, 0, 1s, 5s, 30s, 60s);
这同时给出了交易前后的标记曲线。结合按交易场所、策略或订单规模进行过滤,你可以在数据库内部构建完整的执行质量分析框架。
QuestDB 的手册中包含了五种基于 HORIZON JOIN 的交易后分析模式:滑点分析(执行价格 vs. 中间价)、标记曲线、实施差异(Perold 分解)、用于智能订单路由的交易场所评分,以及流量毒性检测(VPIN)。每种模式都可以在其实时演示中用真实数据运行。
WINDOW JOIN 于 QuestDB 9.3 引入。它允许主表的每一行与另一张表的时间窗口内的行进行连接,并对匹配的行计算聚合。
考虑一个外汇交易场景,你想将每笔成交与成交后 10 秒内的平均买卖盘价格关联起来:
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;
INCLUDE PREVAILING 子句确保即使窗口边界处没有精确匹配,你也能获取最新价格。这消除了短周期分析通常需要的大量子查询。
在交易中的应用场景:计算每次执行前后的平均市场状况、检测大额订单前某时间窗口内的异常价格行为、将 IoT/基础设施事件(网络延迟峰值)与执行质量相关联。
这是一个看似简单却非常实用的扩展。LATEST ON 返回每个分区列值对应的最后一行:
SELECT * FROM trades
LATEST ON timestamp PARTITION BY symbol, side
ORDER BY timestamp DESC;
这给出了每个(symbol, side)组合的最新成交——本质上是一个实时的"当前状态"快照。在传统数据库中,这需要一个关联子查询或带 ROW_NUMBER() 的窗口函数。
对于显示数百个交易对最新价格的交易仪表板,LATEST ON 的执行几乎是瞬时的。结合物化视图(我们将在第 3 篇介绍),它成为亚毫秒级投资组合快照的基础。
twap(price, timestamp) 聚合函数在 QuestDB 9.3.3 中加入。与按成交量加权的 VWAP 不同,TWAP 按时间长度加权——每个价格持续到下一次观测,结果是阶梯函数下面积除以总时间。
SELECT symbol,
twap(price, timestamp) AS twap_value,
vwap(price, quantity) AS vwap_value
FROM trades
WHERE timestamp IN today()
SAMPLE BY 1h;
TWAP 是算法订单的标准执行基准。将其作为支持并行 GROUP BY 和 SAMPLE BY(包含所有 FILL 模式)的原生聚合函数,意味着你不需要任何客户端集成——计算完全在查询引擎内部运行。
QuestDB 支持标准 SQL 窗口函数,这是技术指标计算的骨干:
-- 基于物化 OHLC 视图的布林带
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 还引入了 SQL 标准的 WINDOW 子句——定义一次窗口规范,在多个函数中按名称引用。不再需要在每个表达式中重复相同的 PARTITION BY 和 ORDER BY:
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);
查询更简洁,复制粘贴更少,执行性能不变。
让我展示一些在生产交易系统中频繁出现的模式:
-- ETH 与另一资产之间的滚动小时和日相关性
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;
-- 所有 USDT 交易对的 14 日 RSI
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
);
-- 基于 15 分钟 OHLC K 线的 14 周期 ATR
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;
你可能会想:既然可以将原始数据拉取到 Python 中使用 ta-lib 或 pandas 处理,为什么要在数据库内部计算这些指标?
三个原因。第一,数据局部性——将 TB 级的 tick 数据通过网络传输来计算滚动平均值是一种浪费。计算应该在数据所在的地方进行。第二,并行性——QuestDB 带有 JIT 编译的向量化引擎可以利用 SIMD 指令在多核上并行处理这些查询,通常比单线程 Python 代码更快。第三,一致性——当多个仪表板、策略和监控系统都需要相同的指标时,在数据库中维护单一数据源可以消除同步错误。
话虽如此,QuestDB 并不试图取代你的整个分析栈。Parquet 互操作性意味着对于批量工作负载,你仍然可以不经过数据库直接将历史数据读取到机器学习管道中。
在最终篇中,我们将介绍用于实时 OHLC 的物化视图(包括多个时间周期的级联视图)、用于原生订单簿分析的 2D 数组,以及基于 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/en/blog/post/questdb-algotrading-sql},
version = {0.1.0},
description = {Deep dive into QuestDB's time-series SQL extensions: SAMPLE BY, ASOF JOIN, HORIZON JOIN, WINDOW JOIN, LATEST ON, and real-world trading query patterns.}
}