[原创] 屏幕上的 24h 涨跌幅会影响短线价格吗?——用 FMZ Rust 实现 Roll-Out TradFi 策略

本文研究的是一种由“滚动统计窗口”产生的行为金融假设:我们并不直接预测未来价格,而是提前计算未来几十分钟交易者将看到的 24h 涨跌幅会如何机械变化,再观察价格是否对这种注意力变化产生反馈。当前策略没有直接测量主动订单流,因此本文讨论的是“可能的传导路径”,不是已经得到证明的订单流因果关系。

一、一个看似不起眼的数字

几乎所有加密货币交易界面都会把 24 小时涨跌幅放在非常醒目的位置。涨幅榜、跌幅榜、合约列表和移动端行情页,都在持续强化这个数字。

通常我们把 24h 涨跌幅理解成“过去一天发生了什么”,但它其实是一个不断移动的窗口:

C_t = \frac{P_t}{P_{t-24h}} - 1

它的变化由两部分共同决定:

  1. 当前价格  P_t 接下来怎么走——这是未知的;
  2. 24 小时前的价格  P_{t-24h} 接下来如何移出窗口——这是已经发生、完全已知的。

假设昨天 14:00 至 14:30 出现了一段快速上涨。今天来到相同时间后,那段上涨会逐分钟退出 24h 统计窗口。即使当前价格一动不动,显示的 24h 涨幅也会逐步下降。反过来,昨天的一段快速下跌退出窗口时,显示收益会机械改善。

这件事本身只是数学恒等式,不是 Alpha。真正需要验证的假设是:

可预测的显示数字变化
        ↓
排行榜、筛选器和交易者注意力变化
        ↓
交易行为可能发生变化
        ↓
可交易的价格漂移

句话说,策略预测的不是价格本身,而是市场参与者即将看到什么。

二、公开研究给了我们什么起点

这个思路来自 Robot James 的文章 A Truly Idiotic Crypto Trade,随后由开源项目 OctopusTakopi/24h-rollout-effect 使用 Binance USDT 永续历史档案进行了大样本复现。

公开复现使用的粗粒度规则很简单:

  • 24 小时前最大的上涨小时线开始滚出时,做空一小时;
  • 24 小时前最大的下跌小时线开始滚出时,做多一小时。

研究覆盖 2020—2026 年、包括已下架合约在内的 788 个 Binance USDT 永续合约。结果支持 24h Roll-Out 异常确实存在,而且 1—24 小时的安慰剂检验显示,效果主要集中在恰好第 24 小时。

但研究也给出了比收益数字更重要的结论:原始信号非常薄。高换手会让手续费吞掉大部分毛收益,资金费率会继续侵蚀结果;集中**产生的夸张回测,往往混合了路径运气、复利数学和尾部风险。公开样本中,近年的优势主要集中在 Short 侧,而单笔极端反向行情足以抹掉大量普通交易的利润。

因此,这个公开结果只能证明“值得研究”,不能直接证明一个可实盘策略已经成立。

本文的原创工作不在于重新宣称发现了 24h 异常,而在于把它改造成一个连续分钟模型,迁移到 Binance TradFi 永续场景,并用 FMZ Rust 实现可观察、可模拟、可真实执行的完整工程原型。

三、为什么不直接做“24h 涨得多就空”

当前 24h 涨跌幅只能表示注意力,不能决定交易方向。

例如某合约当前上涨 20%,但 24 小时前对应时间段的价格几乎没动,那么未来半小时并不存在明显的 Roll-Out 催化。此时仅凭“涨得多”做空,本质上只是普通反转策略。

本策略把两个概念严格分开:

Current 24h Change = Attention Filter
Expected Roll-Out Shock = Alpha Trigger

前者用于缩小全市场扫描范围,后者才用于形成候选方向。

四、从一根小时线改造成连续 Roll-Out 曲线

小时线规则默认所有信息都在整点发生,但真实价格路径是连续的。昨天 14:05、14:12 和 14:27 的价格变化,会在今天对应时刻依次退出统计窗口,而不是在 14:00 一次性消失。

