fix(aggregate): 修正聚合刷新触发时机 — 桶闭合时触发而非桶首
- aggregate.ts: openTime % ms === 0 → (openTime + 60_000) % ms === 0 确保桶内最后一根1m K线到达(桶已闭合)时才触发聚合刷新 - AGENTS.md: 新增 K线 time 字段与 time_bucket 边界说明 - ARCHITECTURE.md: 新增 §4.3 聚合时间边界与刷新时机 小节 - websocket-realtime.md: 同步更新 checkAndRefresh 判定公式和表格
This commit is contained in:
@@ -50,6 +50,7 @@ data/ engine/
|
||||
```
|
||||
|
||||
- **K 线复合主键**:`[exchange, symbol, type, interval, time]`(5 列),TimescaleDB 超表按 `time` 分区。`type` 区分现货(spot)/ USDT-M 合约(um)/ Coin-M(cm)。
|
||||
- **K 线 `time` 字段**:存储交易所原始 `openTime`(非系统接收时间),这是连续聚合正确性的前提。`time_bucket` 按左闭右开 `[start, start+interval)` 切分:例 5m 桶取 00:00~00:04 共 5 根 1m K 线,00:05 进入下一桶;`FIRST(open, time)`/`LAST(close, time)` 以 `time` 排序保证 OHLC 语义与交易所原生高周期 K 线一致。
|
||||
- **`synchronize: false`**:TypeORM 不自动 sync schema,DDL 由 `db/init-db/` SQL 脚本手动管理。
|
||||
- **`@timescaledb/typeorm` 是 v0.0.1 实验版**,连续聚合用原生 SQL 视图(`klines_5m` / `klines_15m` 等)。
|
||||
- TS 侧价格字段为 `string`(精度),Python 侧 Pydantic `field_validator` 转 float。
|
||||
|
||||
+27
-1
@@ -355,6 +355,32 @@ BTCUSDT + um → klines ← 同一 symbol,不同 type,PK 不冲突
|
||||
> | API 调用 | 每次 strip 后缀 | symbol 原样传入,零变换 |
|
||||
> | 扩展 Coin-M | 需要 `_PERP` 新后缀规则 | 加 `'cm'` 枚举值即可 |
|
||||
|
||||
#### 聚合时间边界与刷新时机
|
||||
|
||||
**`time` 列语义**:存储交易所原始 `openTime`(K 线开盘时间的 Unix 毫秒时间戳),**不使用系统接收时间**。这是连续聚合正确性的前提——回补的 3 天前 K 线若用"当前系统时间"写入,`time_bucket` 会将其分入错误的桶。
|
||||
|
||||
**`time_bucket` 左闭右开**:`time_bucket('5 minutes', time)` 将 `time` 向下取整到 5 分钟边界,桶区间为 `[start, start + interval)`。
|
||||
|
||||
```
|
||||
00:00 ─┐
|
||||
00:01 │
|
||||
00:02 │ 5m 桶 [00:00, 00:05)
|
||||
00:03 │ FIRST(open, time) = 00:00 的开盘价
|
||||
00:04 ─┘ LAST(close, time) = 00:04 的收盘价
|
||||
00:05 ─┐ 下一个 5m 桶 [00:05, 00:10)
|
||||
...
|
||||
```
|
||||
|
||||
**聚合刷新触发时机**:当桶内最后一根 1m K 线到达(桶已闭合)时触发,而非桶的第一分钟。判定条件 `(openTime + 60_000) % period_ms === 0`,即当前分钟结束边界刚好整除聚合周期。
|
||||
|
||||
| 聚合周期 | 桶范围 | 触发 1m K 线 | 判定 |
|
||||
|----------|--------|-------------|------|
|
||||
| 5m | [00:00, 00:05) | 00:04 | `(00:04 + 60s) % 300s = 0` ✅ |
|
||||
| 15m | [00:00, 00:15) | 00:14 | `(00:14 + 60s) % 900s = 0` ✅ |
|
||||
| 1h | [00:00, 01:00) | 00:59 | `(00:59 + 60s) % 3600s = 0` ✅ |
|
||||
|
||||
> **为什么不是 `openTime % period_ms === 0`**:该条件在桶的第一分钟(如 00:00)触发,此时桶内仅 1 根 K 线,其他 4 根尚未到达,刷出的聚合 K 线不完整。延迟到桶闭合触发可保证聚合数据完整性。
|
||||
|
||||
#### TimescaleDB 配置
|
||||
|
||||
| 配置项 | 值 | 说明 |
|
||||
@@ -964,7 +990,7 @@ bun test
|
||||
touch exchanges/new-exchange.ts
|
||||
|
||||
# 2. 实现 MarketDataFeed 接口
|
||||
# 3. 在 types/base.ts 注册类型,在 exchanges/index.ts 注册适配器
|
||||
# 3. 在 types/base.ts 注册类型,在 exchanges/rest-registry.ts 注册适配器(index.ts 为纯 re-export)
|
||||
# 4. 编写测试 tests/exchanges/new-exchange.test.ts
|
||||
# 5. 如需在数据库中配置,插入 exchanges 表和 trading_pairs 表
|
||||
```
|
||||
|
||||
@@ -21,7 +21,10 @@ const REFRESH_INTERVALS = [
|
||||
|
||||
export async function checkAndRefresh(openTime: number): Promise<void> {
|
||||
for (const { interval, ms } of REFRESH_INTERVALS) {
|
||||
if (openTime % ms === 0) {
|
||||
// 桶闭合判定:当前 1m K 线是否为该聚合周期桶的最后一分钟。
|
||||
// 例 5m 桶 [00:00, 00:05),最后一根 1m 是 00:04,
|
||||
// (00:04 + 60s) = 00:05 刚好整除 5min → 触发刷新。
|
||||
if ((openTime + 60_000) % ms === 0) {
|
||||
// TODO: 实现 DB 连接后接入
|
||||
// await db.query(`CALL refresh_continuous_aggregate('klines_${interval}', NULL, INTERVAL '1m')`);
|
||||
logger.debug({ interval }, "聚合刷新触发");
|
||||
|
||||
+10
-10
@@ -294,19 +294,19 @@ run/start.ts 启动
|
||||
|
||||
`service/aggregate.ts` 导出的纯函数,由 start.ts 在收到 `kline:saved`(数据已入库)后调用,确保聚合基于已落库的 1m 数据。
|
||||
|
||||
`openTime % period_ms === 0` 时刷新对应聚合视图:
|
||||
桶闭合判定:`(openTime + 60_000) % period_ms === 0`。`openTime` 是当前 1m K 线的开盘时间戳,`+60s` 定位到该分钟结束边界,若刚好整除聚合周期则说明本分钟是该桶的最后一分钟(桶已闭合),触发刷新。例 5m 桶 `[00:00, 00:05)`,00:04 的 1m K 线到达时 `(00:04 + 60s) = 00:05` 整除 5min,触发 `klines_5m` 刷新。
|
||||
|
||||
| 触发条件 | 刷新视图 | 频次 |
|
||||
|----------|----------|------|
|
||||
| `openTime % 300000 === 0` | klines_5m | 每 5 分钟 |
|
||||
| `openTime % 900000 === 0` | klines_15m | 每 15 分钟 |
|
||||
| `openTime % 1800000 === 0` | klines_30m | 每 30 分钟 |
|
||||
| `openTime % 3600000 === 0` | klines_1h | 每小时 |
|
||||
| `openTime % 7200000 === 0` | klines_2h | 每 2 小时 |
|
||||
| `openTime % 14400000 === 0` | klines_4h | 每 4 小时 |
|
||||
| `openTime % 21600000 === 0` | klines_6h | 每 6 小时 |
|
||||
| `openTime % 28800000 === 0` | klines_8h | 每 8 小时 |
|
||||
| `openTime % 86400000 === 0` | klines_1d | 每天 |
|
||||
| `(openTime + 60_000) % 300000 === 0` | klines_5m | 每 5 分钟 |
|
||||
| `(openTime + 60_000) % 900000 === 0` | klines_15m | 每 15 分钟 |
|
||||
| `(openTime + 60_000) % 1800000 === 0` | klines_30m | 每 30 分钟 |
|
||||
| `(openTime + 60_000) % 3600000 === 0` | klines_1h | 每小时 |
|
||||
| `(openTime + 60_000) % 7200000 === 0` | klines_2h | 每 2 小时 |
|
||||
| `(openTime + 60_000) % 14400000 === 0` | klines_4h | 每 4 小时 |
|
||||
| `(openTime + 60_000) % 21600000 === 0` | klines_6h | 每 6 小时 |
|
||||
| `(openTime + 60_000) % 28800000 === 0` | klines_8h | 每 8 小时 |
|
||||
| `(openTime + 60_000) % 86400000 === 0` | klines_1d | 每天 |
|
||||
|
||||
平均每分钟触发 1-2 次刷新调用。刷新 SQL:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user