Skip to content

More rules - #23

Merged
MathisWellmann merged 12 commits into
mainfrom
more-rules
Oct 29, 2026
Merged

MathisWellmann merged 12 commits into
mainfrom
more-rules

Conversation

@MathisWellmann

Copy link
Copy Markdown
Owner

Changes:

  • add TriggerTrade enum to choose which candle gets the triggering trade
  • add AnyRule, AllRule, MinDuration and MaxDuration combinators
  • add the Adaptive wrapper and ThresholdRule trait
  • add RangeRule for range bars
  • add DirectionalChangeRule
  • add CusumRule, a symmetric CUSUM filter on log returns
  • add VolatilityRule for volatility bars
  • add SignedVolumeRule for order flow (delta) bars
  • add ImbalanceRule for tick, volume or dollar imbalance bars
  • add RunRule for tick, volume and dollar imbalance bars
  • bump to version 13.1.0

… derive crate

The buildkite clippy step was red before any rule work: the derive crate failed
on needless borrows, a collapsible else-if and and_then(Some), and the library
on manual assign operations and a legacy f64 constant. Fixing them first lets
every following revision prove it adds no new clippy findings.
…ng trade

GenericAggregator::new(rule, true) puts the trade that triggers the rule into
both the closing and the next candle, on purpose, so that the close equals the
next open. Components that sum over trades (Volume, NumTrades, ...) then count
that trade twice, which the docs did not say.

with_trigger_trade(rule, TriggerTrade) adds a third mode, ClosingCandle, that
ends a candle with its triggering trade and counts every trade once. new() keeps
its meaning (false = NextCandle, true = Both), so this is not a breaking change.
Tests check trade and volume conservation for all three modes.
The literature on information-driven bars uses hybrid bars (e.g. dollar volume
OR volatility, whichever fires first) and bounds on the duration of a bar, so
that a burst does not produce a flood of tiny bars and a quiet market still
produces bars. These combinators express that over any existing rule.

Combined stateful rules must restart when another rule closes the candle,
otherwise a volume rule keeps the volume of a candle that a price rule closed
and fires early in the next one. AggregationRule gets a provided reset() method
(default no-op, so existing implementations keep compiling); TickRule,
VolumeRule and RelativePriceRule implement it. Time rules keep their grid.
Durations use the trade's own timestamp_resolution().
A fixed tick or volume threshold drifts with the market regime: on Binance
BTCUSDT the same 20k trades span about 2 h in 2018 and 3 min in late 2022.
Adaptive<R> recalibrates the threshold of the inner rule after each of its
triggers to an EWMA of the activity per millisecond times a target candle
duration, clamped to [min, max] (the calibration of dollar bars in AFML ch. 2).

ThresholdRule exposes threshold()/set_threshold() so any rule can be wrapped;
TickRule, VolumeRule and RelativePriceRule implement it. VolumeRule empties a
finished bucket with the old threshold before switching, so the carried excess
volume stays correct. Adaptive::new checks that the rule accepts both bounds,
which makes every clamped threshold valid later.
Range bars close when (high - low) / open of the candle would reach a fraction.
They are a standard bar type of trading platforms (Sierra Chart, NinjaTrader)
and one of the information bars in the 2026 BTCUSDT comparison
(arXiv 2608.26158). Unlike RelativePriceRule, which measures the distance from
the open, a candle that swings both ways closes earlier.

