本文研究的是一种由“滚动统计窗口”产生的行为金融假设:我们并不直接预测未来价格,而是提前计算未来几十分钟交易者将看到的 24h 涨跌幅会如何机械变化,再观察价格是否对这种注意力变化产生反馈。当前策略没有直接测量主动订单流,因此本文讨论的是“可能的传导路径”,不是已经得到证明的订单流因果关系。
几乎所有加密货币交易界面都会把 24 小时涨跌幅放在非常醒目的位置。涨幅榜、跌幅榜、合约列表和移动端行情页,都在持续强化这个数字。
通常我们把 24h 涨跌幅理解成“过去一天发生了什么”,但它其实是一个不断移动的窗口:
它的变化由两部分共同决定:
假设昨天 14:00 至 14:30 出现了一段快速上涨。今天来到相同时间后,那段上涨会逐分钟退出 24h 统计窗口。即使当前价格一动不动,显示的 24h 涨幅也会逐步下降。反过来,昨天的一段快速下跌退出窗口时,显示收益会机械改善。
这件事本身只是数学恒等式,不是 Alpha。真正需要验证的假设是:
可预测的显示数字变化
↓
排行榜、筛选器和交易者注意力变化
↓
交易行为可能发生变化
↓
可交易的价格漂移
句话说,策略预测的不是价格本身,而是市场参与者即将看到什么。
这个思路来自 Robot James 的文章 A Truly Idiotic Crypto Trade,随后由开源项目 OctopusTakopi/24h-rollout-effect 使用 Binance USDT 永续历史档案进行了大样本复现。
公开复现使用的粗粒度规则很简单:
研究覆盖 2020—2026 年、包括已下架合约在内的 788 个 Binance USDT 永续合约。结果支持 24h Roll-Out 异常确实存在,而且 1—24 小时的安慰剂检验显示,效果主要集中在恰好第 24 小时。
但研究也给出了比收益数字更重要的结论:原始信号非常薄。高换手会让手续费吞掉大部分毛收益,资金费率会继续侵蚀结果;集中**产生的夸张回测,往往混合了路径运气、复利数学和尾部风险。公开样本中,近年的优势主要集中在 Short 侧,而单笔极端反向行情足以抹掉大量普通交易的利润。
因此,这个公开结果只能证明“值得研究”,不能直接证明一个可实盘策略已经成立。
本文的原创工作不在于重新宣称发现了 24h 异常,而在于把它改造成一个连续分钟模型,迁移到 Binance TradFi 永续场景,并用 FMZ Rust 实现可观察、可模拟、可真实执行的完整工程原型。
当前 24h 涨跌幅只能表示注意力,不能决定交易方向。
例如某合约当前上涨 20%,但 24 小时前对应时间段的价格几乎没动,那么未来半小时并不存在明显的 Roll-Out 催化。此时仅凭“涨得多”做空,本质上只是普通反转策略。
本策略把两个概念严格分开:
Current 24h Change = Attention Filter
Expected Roll-Out Shock = Alpha Trigger
前者用于缩小全市场扫描范围,后者才用于形成候选方向。
小时线规则默认所有信息都在整点发生,但真实价格路径是连续的。昨天 14:05、14:12 和 14:27 的价格变化,会在今天对应时刻依次退出统计窗口,而不是在 14:00 一次性消失。
设当前价格为 ,24 小时前当前窗口起点价格为
。假设未来 分钟当前价格暂时不变,届时显示涨跌幅相对现在的机械变化为:
因此:
Shock(30) > 0:未来显示收益机械改善,候选方向为 Long;Shock(30) < 0:未来显示收益机械恶化,候选方向为 Short;Shock(5) 与 Shock(30) 必须同号,避免旧路径先反向再运动;举个简化例子:
当前价格 120
24h 窗口当前起点 100
24h 前再过 30 分钟的价格 115
当前显示涨幅 +20.0%
假设现价不变,30 分钟后 +4.35%
Shock(30) -15.65 个百分点
候选方向 SHORT
需要强调,-15.65 个百分点是显示数字的机械变化,不是价格预计下跌 15.65%。价格究竟会反馈其中多少,必须由实盘样本回答。
策略里的核心计算与公式一一对应,最终方向只由 Shock(30) 的正负号决定:
signal.shock_5_pct = 100.0 * current * (1.0 / old_5 - 1.0 / old_now);
signal.shock_30_pct = 100.0 * current * (1.0 / old_30 - 1.0 / old_now);
signal.direction = if signal.shock_30_pct > 0.0 {
1
} else if signal.shock_30_pct < 0.0 {
-1
} else {
0
};
Binance TradFi 永续把股票、ETF、商品等传统资产映射到 USDT 永续市场。这个场景有几个值得研究的特点:
但“零挂单手续费”不等于零成本。排队失败、逆向选择、价差、资金费率和紧急平仓滑点仍然存在。因此原型使用真实盘口和 Maker 成交约束,不把挂单发出直接当作成交。