设当前价格为P(t) ,24 小时前当前窗口起点价格为 P(t-24h)。假设未来  分钟当前价格暂时不变,届时显示涨跌幅相对现在的机械变化为:

Shock(h) = 100 \times P(t) \times \left[ \frac{1}{P(t-24h+h)} - \frac{1}{P(t-24h)} \right]

因此:

  • Shock(30) > 0:未来显示收益机械改善,候选方向为 Long;
  • Shock(30) < 0:未来显示收益机械恶化,候选方向为 Short;
  • Shock(5) 与 Shock(30) 必须同号,避免旧路径先反向再运动;
  • 5 分钟冲击还要达到最低强度,避免 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
};

五、为什么把第一版放到 TradFi 永续

Binance TradFi 永续把股票、ETF、商品等传统资产映射到 USDT 永续市场。这个场景有几个值得研究的特点:

  1. 标的仍在加密交易界面中以 24h 涨跌幅和排行榜方式展示;
  2. 传统市场开闭盘、盘前盘后和隔夜时段可能形成更有结构的历史路径;
  3. TradFi 合约与纯加密资产的参与者结构、流动性和信息节奏不同,可以作为机制迁移检验;
  4. 测试账户当时显示的被动挂单费率为零,有利于先观察较薄的毛信号;这只是特定账户、特定时间的费率状态,不能外推到其他账户或未来费率。

但“零挂单手续费”不等于零成本。排队失败、逆向选择、价差、资金费率和紧急平仓滑点仍然存在。因此原型使用真实盘口和 Maker 成交约束,不把挂单发出直接当作成交。

 

更重要的是:公开历史研究验证的是 Binance 加密货币 USDT 永续,不是本文的 TradFi 子市场。把机制迁移到 TradFi 是新的待验证假设,不能借用原研究结果作为收益证明。

六、FMZ Rust 原型的数据架构

第一版采用“全市场轻扫描、少量候选深跟踪”的结构:

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,需要先排查网络或接口响应,再重启策略。这个行为适合在后续版本中改成带退避的自动重试。

七、第一版的信号判定

原型默认参数下,一个候选需要同时通过:

  1. abs(Current24hChange) >= 4%
  2. abs(Shock30) >= 0.8 个百分点;
  3. abs(Shock5) >= 0.8 × 0.08 个百分点;
  4. Shock5 与 Shock30 同号;
  5. ticker 不超过 20 秒;
  6. 入场前盘口和逐笔成交时间不超过 5 秒;
  7. 买卖价差不超过 30 bps。

这个设计有意保持简单。原研究资料中还讨论了排行榜冲击、持仓量、资金费率、主动买卖流、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 结果适合筛查问题,不应当当作真实成交回放。

九、Maker 执行与单仓状态机

第一版最多只持有一个事件仓位:

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%:止盈;
  • 最长持有 30 分钟;
  • 信号方向反转;
  • 剩余 30 分钟 Shock 低于入场时的 20%,即催化基本耗尽。

止损属于紧急退出,允许直接使用市价处理剩余仓位;普通退出先尝试 Maker。这里追求的是尾部可控,而不是为了守住零手续费坚持永远只挂单。

十、为什么下单后不能立即判定“订单不存在”

交易所接受订单、开放订单列表更新和历史订单可查询之间可能存在短暂传播延迟。如果下单后立刻查询,一次 null 或“订单不存在”不等于订单创建失败,更不能据此重新发送一张相同订单。

原型设置了 2 秒传播宽限,但没有调用 Sleep(2000) 阻塞策略。订单状态机继续运行,宽限期内只把“暂时查不到”视为传播中;超过宽限后依次检查:

  1. 当前开放订单;
  2. 历史订单;
  3. 单订单查询。

这样 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,不作为调参项。交互也只保留一个暂停/恢复入场按钮。