The trade that would reach the range starts the next range, so with
TriggerTrade::NextCandle every closed candle stays below the threshold; a test
checks that on the Bitmex sample. Implements reset (new range on the next
trade) and ThresholdRule, so Adaptive and the combinators work with it.
Directional-change sampling (Guillaume et al. 1997; Glattfelder, Dupuis, Olsen
2011; Tsang's formal definition) cuts the price path where it reverses by a
fraction from the last extreme. It has well-studied scaling laws (the
overshoot is about the threshold on average) and needs little state: the trend
and its extreme.

Candles split at the confirmation points, because an extreme is only known in
hindsight. reset() keeps the trend, since it follows the price path rather
than the candle. The threshold must be in (0, 1) so that a downward change is
reachable. Tests cover both first directions, a pure trend, reset, and that
larger thresholds confirm fewer changes on the Bitmex sample.
The symmetric CUSUM filter (Lam & Yam 1997; AFML 2.5.2.1) samples when the
price has drifted in one direction: small pullbacks do not restart it and
noise around a level does not add up. In a 2025 Financial Innovation study on
BTC/ETH 2018-2023, CUSUM sampling with triple-barrier labels beat time bars
after costs.

reset() restarts both sums but keeps the last price as the reference for the
next return. Tests cover drifts with pullbacks, both directions, pure noise,
the restart of both sums after firing, and reset.
Volatility bars close when the sum of absolute log returns between trades
reaches a threshold, so sampling follows realized price movement. In the
BTCUSDT comparison of arXiv 2608.26158, volatility bars from trades had about
69% less serial dependence than bars from minute data.

The docs warn that bid-ask bounce adds up without real movement and point to
CusumRule, which ignores such noise; a test shows the difference on bouncing
prices. reset() restarts the sum and keeps the last price as reference.
Signed-volume bars close when the net taker volume (buys minus sells, from the
sign of TakerTrade::size) reaches a threshold in either direction, so balanced
flow keeps a candle open and one-sided, informed flow closes it. This is the
fixed-threshold form of AFML's volume imbalance bars, whose expected-imbalance
threshold is known to collapse or explode (quant.SE #53038); Adaptive gives a
threshold that follows the market instead.

By::Quote / By::Base work as for VolumeRule. Tests compare it with VolumeRule
on balanced flow and cover both directions, By::Base and reset.
Imbalance bars (AFML ch. 2.3.2) close when the net signed flow of a bar exceeds
E[T] * |E[b * x]|: the expected bar length times the expected imbalance per
trade. The side comes from the sign of TakerTrade::size, which is exact, unlike
the book's tick rule.

The book's threshold is known to collapse or explode (quant.SE #53038): with
balanced flow |E[b * x]| goes to 0 and every trade closes a bar, and learning
E[T] from closed bars has an unstable fixed point. FlowBarParams therefore
- keeps E[T] fixed by default (mlfinlab's const variant),
- floors |E[b * x]| at min_flow_fraction * E[|x|]; a balanced market then
  gives bars of about (min_flow_fraction * E[T])^2 trades,
- clamps a learned E[T] (with_bar_alpha) to [min_ticks, max_ticks].
ThresholdRule reads and sets E[T], so Adaptive can calibrate it to a target
duration instead. The shared flow_bars module (FlowSignal, FlowBarParams, Ewma)
is reused by the run bars in the next revision.

Tests: hand-computed tick and volume cases, seeded random flow (one-sided bars
of about E[T], balanced bars set by the floor, learned E[T] within bounds).
Run bars (AFML ch. 2.3.2.3-4) close when the flow of one side within a bar,
max(buys, sells), exceeds E[T] * max(E[x * 1_buy], E[x * 1_sell]). The trades
of a side need not be consecutive, so a large order executed in slices closes
a bar even when other trades are mixed in, where the net flow of an
ImbalanceRule partly cancels; a test shows this on alternating flow.

Reuses FlowSignal/FlowBarParams/Ewma from the imbalance revision. The expected
flow per side is at least half the average flow, so no floor is needed, but
balanced flow gives bars somewhat shorter than E[T] and a learned E[T] drifts
to its lower bound; the docs say so and recommend a fixed E[T], or Adaptive
via ThresholdRule (= E[T]). A test checks that Adaptive<RunRule> settles at
bars of the target duration.
The revisions since 13.0.1 add public API (TriggerTrade, the combinators,
Adaptive/ThresholdRule, five new rules, the provided AggregationRule::reset)
without breaking any, so this is a minor bump. The README usage snippet still
said version 12; it now says 13. The derive crate keeps 0.4.1: its changes are
lint fixes without any change to the generated code.
@MathisWellmann MathisWellmann self-assigned this Oct 29, 2026
@MathisWellmann
MathisWellmann merged commit 0301f0c into main Oct 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant