方法论¶
逐条说明回测如何把归档文件变成报告。这里的每条规则在两种语言中各实现一次,并把两个实现相互对照测试(见验证);规则是近似时,会明确说明。
1. 从文件到事件¶
归档是 gzip 压缩的 JSONL 和 CSV,每个平台 × 数据集 × 资产组 × UTC 日一个文件,与订阅者下载到的完全相同。
先处理市场。 每天的 markets 行被规范化为每个市场一条记录:id、资产、周期、开盘和收盘时间、strike(要超过的价格)、官方结果、代币 id,以及 Polymarket 的费率表。其他数据都通过 slug(Polymarket)或数字 id(Predict.fun)对应到市场。
使用哪条结算流。 Polymarket 加密市场使用 Chainlink 瞬时流、30 秒 TWAP 或 60 秒 TWAP 结算。市场自己的配置说明用哪条(cryptoMarketConfig.twapLookbackSeconds:null 或 0 → 瞬时流,30、60)。平台曾随时间把市场从一种流迁移到另一种,因此绝不按日期决定。Predict.fun 市场使用自己的价格流结算,通过 price_feed_id 匹配。
出现以下情况时,市场会被剔除,并列在报告的覆盖度中: 没有资产;Polymarket 市场的 token_ids 不是两个不同的 id(每条盘口、最优买卖价和成交行都通过这一对 id 分配给 UP 或 DOWN,没有它就会对着空盘口回放);无法确定结算流;读不出结果(Polymarket:已结算市场中完整的 ["1","0"] / ["0","1"] 对;Predict.fun:存在 end_price);窗口内没有事件;或窗口内没有结算 tick。被剔除的市场可见且不收费;靠猜测结算的市场则会造成不可见的错误。
窗口。 市场事件截取到 [open, close]。结算文件覆盖一整天,因此这一步能防止 14:00 的价格进入 10:00 就已收盘的市场。
同一毫秒内的顺序固定,绝不由读取顺序决定:先盘口,再成交,再结算 tick;盘口内部,完整快照先于增量。因此 hook 看到的是市场在该时刻已经处于的状态,而不是即将变成的状态。
2. 盘口¶
每个市场两本盘口:UP 和 DOWN,各有买档和卖档。
- Polymarket 每条快照行发布一个结果侧的盘口(
book,按asset_id);一行替换该代币的档位,不改变另一代币的档位。price_change设置一个档位的绝对数量(数量为 0 时删除);其中BUY行是买档,SELL行是卖档。 - Predict.fun 每个市场发布一本盘口。它是 UP 的盘口;DOWN 是它的镜像(UP 在 0.40 的买档就是 DOWN 在 0.60 的卖档)。
- 价格在 1e-4 网格上比较,因此
0.1 + 0.2这样的浮点数不能决定订单是否成交。 - 采集频率。 盘口流按每个资产设定的采集频率归档(每个窗口、每个市场一行,保留第一行)。一次运行对每个资产采用其日期范围内最稀疏的采集频率回放,因此同一本盘口的数据密度不会在报告中途改变。
- 最优买卖价作为边界。 不抽稀的
best_bid_ask流只有价格,没有数量。它只用于删除:严格优于最新最优价且比它更早的档位会被移除。它绝不添加档位,因为没有数量的档位是凭空捏造的流动性。它不会触发 hook。只要声明了book就会启用:bbo数据集随之加入。
这无法恢复的内容:两次快照之间,被采集频率丢弃的增量中新增或改量的档位,会一直缺失或过旧,直到下一次快照。盘口顶部和更深的档位都受此限制。
影响有多大:对照平台的完整行情流,在 24 小时的 Polymarket 加密市场实时数据上实测(2026-10-11 至 2026-10-12,按回测顺序重放),仅用归档盘口重建时,最优价在 50.6% 的采样时刻与平台不一致;应用 best_bid_ask 后为 36.5%。剩下的大多是被丢弃的增量里新出现的更优档位,只有价格的流无法把它补回来。报告的 book_age 块(第 7 节)给出一次运行的吃单成交量中,有多少来自成交前超过一秒没有更新过的档位。
3. 订单与成交¶
hook 返回一个 Order(或列表,或什么都不返回):
| 字段 | 含义 |
|---|---|
side |
"UP" 或 "DOWN"——哪一个代币 |
size |
合约数;或带 limit 的 notional(美元),得到 floor(notional / limit) 份合约(折算为 0 份会直接报错:金额不够一份时请自己跳过这一单) |
limit |
买入时的上限,卖出时的下限;不吃超出限价的档位 |
reduce_only |
卖进所持代币的买档,最多卖出持有数量 |
hold_s |
成交后经过这么多秒平仓 |
tif |
"ioc"(默认),或挂单用的 "gtc"(仅 Polymarket,见下文) |
撮合从最优档向外逐档进行,并扣除吃掉的量,因此同一个事件中的两张订单不能对着同一份深度成交。盘口里没有的数量记为未成交。成交行记录发单时屏幕上的价格(quoted_px)、实际支付的平均价和最差价,以及吃过的档位——滑点面板由这些数据构建。
延迟。 设置 latency_ms 后,在 t 决定的订单于 t + latency 撮合。严格安排在某事件之前执行的操作,按该事件发生前的盘口处理;安排在事件同一毫秒的操作,在应用该事件后处理。市场收盘时仍在等待的订单没有到达。
挂单(tif: "gtc",仅限 Polymarket)由排队模型 polymarket-queue-v1 决定成交。挂单是否成交取决于它的排队位置,公开行情没有记录这一位置,乐观猜测会把做市收益夸大数倍——因此模型建立在归档确实记录的内容上,所有歧义都按对策略不利的方式处理。
归档说明了什么:实测。 在公开样本日(BTC 5 分钟,2026-09-08),与平台自己的逐笔撮合记录关联后:
- 盘口是镜像的。每个
price_change都携带一对档位:UP 在 q 的买档和 DOWN 在 1 − q 的卖档,数量相同。它们是一个队列,发布了两次。因此每张自有订单都放入一个规范化队列,以 UP 视角表示:在 q 买 DOWN 排在 UP 的 1 − q 卖档,在 q 卖 DOWN 排在 UP 的 1 − q 买档。 - 一笔成交只打印一次,打印在吃单方的代币上:480,491 条成交记录,480,491 个不同的交易哈希;在一个逐笔关联的市场中,1,138 条成交记录中的 1,138 条都携带吃单方的代币、方向和总数量。价格是挂单方的价格——一次跨多档吃单中,有时是最优价,有时是最差价。挂单方可以挂在任一代币上:该市场的 1,138 条成交记录中,555 条是纯铸造撮合(DOWN 买家由挂着的 UP 买单成交)。UP 挂买单会被其价格上的 UP 卖出成交记录以及在 1 − 其价格上的 DOWN 买入成交记录消耗。
- 成交和盘口顶部完整存储;盘口增量按市场抽稀(丢弃,不合并——每条保留的增量仍给出该档位在那一时刻的真实数量);快照按代币抽稀。
- 成交记录可能晚于它引起的盘口更新。约四分之一的吃单买入成交记录中,全采集频率的盘口顶部在成交记录自身的时间戳之前就已显示该档位消失(在观测到的变化中,这一提前量的第 99.9 百分位:吃单买入为 702 ms,吃单卖出为 639 ms)。
规则。
- 入队。 到达平台的订单(经过
latency后),如果能在自身代币的盘口上立即成交,会先吃流动性,与 IOC 完全相同(或因post_only被拒绝);剩余部分挂在规范化档位上该价格可见数量的后面。同价的自有订单按到达顺序排队;两张会相互交叉成交的自有订单会被拒绝。 - 触达。 成交记录只能使价格至少与其自身价格一样优的订单成交。成交记录的价格可能是一次跨档吃单中的最优价,因此不假定它触达更远的价格。
- 每条成交记录一个数量预算。 按最优价优先遍历已触达的价格,成交记录的数量依次消耗:成交记录之前滞后窗口内盘口移除的深度,按该深度自身的价格扣除(消耗台账——解释盘口更新的成交记录,不能越过该更新已移除的量);每张自有订单前方的外部排队量;订单自身;该档位的剩余量。自有成交加上其他消耗,绝不超过成交记录打印的数量。
- 盘口更新只缩短排队,不使订单成交。 每次盘口事件后,订单的前方排队量估计上限被限制为该档位的可见数量——在它前方且仍挂着的量是可见的。抽稀增量流中漏掉的新增量可能让档位显得比实际更短;这是这条规则唯一可能出错的方向,且会明确披露。穿过挂单价格的盘口不会使它成交(绝对数量不是新到达的主动成交量);报告会统计这种交叉。
- 双向滞后窗口(
print_lag_ms,800 ms:上述实测第 99.9 百分位向上取整)。新订单忽略在它开始挂单后的窗口内带有时间戳的所有成交记录——记录可能描述订单出现前的撮合。撤单到达后(cancel_latency),被撤订单在窗口内仍暴露于成交——其中的记录可能描述撤单前的撮合。第二条规则是压力假设,并标明如此。 - 收盘。 收盘时仍在工作的所有订单都在结算前到期,包括尚未到达平台的订单。收盘后没有成交。
成交价为订单自己的限价。两个引擎中的模型代码相同,一致性测试逐笔对照成交,包括排队和成交后价格走势字段。报告的 maker 块给出计数(挂单、成交、撤单、到期、拒绝、滞后窗口扣留的成交量、盘口交叉)、按数量和按订单计算的成交率、入队时和成交时前方排队量的中位数、排队时长的中位数,以及成交后价格走势:每笔成交后 1 秒、10 秒和 60 秒时成交代币的中间价(取该时刻或之前的最新盘口;盘口超过 5 秒未更新,或观察时点越过收盘时,为 null),以及到结算的价格走势,全部按每股计算,并按持有者视角确定正负。独立核对器目前还不重算挂单成交,会用自己的退出码拒绝此类报告,而不是让它们通过。
4. 结算¶
市场收盘时,所有未平仓位按官方结果结算:获胜侧每份合约 $1,失败侧 $0,Predict.fun 平局时两侧均为 $0.50。结算不收手续费。
5. 手续费¶
运行的手续费策略为以下一种:
- venue(默认)——每个市场自己的费率表。Polymarket 加密市场向吃单方收取
C × rate × p × (1 − p)USDC,取整到小数点后五位,不向挂单方收费;归档携带每个市场的feeSchedule(2026-09-08 样本中:rate 0.07, exponent 1)。吃单成交支付此费用;挂单成交不支付,平台的挂单返佣——从资金池支付,不按每笔成交支付——不建模。 - bps——每笔成交都按名义金额收统一费率,无论吃单还是挂单,由
fee_bps设置(包括 0)。
报告的 fee_model 块说明的细节:
- 逐档计算,是估算。 平台按每笔撮合应用公式——一张吃单订单对一张挂单订单——并分别取整。归档保存盘口档位,一个档位聚合了多张挂单,而这些挂单各自的数量已不可得,因此手续费按吃掉的每个档位计算并取整(四舍五入)。
- 已知为零与未知。 明确说手续费关闭的市场不收费,计为已知为零。读不出费率表,或手续费曲线不是文档规定曲线(
exponent ≠ 1)的市场不收费,计为未知。Predict.fun 归档没有费率表,因此 Predict.fun 运行不收手续费,除非设置fee_bps。 - 挂单返佣不属于 venue 策略:平台从资金池支付返佣,而不是按每笔成交支付。设置
maker_rebate(第 10 节)时按估算返还。
手续费计入每笔交易的 PnL 和净 PnL。每份合约的边际、Brier 分数、校准和朴素基准都是价格指标,在扣手续费前计算。
6. 不偷看未来¶
- 事件从流中逐条交给策略;当天剩余数据不在进程中。
- 市场的
outcome从on_market_open和所有事件 hook 可见的内容中移除。只有on_settle收到它。 ctx.book()是当前事件时刻的盘口;ctx.history()保存已见到的结算 tick。- market 模式(默认):每个市场获得一个新的策略实例,因此市场之间不保留任何内容。
- session 模式:整个运行只有一个实例和一个投资组合。时间上重叠的市场按同一全局时间顺序交错重放(同一毫秒先开盘,再按市场顺序处理事件,最后收盘)。每个事件之前,其他所有仍开盘的市场先处理各自安排在严格更早时间的操作。因此从一个市场保留到另一个市场的状态绝不会超前于时钟。
7. 报告¶
| 块 | 内容 |
|---|---|
metrics |
净 PnL、胜率、盈利因子、最大回撤(基于已实现 PnL)、Sharpe 和 Sortino(每日已实现 PnL,×√365;Sortino 使用下行部分,目标为 0)、净 PnL 与最大回撤之比、交易日 / 盈利日 / 最好和最差的一天、峰值占用资金及其收益率、持仓比例、每份合约的边际(按股数加权)和每笔交易的边际(每笔已结算交易计一次——两者可能符号相反)、Brier 分数、手续费、平均持仓时长 |
by_settlement |
按结算规则给出同样的结果(每个市场使用的结算流:瞬时流、30 秒或 60 秒 TWAP),计入所有市场,无论是否入场——不同规则的市场并排报告,绝不只混在一起 |
activity |
从成交日志统计的换手合约数和美元金额,区分吃单和挂单 |
fingerprint |
预注册指纹(见下文) |
calibration |
按入场价分桶:支付的价格与该侧实际获胜的比例对照 |
baselines |
一直买 UP、一直买 DOWN、一直买热门——按开盘报价(首次两侧都有报价的时刻)、以策略的平均数量买入,持有到结算 |
slippage |
按报价成交、跨档成交、未成交订单、滑点百分位、滑点造成的 PnL 损失 |
fee_model |
手续费策略、模型、取整假设,以及收费 / 已知为零 / 未知的市场 |
coverage |
扫描的市场日、每个被剔除的市场日及其原因、每个市场使用的结算流、哪些日期应用了最优买卖价修正 |
book_age |
吃单成交量按所吃档位的「年龄」分档:成交时间减去该档位最后一次被快照或增量更新的时间(仅归档市场) |
equity |
已实现 PnL,每笔平仓交易一个点(抽稀到 2,000 个点,保留所有极值) |
完整交易和成交行在 Result.trades / Result.fills 中(也在托管运行的归档中)。Result.markets(归档中的 markets.csv)对每个重放的市场都有一行,包括策略从未入场的所有市场——市场 id、资产、时长、结算规则、开盘和收盘、strike、结果、开盘报价、是否入场、交易数、合约数、PnL、手续费、首次入场和两种边际——这些是市场层面的置信区间或对结果做置换检验所需的内容。
预注册。 report.fingerprint.sha256 是对以下内容计算的一个哈希:策略目录中每个文件的逐字节内容(代码、它读取的配置文件、它的序列,以及 outcometick.json 本身——code_sha256;重新格式化清单会得到不同指纹,因为策略可能读取这一差异)、明确展开默认值的清单、参数、执行选项、每条已声明序列的字节、范围(平台、资产,以及按顺序运行的每一天——从中间去掉一天会得到不同指纹)、自有数据的字节(通用数据:市场文件和每个行情帧文件,data_sha256)、本地回放设置(book_throttle、mark_max_age_ms)、实际应用的手续费策略、实现(引擎镜像标签和解码器版本——改变结果的升级会改变指纹),以及种子。在运行前计算它——ot fingerprint <dir> --assets … --from … --to …(或 --data <your-dataset>),或 outcometick.backtest.fingerprint(path, assets=…, days=[…])——并公布;随后运行的报告会携带相同值,无论来自托管队列、ot run 还是 Python 运行器。以进程内 Python 类传入的策略没有代码文件,因此其指纹覆盖代码以外的所有内容(code_sha256 为 null)。运行实际读取的归档文件在旁边以 inputs_sha256 报告(排序后的 path:sha256 行再做哈希):它不能成为预注册值的一部分,因为一天需要哪些文件,只有读过市场后才知道,但它准确说明了哪些字节生成了报告——托管 catalog 的校验和与本地归档文件,对相同文件给出相同值。
run() 和 run_template() 在你的 Python 进程内执行策略:文件从同一份快照计算哈希并加载,字节码缓存和此前的策略模块被搁置,运行期间发生变化的目录会被拒绝——但整个进程的状态(策略动态修改的库、此前代码留下的全局变量)是共享的,不在指纹覆盖范围内。要完全隔离、可复现的运行,请用 ot run(每次运行一个新进程)或托管回测(容器)。
8. 所有地方都适用的限制¶
- 托管沙箱执行的逐事件预算(每次 hook 调用的持续平均耗时为 400 µs)也适用于本地,因此在那里会被拒绝的策略,在这里也会被拒绝。
- 一天是样本,不是证据。请跨一段范围回测,并与朴素基准对照。
9. 用你自己的数据(任意 Polymarket 二元市场)¶
用你自带的数据运行同一个引擎,使用同样的成交、手续费和报告:一个市场文件(Gamma 市场对象,或 {market_id, token_ids, labels, ...})和 Polymarket CLOB 市场频道帧。与归档不同的地方及原因:
- 同一毫秒内按输入顺序。 事件先按时间排序,再按输入位置排序(文件、行、
price_changes批次中的位置)。归档按类型排列同一毫秒的事件,因为其文件以任意顺序读取;录制的流不是这样。出于同样原因,最优买卖价边界会移除同一毫秒内在它之前设置的档位。 - 严格解析。 读不出的盘口、增量或边界行——价格超出 [0, 1]、方向不是 BUY/SELL、同一价格有两个档位、代币不属于该市场、标识指向两个市场——会剔除整个市场,并列出原因和输入位置。坏的成交行会被丢弃并计数。
- 在流中途开始的窗口从当时的盘口开始:此前每条盘口行都合并进窗口起点的一份快照。
- 只凭证据结算:Gamma 的
umaResolutionStatus: resolved,且outcomePrices恰好为 1/0、0/1 或 0.5/0.5(Polymarket 的 50:50);一个market_resolved帧;或你提供的resolution。来源必须一致,否则剔除市场;没有时间的结算结果也被剔除,因为它无法说明何时必须停止交易。有帧时使用帧的时间(观测时间),否则使用你提供的时间,再否则使用 Gamma 的时间。 - 生命周期。 交易在以下最早时刻结束:你声明的交易结束时间、结果公开前一毫秒、最后一个事件。此后的事件在重放前丢弃;仍在途的订单到期;挂单到期。已结算市场在其结算时间兑付(session 模式下,现金在那时到账)。
- 估值。 未结算市场的未平仓位,在交易结束时用仅从市场数据计算的估值价估值——UP 代币的中间价,否则 1 − DOWN 代币的中间价,否则最后一笔公开成交——每个候选都先按
mark_max_age_ms检查,再尝试下一个。一对对冲仓位的价值恰为 $1。现金不变:报告的valuation块区分realised_pnl与marked_pnl,列出所有因缺少估值价而按 0 估值的仓位,并说明使用的最旧估值价。这些行不计算 Brier、边际或校准分数(只有持有到结算的行才计算),基准也只使用已结算市场,旁边列出策略在这些市场上的自身结果(strategy_on_resolved)。 - 单盘口表格形态(Python,
Dataset.from_pmdata)携带一本盘口;另一个结果侧是它的镜像,每次更新一步同时改变两侧。成交仅来自成交表,因此每笔公开成交只计一次。 - 已知简化:在任何镜像盘口(这种盘口,以及 Predict.fun 的盘口)上,两个结果侧各有一张立即执行的订单时,各自消耗自己的深度副本,因此同一份源流动性可能被吃两次。挂单已经共享一个队列和一个成交记录预算。
10. 执行选项¶
默认全部关闭——一个都不设置的运行,重放行为与此前完全相同。清单、run(...) / run_template(...)、托管和本地使用相同的键;两个引擎都实现每个选项,一致性测试逐笔交易对照它们。
fok: true启用tif: "fok":订单到达盘口时,限价内必须有全部所需数量(在吃掉任何量之前测量),否则完全不成交。被拒绝的订单是一条带reject_reason: "fok_unfilled"的成交行。预先声明,因此每种输出在第一行之前就知道自己的列。order_rules让订单遵守平台规则:数量不小于最小值且在 0.01 股网格上,限价在价格网格上,名义金额转换为百分之一股单位(用名义金额构建的 SDKOrder在构建时转换为整股——仍在网格上;普通订单对象的名义金额由运行器转换)。平仓订单在检查前先限制到持仓数量,并向下取整到网格,因此小于最小值的零碎仓位会持有到结算,与平台上的情况相同。"venue"读取每个市场自己的价格步长和最小数量(自有数据);"assumed"用于归档,因为归档市场不携带这些值:价格步长 0.001,最少 5 股,这些值是在归档上测得的。报告说明这是假设——以 0.01 报价的市场会拒绝这里接受的 0.001 限价,以更细步长报价的市场则会接受这里被拒绝的限价。拒绝按原因计数(below_min_size、size_step、off_tick)。maker_rebate(比例,例如 0.2)把同一笔成交若作为吃单成交本应支付的手续费,按该比例返还给每笔挂单成交,记为负手续费。这是估算:平台从每日资金池按比例支付挂单返佣,报告会说明这一点。quote_guard——{max_age_ms, ask_sum: [lo, hi]}——仅当两个结果侧都有最优卖价、卖价之和在范围内,且四个最优报价都在max_age_ms内设置时,才允许开仓吃单订单执行(缺少报价即不通过)。未通过的挂单会被整张拒绝,因此不能先挂着、以后再成交。除非保护设置include_exits: true,否则不阻止平仓——阻止平仓的保护会让仓位持有到结算。报价年龄是以交易所时间计算的档位年龄(ctx.book().quote_ts(side))。mirror_depth: "shared"用于单盘口市场(Predict.fun,以及用from_pmdata读取的数据):一个结果侧在 p 的卖档和另一侧在 1 − p 的买档是同一份流动性,因此吃掉一侧会移除另一侧。- 仅限本地:
book_throttle=False(ot run --book-throttle off)回放归档盘口流时,不应用托管运行按资产执行的抽稀。无论传入哪种取值,都会写出coverage.book_throttle,即每个资产实际使用的窗口或"off"。归档流在采集时就已限频,因此「off」是归档拥有的最密数据,不是平台发送的每次更新。
报告的 orders 块列出使用的选项、它们拒绝的内容和返佣总额。
11. 定时器与行情延迟(同一条时间线)¶
声明 on_timer hook 或 feed_latency,会让运行中的所有市场使用同一条时间线。每个市场的待处理工作——订单和撤单到达平台、定时器、滞后事件到达策略——都是安排在某个时间的任务;处理时间 T 的任何内容之前,所有市场中严格早于 T 的任务都先执行,最早的优先(同一毫秒内:交易所任务,再到达任务,再定时器;然后按市场在运行中的位置;再按任务创建顺序)。恰在 T 到期的任务,在数据所述的 T 时刻发生的所有内容之后执行。因此跨重叠市场共享的策略绝不会看到时钟倒退,150 时刻的定时器也绝不会看到时间戳为 151 的参考数据行。
- 定时器。
ctx.schedule(at_ms, tag)在该时间唤醒on_timer(ctx, timer),此时ctx.now = at_ms;它可以像任何 hook 一样返回订单。at_ms必须晚于现在,且不晚于市场声明的收盘时间;市场在自己的开盘时间开盘,因此on_market_open可以从该时刻安排定时器。每个市场的限制:64 个待触发、10,000 个设置、100,000 次触发。交易结束时仍待触发的定时器会被丢弃并计数(report.timers)。 - 从另一个市场保留的 ctx(session 模式)按运行的时间操作,不按该市场最后一个事件的时间:它设置的定时器必须晚于运行的当前时间,发送的撤单也从该时刻开始发生。
- 行情延迟。 设置
feed_latency: L后,每个事件在发生 L ms 后到达策略:hook 在那时触发,ctx.book()、ctx.history()和仓位估值价显示策略已经听到的市场状态。订单、挂单队列和报价保护始终使用执行时平台自己的盘口。成交发生时ctx.position()就知道它——没有单独的订单响应延迟。 - 两者都不设置时,运行逐字节沿用此前一直使用的逐市场路径。
12. 概率排队模型(可选)¶
第 3 节的规则 4 对没有成交能解释的档位缩量作最不利假设:离开的量全在你的订单后面。queue_model 用前方离开了多少量的估计替换这一假设,采用 hftbacktest 推广的形式:订单前方有 a、后方有 b 时,来自后方的比例为
prob |
来自后方的比例 |
|---|---|
power(n 1–10) |
b^n / (b^n + a^n) |
power2(n 1–10) |
(b / (a + b))^n |
power3(n 1–10) |
1 − (a / (a + b))^n |
log |
ln(1+b) / (ln(1+b) + ln(1+a)) |
log2 |
ln(1+b) / ln(1+a+b) |
无法解释的减量 c 把订单移动到 a − (1 − p)·c + min(0, b − p·c),绝不超过可见数量。
什么算作无法解释,仅根据归档决定,你的订单并不存在于归档中。成交记录从其触达的档位移除数量(先消耗它能解释的盘口减量,即消耗台账);它已移除但盘口尚未显示的量,按价格等待该处下一次减量,由后者吸收一次。只有减量的剩余部分才记入台账并交给模型。bbo 边界只限制排队量上限(被剪掉的交叉档位不能说明其中的量从哪里离开)。每张订单在此基础上保留自己的历史:第 3 节的数量瀑布推进它,入队窗口仍适用,因此订单队列可能滞后于归档档位已知的情况——这只会向对订单不利的方向出错。
明确的限制:两个等待列表都存活 print_lag_ms(800 ms),这是启发式窗口——在成交记录的条目过期后才到达的盘口减量会被视为撤单;在一次盘口更新内被同量补单掩盖的成交记录会一直等待到过期。模型中的比例是归档无法核实的假设。默认仍是保守队列;请两种都运行并比较。报告给出模型名称(maker.queue_model polymarket-queue-v1+prob-v1、maker.queue_prob)。函数只按固定顺序使用 + − × ÷(不使用库的 pow 或 log),因此两个引擎生成相同的位,一致性测试逐位检查。
13. 记录器(按时间网格盯市估值)¶
record_ms: N(market 模式,1000–3,600,000)每隔 N 毫秒 UTC 时间写出各市场的价值:该市场截至当时花费或兑付的现金,加上每个未平仓侧按其代币最优买价计算的价值(忽略深度;没有买价的仓位侧按 0 计——现在卖出能得到的金额,绝不是中间价或成本)。一条规则确保每个点准确:时间 t 的任何变化之前——订单或撤单到达平台、事件、定时器、终止估值、结算——先写出 t 之前的每个网格点,因此一个点是该时刻及之前所有内容处理后的状态,不包含更晚的内容;结算后再取一个最终样本。记录只读取台账和盘口,不改变任何成交(两个引擎都测试了关闭 / 开启)。
报告的 record 块在网格上汇总所有市场(市场在第一个点之前按 0 计,最后一个点之后按最终价值计):点数、最终价值、该账户价值的最大回撤(峰值从 0 开始)及其时间、最差的点、未平仓位价值的最大值和平均值。它能看到 metrics 中已实现 PnL 回撤看不到的内容——未平仓位在平仓前承受的亏损。record.csv 包含账户序列,record_markets.csv 包含每个市场的样本(Python 中的 Result.record / record_markets)。如果一次运行将写出超过 1,000,000 行或 256 MiB 的样本,会停止记录并报告 truncated,不提供序列——绝不提供部分曲线;托管回测在提交时拒绝这种范围,并给出能容纳该范围的最小 record_ms。
14. 即时价格与结算 TWAP 已锁定的部分(可选)¶
Polymarket 的 5 分钟和 15 分钟加密市场按收盘时 Chainlink 的 60 秒 TWAP 结算(raw.cryptoMarketConfig.twapLookbackSeconds)。最后一分钟内,该平均值的一部分已经固定,策略可能想知道固定了多少、处于什么水平。
spot 数据集。 声明它后,会在市场窗口内向每个市场提供其资产的 1 Hz Chainlink 报告流(归档的 prices 目录树),无论该市场使用哪条流结算。它是静默的:不唤醒任何 hook,不是 tick,不进入 ctx.history() 或滚动辅助函数,也不计入已见事件。同一毫秒内,即时价格值先于盘口、成交和 tick 应用,因此该毫秒上的 hook 可以读到它(这是同时间排序规则,不是录制的顺序)。设置行情延迟后,它像其他所有事件一样延迟到达策略。仅限 Polymarket;超出其采集窗口的运行会被拒绝。报告的 spot 块统计收到过任何即时价格值的市场,以及完全没收到的市场。
两者都只读取时间戳不晚于 ctx.now 的值,与 ctx.ref() 相同:session 模式下从另一个市场保留的 ctx 按自己的时钟回答,不会把较晚的值和较早的时间混在一起。
ctx.spot() 返回已到达策略的最新即时价格值 {value, ts_ms},或 null。
ctx.twap_locked() 读取窗口 [close - lookback, close)(lookback 来自市场的结算流:twap60s 为 60 秒,twap30s 为 30 秒;市场不按 TWAP 结算、窗口尚未开始,或其中还没有任何即时价格值到达时,为 null):
value:窗口内截至目前已见即时价格样本的均值,每个行情时间戳一个样本(最先到达的那个),不对缺口插值;samples;窗口开始后经过的elapsed_ms(上限为 lookback);elapsed_fraction= elapsed_ms / lookback——经过的时间,不是确定性;coverage= samples / 已经过的整秒数,上限为 1——归档会缺少部分秒的数据;last_ts_ms:已见最新即时价格值的行情时间;projected= elapsed_fraction × value + (1 − elapsed_fraction) × 最新即时价格:如果价格保持在当前位置时的最终平均值。不是预测。coverage 低于 0.8 或最新即时价格值已超过 5 秒时,为 null。
这是近似。 Chainlink 对比这条逐秒流更细的观测取平均。在我们测量的一个 BTC 日(6,675 个完整窗口)中,从归档即时价格流在 [t - 60 s, t) 上重算官方 twap60s,完全一致的次数为零;误差中位数为 $0.51,第 99 百分位为 $3.45,最差为 $6.35。回测结算绝不使用这个值:市场按官方结果结算(§4)。与每个 tick 一样,事件时间使用行情自身的时间(feed_ts_ms);设置 feed_latency 来模拟你何时会听到一个值。