更重要的是:公开历史研究验证的是 Binance 加密货币 USDT 永续,不是本文的 TradFi 子市场。把机制迁移到 TradFi 是新的待验证假设,不能借用原研究结果作为收益证明。
第一版采用“全市场轻扫描、少量候选深跟踪”的结构:
Binance exchangeInfo
│
└── 动态发现 TradFi 永续目录
Binance 全市场 WebSocket 24h ticker
│
└── Attention Scanner
│
└── Top-N 候选
├── 最近约 25 小时 1m K 线
├── ticker
├── bookTicker
├── aggTrade
└── 1m kline
│
└── Roll-Out Engine
启动时只通过 REST 读取合约目录。运行阶段的全市场行情和候选增量行情主要使用 WebSocket,主循环用非阻塞读取,不会因为等待一条行情而停止订单对账或状态更新。
目录筛选并不是依赖一份手写白名单,而是读取合约元数据,只保留处于交易状态、以 USDT 计价并带 TradFi 标记的永续产品:
let subtype_tradfi = subtypes.map(|values| values.iter().any(|value| {
value.as_str()
.map(|text| text.eq_ignore_ascii_case("TradFi"))
.unwrap_or(false)
})).unwrap_or(false);
let tradfi_contract = contract_type.eq_ignore_ascii_case("TRADIFI_PERPETUAL")
|| subtype_tradfi;
if !tradfi_contract || status != "TRADING" || quote != "USDT" {
continue;
}
每分钟扫描一次全市场。默认先要求当前 24h 涨跌幅绝对值达到 4%,再按“涨跌幅绝对值 + 温和的成交额权重”排序,只维护前 6 个候选的分钟历史和细分数据流。这样可以避免对上百个合约重复拉取 1500 根 K 线。
候选历史至少需要 1475 根有效 1 分钟 K 线,并按时间去重。计算时优先使用 ticker 返回的真实滚动窗口起止时间,而不是盲目假设窗口永远精确落在本地整分钟。
这里也有一个需要监控的 v0.1.6 边界:候选第一次加载历史失败后,bootstrap_requested_at 不会在当前进程中自动清零,因此不会自动发起第二次加载。SHADOW 阶段如果长期停在 HISTORY_NOT_READY,需要先排查网络或接口响应,再重启策略。这个行为适合在后续版本中改成带退避的自动重试。
原型默认参数下,一个候选需要同时通过:
abs(Current24hChange) >= 4%;abs(Shock30) >= 0.8 个百分点;abs(Shock5) >= 0.8 × 0.08 个百分点;Shock5 与 Shock30 同号;这个设计有意保持简单。原研究资料中还讨论了排行榜冲击、持仓量、资金费率、主动买卖流、Probe 后确认加仓等模块,但第一版全部暂缓。否则,即使策略表现发生变化,也很难判断到底是哪一项产生了作用。
代码中的门控顺序也保持可解释:先看注意力,再看 30 分钟强度,最后确认前 5 分钟方向没有打架。
if signal.current_change_pct.abs() < AttentionThresholdPct {
signal.reason = "ATTENTION_LOW".to_string();
} else if signal.shock_30_pct.abs() < MinShock30Pct {
signal.reason = "SHOCK_LOW".to_string();
} else if signal.shock_5_pct.abs() < MinShock30Pct * 0.08 {
signal.reason = "EARLY_INTENSITY_LOW".to_string();
} else if signal.shock_5_pct.signum() != signal.shock_30_pct.signum() {
signal.reason = "PATH_NOT_MONOTONIC".to_string();
} else {
signal.ready = true;
signal.reason = "READY".to_string();
}
策略提供三个模式:
| 模式 | 行为 | 用途 |
|---|---|---|
SHADOW |
扫描、计算、展示,不建立仓位 | 验证数据和信号方向 |
PAPER |
使用真实盘口和逐笔穿价模拟 Maker 成交 | 估计成交率、滑点和信号收益 |
LIVE |
通过 FMZ exchange 对象真实下单 |
小额生产验证 |
PAPER 并不是“看到信号就按盘口成交”。v0.1.6 会先留出 2 秒传播宽限,然后使用最新收到的 aggTrade 与挂单价比较;买单需要成交价不高于挂单价,卖单则相反。单次入场最长等待 90 秒,但信号失效、数据过期或报价偏离最优价超过 10 bps 时会提前撤销。
let is_buy = (execution.order_purpose == "ENTRY" && execution.direction == 1)
|| (execution.order_purpose == "EXIT" && execution.direction == -1);
if is_buy {
symbol.last_trade <= execution.order_price
} else {
symbol.last_trade >= execution.order_price
}
这仍是近似成交模型:它没有模拟队列位置,也没有为每张模拟订单记录创建时的逐笔序号,因此不能严格证明用于判定的成交一定发生在挂单之后。PAPER 结果适合筛查问题,不应当当作真实成交回放。
第一版最多只持有一个事件仓位:
IDLE
└── READY ──> ENTRY_WORKING(GTX Maker)
├── 超时未成交 ──> IDLE
└── 成交 ──> POSITION
└── 退出条件 ──> EXIT_WORKING
├── Maker成交 ──> IDLE
└── 剩余仓位 ──> 市价清理
普通入场和退出使用 GTX/Post-Only 挂单,避免限价单意外变成 Taker。默认名义金额为 50 USDT,数量并非简单用 金额 ÷ 价格 后直接发送,而是先从 GetMarkets() 读取 CtVal、数量步长、价格步长和最小名义金额,再换算为交易所实际合约数量。
let quote_per_contract = contract_quote_value(spec, meta, price)?;
let amount = round_amount(spec, notional / quote_per_contract);
let actual_notional = amount * quote_per_contract;
if actual_notional + 1e-9 < spec.min_notional {
return Err(format!("notional {} < MinNotional {}",
actual_notional, spec.min_notional));
}
固定的第一版退出条件为:
-0.4%:止损;+0.6%:止盈;止损属于紧急退出,允许直接使用市价处理剩余仓位;普通退出先尝试 Maker。这里追求的是尾部可控,而不是为了守住零手续费坚持永远只挂单。
交易所接受订单、开放订单列表更新和历史订单可查询之间可能存在短暂传播延迟。如果下单后立刻查询,一次 null 或“订单不存在”不等于订单创建失败,更不能据此重新发送一张相同订单。
原型设置了 2 秒传播宽限,但没有调用 Sleep(2000) 阻塞策略。订单状态机继续运行,宽限期内只把“暂时查不到”视为传播中;超过宽限后依次检查:
这样 WebSocket 行情、状态栏和其他对账任务不会被阻塞,也避免因短暂不可见产生重复订单。
LIVE 模式在调用 CreateOrder 之前,先把订单意图持久化。只有拿到非空订单 ID 并再次保存状态后,才清除待处理意图。
核心顺序只有三步,但次序不能颠倒:先保存 Intent,再发单,最后才根据明确结果更新本地订单状态。
下面是从完整错误分支中压缩出来的关键顺序;create_result 随后仍由原策略区分 Maker 明确拒单、确定失败和未知结果:
state.pending_intent = Some(intent);
save_state(runtime, state)?;
let create_result = exchange.CreateOrder(
symbol.meta.fmz.as_str(), side.as_str(), price, amount
);
如果网络在“交易所已经收到订单、策略尚未拿到订单 ID”的瞬间中断,策略不会盲目重发,而是进入自动对账状态。它以非阻塞方式查询开放订单、近期历史订单和持仓:找到订单就接管,发现一致持仓就重建本地状态;只有连续三次查询成功且账户干净,才确认请求未形成订单并恢复运行。
还要区分一个更窄的边界:如果 CreateOrder 走的是成功返回分支,但得到的订单 ID 字符串为空,v0.1.6 会保留 Intent 并进入保守停机。自动对账仍会继续,但停机标记未必能像普通“未知结果”一样自动解除。因此在无人值守 LIVE 之前,应专门测试空 ID 分支;出现后先核对开放订单、历史订单和持仓,不要直接重启并补发。
这套设计解决的是量化交易里最危险的一类问题:
返回失败 ≠ 交易所一定没有执行
此外,策略会拒绝在目标合约存在外部订单或外部持仓时自动入场,撤单只使用本轮查询得到的强类型订单 ID,平仓方向交给 FMZ 的 closebuy / closesell 语义处理,不手工拼接 reduceOnly。

