Cloudflare Durable Object SQLite(全局单例) · ARCHIVE_COLLECTOR → ArchiveCollector(apps/strategy/wrangler.jsonc) · contexts/strategy
ArchiveCollector DO
行情归档的「调度台 + 记账本」:全局单例的采集 DO 记着在采哪些币、每条序列采到哪、下一步跑什么任务、HL 权重这一分钟还剩多少,以及盘中检查要盯的价位和临时拉到的 1m。
不存行情本身:K 线 / 资金费 / 市场参数 / 覆盖范围索引都在 R2 桶 MARKET_ARCHIVE(只追加,更正走数据修订号);行情权威是 Hyperliquid。部署、模拟账户、成交都在各自的 StrategyOwner DO(strategy-owner-do),这里只存所有者报上来的副本。
11 张表
57 个字段
5 个聚合
4 个迁移
语义层已对齐 · sha256:e78deed551d346b9
板块颜色贯穿全文
全库约定
- 时间统一存 INTEGER 毫秒时间戳(UTC epoch ms),不是全仓惯用的 ISO TEXT——已接受的例外:K 线开盘时刻、窗口边界都要做整除对齐与加减,存数字最直接。唯一的例外是
schema_migrations.applied_at(迁移 runner 写 ISO 字符串)。 - 价格 / 数量(
minute_candles 的 OHLCV、intrabar_watches.below / above)一律存十进制字符串,读出后用 safeBN 比较,不经 Number。 - 布尔存 INTEGER 0 / 1(
active / open_pending / archive / archived),库里没有 CHECK,只由仓储写 0 或 1。 - 所有读写都是同步的 DO SQLite(
ctx.storage.sql);写路径里的 batch 指包在 storage.transactionSync 里,single 指一条或几条独立的 sql.exec(不包事务,但同步执行、中间没有 await)。 - 单例:DO 名固定为
mainnet(ARCHIVE_COLLECTOR_NAME)。production(ARCHIVE_ROLE = collector)与 staging(reader)各有一个同名实例、各一个库;staging 不采集,只为本环境的部署做盘中检查,所以采集相关的表在 staging 为空。 - 迁移:
ARCHIVE_COLLECTOR_SCHEMA_MIGRATIONS 只增不改,DO 构造函数里由 migrateStrategySchema 同步跑完才接请求。
业务流程跟着流程看表怎么变
⏱️ 一轮 15m 采集
- Cron(
*/15 * * * *,只有 production)调 tick → jobsForTick 读 coins(在采)、deployed_coins(先抓)、reconciliations(已对的跳过),enqueue 进 jobs同键已在队里只会提前、提优先级
- alarm 到 →
drainCollection 循环:nextDueJob 取任务,读 weight_spends 过去 60 秒的账,checkShare 不放行就把 alarm 拨到能放行的时刻 - 放行 → 按
series 游标打一次 HL candleSnapshot,recordSpend 记一笔 weight_spends(来源 collection)权重按实际返回条目算:20 + 每 60 根 +1
- 新 K 线追加进 R2 →
saveSeries 推进游标;15m 之后顺带把 1h / 4h / 1d 能聚合的桶聚合并推进各自游标 completeJob 删任务;本轮有游标变动就重写 R2 覆盖范围索引(可回测区间)
🆕 HL 新上一个币
- 整点的
universe 任务打 meta(20 权重)→ diffMarkets 对比 coins,发现新币 - 先往 R2 写市场参数(修订 1);参数变了的老币生成新修订,记一条
events(market_revised) - 给新币
enqueue 一次性回补:15m / 1h / 4h / 1d 原生 K 线 + 资金费全量(优先级 4) - 最后
syncMarkets 插入 coins(active = 1)顺序保证中途失败重跑时还能认出它是新币
🎯 模拟部署的盘中止损检查
- 所有者 DO 推进部署后调
replaceDeployments:整组替换 deployed_coins 与 intrabar_watches,有监视就把 alarm 拨到下一分钟收盘后 2 秒 - alarm →
runIntrabarCheck:币少按 1m 拉(每币 20 权重),币多改 allMids 每 6 秒采样(2 权重);结果写 minute_candles + minute_coverage,权重记进 weight_spends(来源 intrabar) - 按币比对
intrabar_watches:1m 低点 ≤ below 或高点 ≥ above 才按所有者分组唤醒,带上那根开盘以来的 1m开盘以来收录不完整的不比,留给收盘兜底
- 所有者用引擎算成交,回新的区间 →
resolveWatch 改行(回 null 则两侧清空、留行防重复唤醒) - 过了整点:production 把
archive = 1 的原生 1m 追加进 R2 并 markArchived;pruneMinutes 裁掉不再需要的 1m
🧾 每日对账发现不一致
- 整点排期回看最近 2 个 UTC 日,
reconciliations 里没有的 币 × 周期 × 天 排 reconcile 任务(日终后 30 分钟起,优先级 5) - 归档还没收录到那天 → 每 15 分钟再看一次(
attempts +1),满 8 次记 reconcile_skipped 放弃 - 收录到了 → 拉那天的 HL 原生 K 线逐根比对,不一致记
events(reconcile_mismatch,最多列 20 条)不自动改归档;更正由人工确认后生成新数据修订号
- 不论一致与否都
markReconciled 写 reconciliations,之后不再排
设计要点
🗄️ 状态在 SQLite,数据在 R2本库只放调度与进度(游标、队列、权重账)和盘中检查的短期缓冲;行情本身只追加进 R2 MARKET_ARCHIVE。游标总在 R2 写成功之后才推进,重跑最多重抓一次,不会漏。
⚖️ 一本权重账,两份限额15m 采集与盘中检查记进同一张 weight_spends,source 区分。放行要同时过「本来源那一份」和「合计」两道(checkShare);有在盯的部署时 15m 采集让出盘中那 300。依据 [HL rate limits](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits)。
🔑 任务按序列去重jobs.key 由 jobKey 生成,一条序列同时只有一个任务;任务按游标决定从哪抓,所以 Cron 漏几轮也只是下一个任务多抓几根。
📨 所有者报告整组替换deployed_coins 与 intrabar_watches 都按 owner 先删后插,不做增量对账——报告是幂等的,丢一次下次补上。两张表各自一个事务,不在同一事务里。
🧮 价格存字符串1m OHLCV 与触发价位都是十进制字符串,比对走 safeBN(金融算术全程 BigNumber);SQL 里从不对价格做比较或排序。
聚合每张表恰好属于一个聚合;kind=supporting 是辅助表
通用语言(术语定义)以 contexts/strategy/CONTEXT.md 为准,这里不重复。
Market(在采币)
聚合
根 coins
主 DEX perp 列表的镜像与在采状态。参数的权威在 HL,归档里的市场参数修订在 R2;这里只用来判断「新币 / 参数变了」。
领域对象
MarketSpec contexts/strategy/src/domain/value-objects/marketData.ts — 币名 + szDecimals + maxLeverage + 保证金分档
不变量与保证方
生命周期 · 币
在采停采
- 在采→停采刷新列表时不在 HL 列表里(
active = 0) - 停采→在采重新出现在列表里(
active = 1,不重排回补)
SeriesCursor(序列游标)
聚合
根 series
每条序列在 R2 归档里的进度。归档只追加,游标只往后推;可回测区间由它派生。
领域对象
SeriesCursor / SeriesId / coverageOf contexts/strategy/src/domain/archive/coverage.ts — 游标 → 可回测区间acceptCandles / acceptFunding / needsNativeFill contexts/strategy/src/domain/archive/continuity.ts — 去重、缺口判定、continuousSince 推进
不变量与保证方
CollectionQueue(采集任务)
聚合
根 jobs + reconciliations
待跑的采集任务,加上「哪些日子已对过账」这份去重记录。每个任务只打一次 HL。
领域对象
CollectionJob / jobKey / jobsForTick / JOB_PRIORITY contexts/strategy/src/domain/archive/collectionJobs.ts — 排期表与去重键reconcileCandles contexts/strategy/src/domain/archive/reconciliation.ts — 聚合 K 线 vs HL 原生逐根比对
不变量与保证方
生命周期 · 任务
排队运行稍后再跑完成(删除)
- 排队→运行到期且权重预算放行(
nextDueJob + checkShare) - 运行→完成(删除)
completeJob - 运行→稍后再跑分页没拉完 / 等收盘 / 等归档收录 / 请求失败(
rescheduleJob) - 稍后再跑→运行
due_at 到期 - 稍后再跑→完成(删除)对账等满 8 次仍未收录:记
reconcile_skipped 后放弃
WeightLedger(HL 权重账)
辅助
根 weight_spends
滑动 60 秒窗口的 HL 权重流水,不是业务实体。采集 DO 独占同出口 IP 额度的一半,另一半留给 Bot([HL rate limits](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits))。
领域对象
hlInfoWeight / checkBudget / checkShare / weightShares contexts/strategy/src/domain/archive/throttle.ts — 权重表、窗口判定、两类来源的分额metered contexts/strategy/src/application/archive/collector.ts — 打 HL 并记账的唯一入口
不变量与保证方
DeployedCoins(有部署的币)
辅助
根 deployed_coins
所有者 DO 报来的副本,只影响 15m 采集的优先级。部署本身的权威在 strategy-owner-do。
领域对象
DeployedCoinsRegistry / DeploymentReport contexts/strategy/src/application/archive/ports.ts — 整组替换的报告端口
不变量与保证方
IntrabarWatch(盘中监视)
聚合
根 intrabar_watches
各部署在当前这根里的触发区间。这里只是筛子:碰到区间就唤醒所有者,成交多少、什么价由所有者 DO 用引擎算。
领域对象
IntrabarWatch / hasLevels / watchTriggered contexts/strategy/src/domain/archive/intrabarWatch.ts — 监视形状与命中判定(BigNumber 比较)runIntrabarCheck contexts/strategy/src/application/archive/intrabar.ts — 一轮:拉 1m / 采样 → 比对 → 唤醒 → 归档 → 裁剪
不变量与保证方
生命周期 · 监视
开盘待定有区间无价位被替换
- 开盘待定→有区间那根有一分钟就唤醒,所有者回 band(
resolveWatch) - 开盘待定→无价位所有者回 null(平掉了 / 不是这一根了)
- 有区间→有区间命中后所有者回新的区间
- 有区间→无价位所有者回 null
- 有区间→被替换所有者推进到下一根后整组重报(
replaceWatches) - 无价位→被替换所有者整组重报
MinuteBuffer(1m 缓冲)
聚合
根 minute_coverage + minute_candles
每个币的 1m 行与它们收录完整的一段,一起写、一起裁。production 的原生 1m 同时是 R2 1m 归档的待写区。
领域对象
MinuteCoverage / candleFetchPlan / sampledCoverage / mergeMidSample / intrabarMode contexts/strategy/src/domain/archive/intrabarWatch.ts — 从哪接着拉、采样怎么并成 1m、按 1m 拉还是 allMidsArchiveCandle contexts/strategy/src/domain/value-objects/marketData.ts — time + OHLCV(十进制字符串)
不变量与保证方
生命周期 · 1m 行
待归档已归档不归档已裁剪
- 待归档→已归档过了整点追加进 R2(
markArchived) - 不归档→待归档合成行被同一分钟的原生行覆盖(
archive 取 MAX) - 已归档→已裁剪
pruneMinutes:早于本整点且不再被在盯的监视需要 - 不归档→已裁剪同上
ArchiveEvents(告警事件)
辅助
根 events
运营告警的近期环形缓冲;更久的看 Workers Logs。
领域对象
ArchiveEvent / ARCHIVE_EVENT_KINDS contexts/strategy/src/application/archive/ports.ts — 事件形状与种类
不变量与保证方
SchemaMigrations
辅助
根 schema_migrations
迁移 runner 的版本记录,不是业务实体。
领域对象
migrateStrategySchema contexts/strategy/src/infrastructure/sqlite/migrate.ts — 与 StrategyOwner DO 共用的 runner
不变量与保证方
保证方:数据库 约束 / 索引 / 触发器 SQL 仓储里的条件写 / batch 领域代码 domain 层规则 外部 链上等外部权威
写路径矩阵哪个仓储方法写哪几张表
跨库 / 链上引用都没有外键,靠应用层维持
保留与清理
敏感度未列出的字段视为「内部」