参数少并不意味着模型粗糙,而是为了让第一轮样本具有可解释性。当前最重要的问题不是把回测曲线调得更漂亮,而是回答三个基本问题:

  1. TradFi 合约的显示 Shock 是否对应后续价格漂移?
  2. 强度是否存在稳定梯度?
  3. 扣除未成交、资金费率和紧急退出后是否仍有剩余收益?

十三、一次 WebSocket 实盘故障及修正

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 做了四项修正:

  • Market 与 Book 使用两条独立连接和订阅集合;
  • 订阅 ACK、错误应答与真实行情数据分开处理;
  • 重连后显示 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 已实现盈亏和手续费统计。

即使数据链路恢复正常,运行截图仍然只能证明系统正在接收和处理数据,并不证明信号有效,更不证明策略盈利。

十四、应该怎样验证,而不是只看总收益

建议先累计事件级数据,再按下面的方法评估:

1. 强度梯度

把事件按 abs(Shock30) 分组,观察方向调整后的 5、15、30 分钟收益是否随强度增加。如果只有某个孤立分组盈利,不构成稳定证据。

2. Long 与 Short 分开

公开研究中存在明显的方向差异和时期变化。TradFi 样本也必须分别统计,不能用总体均值掩盖其中一侧失效。

3. 24h 安慰剂

用 20h、21h、22h、23h 等相邻窗口重复相同计算。如果所有时距都“有效”,观察到的可能只是趋势、反转或日内季节性,而不是 24h 显示窗口机制。

4. 成交与成本

至少记录:

  • Maker 发单次数、成交率和超时率;
  • 信号出现到实际成交的延迟;
  • 成交后最大有利/不利变动;
  • 资金费率、紧急市价退出比例和实际滑点;
  • 不同盘口宽度下的收益差异。

5. 新信息覆盖

Roll-Out 只是一个弱催化。财报、宏观数据、公司新闻或加密市场整体波动都可能完全覆盖它。事件样本应标记传统市场时段和异常行情,避免把更强的信息冲击归因给显示数字。

十五、当前版本明确没有做什么

为了避免误解,v0.1.6 没有实现:

  • 多标的并行持仓;
  • Probe、Build、Core 渐进加仓;
  • 逆势补仓;
  • 全市场未来排名预测;
  • OI、资金费率和主动买卖流过滤;
  • 已被历史样本证明有效的 TradFi 收益模型;
  • 完整的队列位置和冲击成本模型;
  • 历史数据初次加载失败后的自动退避重试;
  • 活动订单或持仓标的退出 Top 6 后,强制保留其 bookTicker、aggTrade 和 kline 细分订阅。

这些不是“漏掉的高级功能”,而是有意留到基础假设通过验证以后。如果连 Shock → Future Return 的稳定梯度都不存在,继续增加参数只会制造过拟合。

十六、结语

24h Roll-Out 最有趣的地方,不是它看起来有多反常,而是它把一个模糊的行为金融故事变成了可提前计算的事件:未来会从统计窗口中删除哪段历史路径,我们现在就知道。

但从数学上的可预测显示变化,到真实可交易收益,中间还隔着注意力、订单流、成交概率、费用、资金费率和尾部风险。公开研究已经说明这个异常真实但脆弱;本文的 TradFi 原型则试图用更连续、更节制、更可观测的方式回答下一步问题。

一个诚实的第一版策略,不应该急着证明自己会赚钱。它首先应该做到:数据链路可靠,信号含义明确,订单状态可对账,失败不会重复下单,最终的正反结果都能够被记录和解释。

这正是这个 FMZ Rust 原型的定位。

策略地址

策略目前只是原型版本,根据实测效果,后续可能会再调整升级,本策略仅为抛砖引玉,公开相互学习,实盘请自行评估、分析、优化使用。

 

免责声明:信息仅供参考,不构成投资及交易建议。投资者据此操作,风险自担。
如果觉得文章对你有用,请随意赞赏收藏
相关推荐
相关下载
登录后评论
Copyright © 2019 宽客在线