| 参数 | 默认值 | 说明 |
|---|---|---|
RunMode |
SHADOW |
观察、模拟或真实执行 |
AttentionThresholdPct |
4.0 | 全市场注意力门槛 |
MinShock30Pct |
0.8 | 30 分钟机械显示冲击门槛 |
OrderNotionalUSDT |
50 | 单仓目标名义金额 |
深度跟踪候选数固定为 6,不作为调参项。交互也只保留一个暂停/恢复入场按钮。
参数少并不意味着模型粗糙,而是为了让第一轮样本具有可解释性。当前最重要的问题不是把回测曲线调得更漂亮,而是回答三个基本问题:
v0.1.1 的实盘截图曾显示:
LIVE / v0.1.1 RUNNING
TradFi 174 / selected 0
WS age = 2100 ms
reconnects = 2
scan #1 / RUN
execution = IDLE
halt = false
其中 REST 目录识别和执行状态机确实已经启动,但后续连续观察发现重连次数每约 20 秒增加一次。进一步核对 Binance 当前 WebSocket 文档后确认,v0.1.1 把全市场 !ticker@arr、ticker、aggTrade、kline 与 bookTicker 混合发送到了 /public/stream。当前接口已经拆分:前四类属于 /market/stream,bookTicker 属于 /public/stream。
因此这张截图里的 WS age 只是连接或控制消息时间,不能证明收到有效行情;selected 0 也不能据此解释为市场没有越过 4% 门槛。这个案例说明,WebSocket 握手成功不等于订阅有效,存活监控必须只由真实业务数据刷新。
v0.1.2 做了四项修正:
WAIT_DATA,不再把连接时刻伪装成最后行情时间;修正后的正常判据是:Market 状态持续为 DATA_OK、Market age 周期性回落、重连计数不按固定 20 秒增长;有候选时 Book 也应为 DATA_OK,没有候选时显示 IDLE。
后续 LIVE 验证又暴露出另一个容易被忽略的边界:GTX Maker 订单可能因为盘口在请求途中移动而被交易所明确拒绝,这与网络超时造成的“执行结果未知”不是一回事。v0.1.3 开始保留 Rust Err 和 GetLastError() 两份证据;明确 Maker 拒单只进入短暂冷却。v0.1.4 进一步取消人工解除机制:真正未知的结果会锁存 Intent,由非阻塞状态机自动查询开放订单、近期历史和持仓。找到订单便接管,发现一致仓位便重建本地仓位;只有连续三次查询均成功且账户干净,才判定请求未形成订单并自动恢复。
实盘还暴露了连续信号下反复“挂单 20 秒—撤单—重新挂单”的问题。这个策略是时间效应驱动的方向策略,不是持续维护最优价的做市策略;频繁撤挂既损失队列位置,也可能追着已经移动的价格成交。v0.1.6 因此改为每个连续信号只挂一张 Maker 入场单,最长等待 90 秒。信号失效、行情过期或订单落后当前最优价超过 10 bps 时提前撤单;无论成交还是未成交,都要等方向反转,或注意力/Shock 明显回落到带滞回的复位区间后才重新武装,避免信号在阈值附近抖动时反复下单。部分成交直接转为持仓,不追补目标数量。
界面里的 Paper PnL 在 LIVE 模式下保持为零,它只是统一状态栏中保留的模拟绩效字段,不能用来判断真实账户收益。后续更适合单独增加 LIVE 已实现盈亏和手续费统计。
即使数据链路恢复正常,运行截图仍然只能证明系统正在接收和处理数据,并不证明信号有效,更不证明策略盈利。
建议先累计事件级数据,再按下面的方法评估:
把事件按 abs(Shock30) 分组,观察方向调整后的 5、15、30 分钟收益是否随强度增加。如果只有某个孤立分组盈利,不构成稳定证据。
公开研究中存在明显的方向差异和时期变化。TradFi 样本也必须分别统计,不能用总体均值掩盖其中一侧失效。
用 20h、21h、22h、23h 等相邻窗口重复相同计算。如果所有时距都“有效”,观察到的可能只是趋势、反转或日内季节性,而不是 24h 显示窗口机制。
至少记录:
Roll-Out 只是一个弱催化。财报、宏观数据、公司新闻或加密市场整体波动都可能完全覆盖它。事件样本应标记传统市场时段和异常行情,避免把更强的信息冲击归因给显示数字。
为了避免误解,v0.1.6 没有实现:
这些不是“漏掉的高级功能”,而是有意留到基础假设通过验证以后。如果连 Shock → Future Return 的稳定梯度都不存在,继续增加参数只会制造过拟合。
24h Roll-Out 最有趣的地方,不是它看起来有多反常,而是它把一个模糊的行为金融故事变成了可提前计算的事件:未来会从统计窗口中删除哪段历史路径,我们现在就知道。
但从数学上的可预测显示变化,到真实可交易收益,中间还隔着注意力、订单流、成交概率、费用、资金费率和尾部风险。公开研究已经说明这个异常真实但脆弱;本文的 TradFi 原型则试图用更连续、更节制、更可观测的方式回答下一步问题。
一个诚实的第一版策略,不应该急着证明自己会赚钱。它首先应该做到:数据链路可靠,信号含义明确,订单状态可对账,失败不会重复下单,最终的正反结果都能够被记录和解释。
这正是这个 FMZ Rust 原型的定位。
策略目前只是原型版本,根据实测效果,后续可能会再调整升级,本策略仅为抛砖引玉,公开相互学习,实盘请自行评估、分析、优化使用。