Skip to main content

sharpebench_core/
entrants.rs

1//! Reference entrants: specified, deterministic signal primitives.
2//!
3//! Every entrant the paper has reported so far was written by the author, which
4//! leaves a reader unable to tell a strict gate from a weak field. The harness
5//! already scores four rules taken from the published literature (Donchian
6//! channel breakout, the Brock-Lakonishok-LeBaron variable moving average,
7//! Faber's ten-month filter, Wilder's RSI). This module adds seven more in the
8//! same spirit, drawn from patterns that are widely published and widely traded.
9//!
10//! These are **entrants, not infrastructure**. Nothing in the scoring kernel
11//! calls them. They exist to be scored *by* it, through exactly the gates every
12//! other entrant passes through, so the field has specified opponents whose
13//! behavior a reader can check line by line.
14//!
15//! House rules, which are what make them scoreable:
16//!
17//! - **State in, state out.** Every stateful primitive takes its previous state
18//!   by value and returns the next one. There is no hidden state, no interior
19//!   mutability, and no clock: replaying the same inputs from the same starting
20//!   state under the pinned implementation reproduces the same outputs. The
21//!   workspace's cross-platform evidence is limited to two committed goldens
22//!   checked on Linux, macOS, and Windows.
23//! - **Caller-supplied thresholds.** No primitive fits a parameter to the data
24//!   it is fed. Defaults are published starting points, named as such, not
25//!   values discovered here.
26//! - **Pre-computed numeric inputs.** RSI, ADX, ATR and moving averages arrive
27//!   as numbers, so an entrant can be wired onto any bar source without this
28//!   module owning an indicator library.
29//! - **No I/O and no randomness.**
30//!
31//! Each primitive's docstring names its source and its parameters.
32
33use serde::{Deserialize, Serialize};
34
35/// Which way a primitive wants to be positioned.
36#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum Side {
39    #[default]
40    None,
41    Long,
42    Short,
43}
44
45impl Side {
46    /// The side that closes this one.
47    pub fn opposite(self) -> Side {
48        match self {
49            Side::Long => Side::Short,
50            Side::Short => Side::Long,
51            Side::None => Side::None,
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// 1. Regime-modulated RSI.
58// ---------------------------------------------------------------------------
59
60/// The trend context a regime-aware rule switches on.
61///
62/// Deliberately coarse: three buckets, because the point of the rule is that a
63/// reading means different things in a trend than in a range, and a finer
64/// taxonomy would need fitting.
65#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum TrendRegime {
68    /// A confirmed uptrend, or a breakout from one.
69    Bull,
70    /// A confirmed downtrend, or a volatile break lower.
71    Bear,
72    /// Ranging or quiet: no directional context.
73    Idle,
74}
75
76/// The overbought/oversold band pair for one regime, with its ADX modulation.
77#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
78pub struct RsiBands {
79    /// RSI level above which the rule goes short.
80    pub rsi_high: f64,
81    /// RSI level below which the rule goes long.
82    pub rsi_low: f64,
83    /// Added to `rsi_high` when ADX is above `RegimeRsiParams::adx_high`.
84    pub mod_high: f64,
85    /// Added to `rsi_low` when ADX is below `RegimeRsiParams::adx_low`. Negative
86    /// in the published defaults: a weak trend pushes the oversold line down.
87    pub mod_low: f64,
88}
89
90/// Parameters of the regime-modulated RSI rule.
91#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
92pub struct RegimeRsiParams {
93    pub bull: RsiBands,
94    pub bear: RsiBands,
95    pub idle: RsiBands,
96    /// ADX above this counts as a strong trend.
97    pub adx_high: f64,
98    /// ADX below this counts as a weak trend.
99    pub adx_low: f64,
100}
101
102impl Default for RegimeRsiParams {
103    /// The conventional starting point, not a fitted set.
104    ///
105    /// Bull: slow to short into strength (75), eager to buy dips (35). Bear:
106    /// eager to short rallies (60), slow to catch knives (25). Idle: the
107    /// textbook symmetric 70/30. ADX 30/15 are Wilder's usual strong/weak marks.
108    fn default() -> Self {
109        Self {
110            bull: RsiBands {
111                rsi_high: 75.0,
112                rsi_low: 35.0,
113                mod_high: 5.0,
114                mod_low: -5.0,
115            },
116            bear: RsiBands {
117                rsi_high: 60.0,
118                rsi_low: 25.0,
119                mod_high: 5.0,
120                mod_low: -5.0,
121            },
122            idle: RsiBands {
123                rsi_high: 70.0,
124                rsi_low: 30.0,
125                mod_high: 3.0,
126                mod_low: -3.0,
127            },
128            adx_high: 30.0,
129            adx_low: 15.0,
130        }
131    }
132}
133
134/// What the regime-modulated RSI rule decided, and the bands it decided against.
135#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
136pub struct RegimeRsiSignal {
137    pub side: Side,
138    pub regime: TrendRegime,
139    /// The overbought line after ADX modulation.
140    pub effective_high: f64,
141    /// The oversold line after ADX modulation.
142    pub effective_low: f64,
143}
144
145/// Regime-modulated RSI: the same RSI reading, read differently by trend context.
146///
147/// **Source.** Wilder's RSI (J. Welles Wilder Jr., *New Concepts in Technical
148/// Trading Systems*, 1978) with its 70/30 bands, combined with the standard
149/// practitioner adjustment that oscillator bands should shift with trend
150/// strength, measured by Wilder's ADX from the same book. In a confirmed
151/// uptrend a low RSI is a dip to buy; in a downtrend the same reading is a
152/// falling knife, and the rally to fade is the high one.
153///
154/// **Parameters.** `params` carries three band pairs (bull, bear, idle) and the
155/// two ADX cutoffs. A strong trend (`adx > adx_high`) raises the overbought line
156/// by `mod_high`, so the rule does not fade strength. A weak trend
157/// (`adx < adx_low`) shifts the oversold line by `mod_low`, so it does not buy a
158/// slow bleed. Only one modulation can apply, strong taking precedence.
159///
160/// **Inputs.** `rsi` and `adx` are pre-computed at the current bar. Stateless.
161pub fn regime_rsi(
162    regime: TrendRegime,
163    rsi: f64,
164    adx: f64,
165    params: &RegimeRsiParams,
166) -> RegimeRsiSignal {
167    let bands = match regime {
168        TrendRegime::Bull => params.bull,
169        TrendRegime::Bear => params.bear,
170        TrendRegime::Idle => params.idle,
171    };
172    let mut effective_high = bands.rsi_high;
173    let mut effective_low = bands.rsi_low;
174    if adx > params.adx_high {
175        effective_high += bands.mod_high;
176    } else if adx < params.adx_low {
177        effective_low += bands.mod_low;
178    }
179
180    let side = if rsi > effective_high {
181        Side::Short
182    } else if rsi < effective_low {
183        Side::Long
184    } else {
185        Side::None
186    };
187    RegimeRsiSignal {
188        side,
189        regime,
190        effective_high,
191        effective_low,
192    }
193}
194
195// ---------------------------------------------------------------------------
196// 2. Bounce counter.
197// ---------------------------------------------------------------------------
198
199/// Which extreme zone an oscillator currently sits in.
200#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
201#[serde(rename_all = "snake_case")]
202pub enum Zone {
203    High,
204    Low,
205    #[default]
206    Neutral,
207}
208
209/// State of the bounce counter. Owned by the caller.
210#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
211pub struct BounceState {
212    pub zone: Zone,
213    /// Bars spent in the current zone, counting the bar it was entered on.
214    pub duration: u32,
215    /// Re-entries into an extreme zone since the last reset.
216    pub bounces: u32,
217    /// True once the zone has been left, arming the next entry to count.
218    pub armed: bool,
219    /// Consecutive neutral bars.
220    pub flats: u32,
221    /// True once this occupancy of the zone has fired, so it fires once.
222    pub fired: bool,
223}
224
225/// Parameters of the bounce counter.
226#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
227pub struct BounceParams {
228    /// Oscillator level above which the zone is `High`.
229    pub high: f64,
230    /// Oscillator level below which the zone is `Low`.
231    pub low: f64,
232    /// Bars the zone must be held before it can fire.
233    pub persistence: u32,
234    /// Re-entries required before a persisted zone fires.
235    pub required_bounces: u32,
236    /// Consecutive neutral bars that reset the bounce count.
237    pub reset_after_flats: u32,
238}
239
240impl Default for BounceParams {
241    /// The textbook 70/30 oscillator bands, three bars of persistence, two
242    /// bounces, and a fifty-bar amnesia. Starting points, not fitted values.
243    fn default() -> Self {
244        Self {
245            high: 70.0,
246            low: 30.0,
247            persistence: 3,
248            required_bounces: 2,
249            reset_after_flats: 50,
250        }
251    }
252}
253
254/// Bounce counter: require a repeated visit to an extreme before acting on it.
255///
256/// **Source.** The practitioner observation, standard in divergence-based
257/// oscillator trading, that a single touch of an overbought or oversold level is
258/// noise: a slow grind revisits the same level repeatedly and only a later visit
259/// marks the turn. The rule counts *re-entries* into the extreme zone after the
260/// oscillator has left it, and fires only when the zone has both persisted and
261/// been re-entered often enough. It is a confirmation filter, not a signal
262/// generator, so it always fades the extreme: a persisted `High` fires short.
263///
264/// **Parameters.** `high` and `low` are the zone boundaries, `persistence` the
265/// bars the zone must be held, `required_bounces` the re-entries demanded, and
266/// `reset_after_flats` the run of neutral bars that clears the count.
267///
268/// **State.** Takes the previous [`BounceState`] and returns the next one along
269/// with the side to trade. Fires at most once per occupancy of a zone.
270pub fn bounce_counter(prev: BounceState, oscillator: f64, p: &BounceParams) -> (Side, BounceState) {
271    let zone = if oscillator > p.high {
272        Zone::High
273    } else if oscillator < p.low {
274        Zone::Low
275    } else {
276        Zone::Neutral
277    };
278
279    let mut next = prev;
280    if zone != prev.zone {
281        if zone == Zone::Neutral {
282            next.armed = true;
283        } else if prev.armed {
284            next.bounces = prev.bounces + 1;
285            next.armed = false;
286        }
287        next.zone = zone;
288        next.duration = 1;
289        next.fired = false;
290        next.flats = if zone == Zone::Neutral {
291            prev.flats + 1
292        } else {
293            0
294        };
295    } else {
296        next.duration = prev.duration + 1;
297        if zone == Zone::Neutral {
298            next.flats = prev.flats + 1;
299            if next.flats >= p.reset_after_flats {
300                next.bounces = 0;
301                next.flats = 0;
302                next.armed = false;
303            }
304        } else {
305            next.flats = 0;
306        }
307    }
308
309    let mut side = Side::None;
310    if !next.fired
311        && next.zone != Zone::Neutral
312        && next.duration >= p.persistence
313        && next.bounces >= p.required_bounces
314    {
315        side = if next.zone == Zone::High {
316            Side::Short
317        } else {
318            Side::Long
319        };
320        next.fired = true;
321    }
322    (side, next)
323}
324
325// ---------------------------------------------------------------------------
326// 3. Signal gate (trend confirmation before execution).
327// ---------------------------------------------------------------------------
328
329/// What the signal gate did with the raw signal it was handed.
330#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
331#[serde(rename_all = "snake_case")]
332pub enum GateStatus {
333    /// The moving averages already agreed, so the signal went straight through.
334    ExecutedImmediately,
335    /// A parked signal was released once the moving averages agreed.
336    ExecutedAfterConfirmation,
337    /// No signal to act on.
338    Passthrough,
339    /// The signal is parked, waiting for confirmation.
340    Pending,
341    /// A signal was dropped because of reversal or the waiting limit.
342    Cancelled,
343}
344
345/// State of the signal gate. Owned by the caller.
346#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
347pub struct GateState {
348    pub pending: Side,
349    /// Price when the parked signal was first raised.
350    pub pending_price: f64,
351    /// Bars the current signal has been parked.
352    pub waited: u32,
353}
354
355/// What the signal gate decided this bar.
356#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
357pub struct GateDecision {
358    pub execute: Side,
359    pub status: GateStatus,
360    /// Fill improvement from waiting, as a fraction of the signal price and
361    /// signed so positive is better. Present only on
362    /// [`GateStatus::ExecutedAfterConfirmation`].
363    pub benefit: Option<f64>,
364}
365
366/// Signal gate: hold an entry until a trend-following reference confirms it.
367///
368/// **Source.** The moving-average crossover as a timing reference, the oldest
369/// published trend filter there is, used here in the role Brock, Lakonishok and
370/// LeBaron (1992) tested it in: not as the signal, but as the confirmation that
371/// the trend has actually turned. An oscillator can be right about direction and
372/// early about timing, so the gate separates *what* to trade from *when*.
373///
374/// **Parameters.** `fast_ma` and `slow_ma` are pre-computed at the current bar;
375/// a long is confirmed when `fast_ma >= slow_ma` and a short when
376/// `fast_ma <= slow_ma`. `max_wait` bounds how long a signal may sit parked; a
377/// signal is cancelled at the wait limit rather than filled on stale conviction.
378/// Parking counts as wait 1; each later call advances that count before checking
379/// expiry. A zero or one-bar limit does not permit parking, but a fresh signal
380/// that is already confirmed can still execute immediately.
381///
382/// **State.** Takes the previous [`GateState`] and returns the next one. A
383/// parked signal is cancelled if a fresh signal points the other way. Reversal
384/// or expiry takes precedence over confirmation of the old signal, including
385/// equal moving averages. Cancellation clears the state and consumes this bar's
386/// raw signal; it does not open the opposite position in the same call. A fresh
387/// same-direction signal does not restart the parked signal's clock.
388pub fn signal_gate(
389    prev: GateState,
390    raw: Side,
391    fast_ma: f64,
392    slow_ma: f64,
393    price: f64,
394    max_wait: u32,
395) -> (GateDecision, GateState) {
396    let confirms = |side: Side| match side {
397        Side::Long => fast_ma >= slow_ma,
398        Side::Short => fast_ma <= slow_ma,
399        Side::None => false,
400    };
401    let cancelled = || {
402        (
403            GateDecision {
404                execute: Side::None,
405                status: GateStatus::Cancelled,
406                benefit: None,
407            },
408            GateState::default(),
409        )
410    };
411
412    if prev.pending != Side::None {
413        let waited = prev.waited.saturating_add(1);
414        if (raw != Side::None && raw != prev.pending) || waited >= max_wait {
415            return cancelled();
416        }
417        if confirms(prev.pending) {
418            let benefit = if prev.pending_price == 0.0 {
419                0.0
420            } else if prev.pending == Side::Long {
421                (prev.pending_price - price) / prev.pending_price
422            } else {
423                (price - prev.pending_price) / prev.pending_price
424            };
425            return (
426                GateDecision {
427                    execute: prev.pending,
428                    status: GateStatus::ExecutedAfterConfirmation,
429                    benefit: Some(benefit),
430                },
431                GateState::default(),
432            );
433        }
434        return (
435            GateDecision {
436                execute: Side::None,
437                status: GateStatus::Pending,
438                benefit: None,
439            },
440            GateState { waited, ..prev },
441        );
442    }
443
444    if raw == Side::None {
445        return (
446            GateDecision {
447                execute: Side::None,
448                status: GateStatus::Passthrough,
449                benefit: None,
450            },
451            prev,
452        );
453    }
454
455    if confirms(raw) {
456        return (
457            GateDecision {
458                execute: raw,
459                status: GateStatus::ExecutedImmediately,
460                benefit: None,
461            },
462            prev,
463        );
464    }
465
466    if max_wait <= 1 {
467        return cancelled();
468    }
469    (
470        GateDecision {
471            execute: Side::None,
472            status: GateStatus::Pending,
473            benefit: None,
474        },
475        GateState {
476            pending: raw,
477            pending_price: price,
478            waited: 1,
479        },
480    )
481}
482
483// ---------------------------------------------------------------------------
484// 4. Max-exposure timeout (the time stop).
485// ---------------------------------------------------------------------------
486
487/// State of the time stop. Owned by the caller.
488#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
489pub struct ExposureState {
490    /// Side of the open position, or `None` when flat.
491    pub side: Side,
492    pub entry_price: f64,
493    pub bars_held: u32,
494}
495
496/// What the time stop decided this bar.
497#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
498pub struct ExposureDecision {
499    /// The trade to place now. `None` means hold.
500    pub action: Side,
501    /// True when the action was forced by the clock rather than by a signal, so
502    /// an attribution can separate time stops from thesis changes.
503    pub forced_exit: bool,
504}
505
506/// Time stop: close a position after a fixed number of bars regardless of P&L.
507///
508/// **Source.** The time stop, standard in mean-reversion practice and stated
509/// explicitly as one of the three exit types in Perry Kaufman's *Trading Systems
510/// and Methods*. A mean-reversion entry fails in two ways: the move continues
511/// against you, which a price stop handles, or the price chops sideways and
512/// never resolves, which a price stop never sees. The second failure is
513/// invisible to a P&L-only exit and quietly ties up capital, so it is capped by
514/// the clock instead.
515///
516/// **Parameters.** `max_bars` is the holding limit. It is the only parameter;
517/// the rule takes its entries from whatever upstream signal is handed to it.
518///
519/// **State.** Takes the previous [`ExposureState`] and returns the next one. An
520/// opposing signal closes the position normally and is not reported as forced.
521pub fn max_exposure_timeout(
522    prev: ExposureState,
523    signal: Side,
524    price: f64,
525    max_bars: u32,
526) -> (ExposureDecision, ExposureState) {
527    if prev.side == Side::None {
528        if signal == Side::None {
529            return (
530                ExposureDecision {
531                    action: Side::None,
532                    forced_exit: false,
533                },
534                prev,
535            );
536        }
537        return (
538            ExposureDecision {
539                action: signal,
540                forced_exit: false,
541            },
542            ExposureState {
543                side: signal,
544                entry_price: price,
545                bars_held: 0,
546            },
547        );
548    }
549
550    let bars_held = prev.bars_held + 1;
551    if bars_held >= max_bars {
552        return (
553            ExposureDecision {
554                action: prev.side.opposite(),
555                forced_exit: true,
556            },
557            ExposureState::default(),
558        );
559    }
560    if signal != Side::None && signal != prev.side {
561        return (
562            ExposureDecision {
563                action: signal,
564                forced_exit: false,
565            },
566            ExposureState::default(),
567        );
568    }
569    (
570        ExposureDecision {
571            action: Side::None,
572            forced_exit: false,
573        },
574        ExposureState { bars_held, ..prev },
575    )
576}
577
578// ---------------------------------------------------------------------------
579// 5. ATR breakout with a volatility-expansion filter.
580// ---------------------------------------------------------------------------
581
582/// Parameters of the ATR breakout rule.
583#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
584pub struct AtrBreakoutParams {
585    /// Entry-stop distance from the bar open, in units of the long ATR.
586    pub entry_multiplier: f64,
587    /// Exit-stop distance from the entry, in units of the long ATR. Tighter than
588    /// the entry multiplier in the published defaults, so losers are cut faster
589    /// than winners are let run.
590    pub exit_multiplier: f64,
591    /// The short ATR must exceed this multiple of the long ATR for the breakout
592    /// to arm. Above 1.0 means volatility must be actively expanding.
593    pub expansion_ratio: f64,
594    /// Long ATR below this is treated as too quiet to trade.
595    pub min_atr_long: f64,
596}
597
598impl Default for AtrBreakoutParams {
599    /// The conventional 2.5x entry and 1.0x exit pair with a 1.0 expansion
600    /// requirement. Published starting points for index futures on intraday
601    /// bars, not values fitted here.
602    fn default() -> Self {
603        Self {
604            entry_multiplier: 2.5,
605            exit_multiplier: 1.0,
606            expansion_ratio: 1.0,
607            min_atr_long: 0.0,
608        }
609    }
610}
611
612/// Why the ATR breakout rule did or did not arm.
613#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
614#[serde(rename_all = "snake_case")]
615pub enum AtrBreakoutVerdict {
616    /// Stops are live at the returned levels.
617    Armed,
618    /// Volatility is not expanding: the short ATR did not clear the ratio.
619    FilteredNoExpansion,
620    /// The long ATR is below `min_atr_long`.
621    FilteredLowVol,
622    /// A non-finite or non-positive input.
623    InvalidInput,
624}
625
626/// The stop levels an armed ATR breakout places.
627#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
628pub struct AtrBreakoutLevels {
629    pub long_entry_stop: f64,
630    pub short_entry_stop: f64,
631    /// Distance from the entry to its protective stop.
632    pub exit_stop_distance: f64,
633    pub long_stop_loss: f64,
634    pub short_stop_loss: f64,
635}
636
637/// What the ATR breakout rule decided at this bar.
638#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
639pub struct AtrBreakoutSignal {
640    pub verdict: AtrBreakoutVerdict,
641    /// `short_atr / long_atr`. 0.0 when the inputs were rejected.
642    pub expansion_ratio: f64,
643    /// Present only when the verdict is [`AtrBreakoutVerdict::Armed`].
644    pub levels: Option<AtrBreakoutLevels>,
645}
646
647/// ATR breakout: bracketing stops at a volatility-scaled distance, armed only
648/// while volatility is expanding.
649///
650/// **Source.** Wilder's Average True Range (J. Welles Wilder Jr., *New Concepts
651/// in Technical Trading Systems*, 1978) used in its volatility-breakout role:
652/// place stop entries above and below the bar's open at a multiple of ATR, so
653/// the trigger distance scales with the market's own noise instead of a fixed
654/// point value. The expansion filter is the standard refinement that a breakout
655/// worth taking happens while range is widening, measured as a short ATR above a
656/// long one; a breakout into contracting range is the classic false break.
657///
658/// **Parameters.** `entry_multiplier` and `exit_multiplier` scale the entry and
659/// protective-stop distances by the long ATR. `expansion_ratio` is the
660/// short-over-long ATR the rule requires. `min_atr_long` is a floor below which
661/// the market is treated as untradeably quiet.
662///
663/// **Inputs.** `bar_open`, `atr_short` and `atr_long` are pre-computed.
664/// Stateless: the rule re-derives its levels from scratch each bar.
665pub fn atr_breakout(
666    bar_open: f64,
667    atr_short: f64,
668    atr_long: f64,
669    p: &AtrBreakoutParams,
670) -> AtrBreakoutSignal {
671    if !bar_open.is_finite()
672        || !atr_short.is_finite()
673        || !atr_long.is_finite()
674        || atr_long <= 0.0
675        || atr_short < 0.0
676    {
677        return AtrBreakoutSignal {
678            verdict: AtrBreakoutVerdict::InvalidInput,
679            expansion_ratio: 0.0,
680            levels: None,
681        };
682    }
683    let ratio = atr_short / atr_long;
684    if atr_long < p.min_atr_long {
685        return AtrBreakoutSignal {
686            verdict: AtrBreakoutVerdict::FilteredLowVol,
687            expansion_ratio: ratio,
688            levels: None,
689        };
690    }
691    if ratio <= p.expansion_ratio {
692        return AtrBreakoutSignal {
693            verdict: AtrBreakoutVerdict::FilteredNoExpansion,
694            expansion_ratio: ratio,
695            levels: None,
696        };
697    }
698
699    let entry_distance = atr_long * p.entry_multiplier;
700    let exit_distance = atr_long * p.exit_multiplier;
701    let long_entry_stop = bar_open + entry_distance;
702    let short_entry_stop = bar_open - entry_distance;
703    AtrBreakoutSignal {
704        verdict: AtrBreakoutVerdict::Armed,
705        expansion_ratio: ratio,
706        levels: Some(AtrBreakoutLevels {
707            long_entry_stop,
708            short_entry_stop,
709            exit_stop_distance: exit_distance,
710            long_stop_loss: long_entry_stop - exit_distance,
711            short_stop_loss: short_entry_stop + exit_distance,
712        }),
713    }
714}
715
716// ---------------------------------------------------------------------------
717// 6 and 7. Distribution days and the follow-through day.
718// ---------------------------------------------------------------------------
719
720/// One bar of the index series the two O'Neil rules read.
721#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
722pub struct IndexBar {
723    pub high: f64,
724    pub low: f64,
725    pub close: f64,
726    pub volume: f64,
727}
728
729/// Parameters of the distribution-day count.
730#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
731pub struct DistributionParams {
732    /// Close-to-close fraction, negative, at or below which a session counts as
733    /// a down day.
734    pub down_threshold: f64,
735    /// Trailing sessions over which live days are counted.
736    pub window: usize,
737    /// Rally from a distribution day's close that invalidates it.
738    pub invalidation_gain: f64,
739    /// Whether volume must exceed the prior session's.
740    pub volume_must_rise: bool,
741}
742
743impl Default for DistributionParams {
744    /// O'Neil's published figures: a down close of at least 0.2% on rising
745    /// volume, counted over 25 sessions, cancelled by a 5% rally.
746    fn default() -> Self {
747        Self {
748            down_threshold: -0.002,
749            window: 25,
750            invalidation_gain: 0.05,
751            volume_must_rise: true,
752        }
753    }
754}
755
756/// How heavy the distribution is.
757#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
758#[serde(rename_all = "snake_case")]
759pub enum DistributionSeverity {
760    Normal,
761    Caution,
762    High,
763    Severe,
764}
765
766/// The distribution-day count over a series.
767#[derive(Clone, Debug, Serialize, PartialEq)]
768pub struct DistributionCount {
769    /// Days in the trailing window that are neither expired nor invalidated.
770    pub live: usize,
771    pub last_5: usize,
772    pub last_15: usize,
773    pub last_25: usize,
774    /// Days cancelled by the rally rule.
775    pub invalidated: usize,
776    pub severity: DistributionSeverity,
777}
778
779/// Distribution days: counting institutional selling into a rally.
780///
781/// **Source.** William J. O'Neil's CANSLIM market-direction rule, stated in *How
782/// to Make Money in Stocks*: a distribution day is an index session that closes
783/// down at least about 0.2% on volume higher than the prior session, and the
784/// count of such days in the trailing 25 sessions is the pressure gauge. Three
785/// to four is caution, five is high, six or more reads as heavy institutional
786/// selling. A day is cancelled once the index has since closed 5% above that
787/// day's close, because the selling was absorbed.
788///
789/// **Parameters.** `down_threshold`, `window`, `invalidation_gain` and
790/// `volume_must_rise` carry O'Neil's published figures as defaults.
791///
792/// **Inputs.** The full bar series, oldest first. Stateless: the count is a pure
793/// function of the series. Fewer than two bars yields a `Normal` zero count.
794pub fn distribution_days(bars: &[IndexBar], p: &DistributionParams) -> DistributionCount {
795    let mut count = DistributionCount {
796        live: 0,
797        last_5: 0,
798        last_15: 0,
799        last_25: 0,
800        invalidated: 0,
801        severity: DistributionSeverity::Normal,
802    };
803    if bars.len() < 2 {
804        return count;
805    }
806    let last = bars.len() - 1;
807
808    for i in 1..bars.len() {
809        let (bar, prev) = (bars[i], bars[i - 1]);
810        if prev.close <= 0.0 {
811            continue;
812        }
813        let change = (bar.close - prev.close) / prev.close;
814        if change > p.down_threshold {
815            continue;
816        }
817        if p.volume_must_rise && bar.volume <= prev.volume {
818            continue;
819        }
820
821        let sessions_ago = last - i;
822        let expired = sessions_ago >= p.window;
823        let invalidation_level = bar.close * (1.0 + p.invalidation_gain);
824        let invalidated = bars[i + 1..].iter().any(|b| b.close >= invalidation_level);
825
826        if invalidated {
827            count.invalidated += 1;
828            continue;
829        }
830        if expired {
831            continue;
832        }
833        count.live += 1;
834        if sessions_ago < 5 {
835            count.last_5 += 1;
836        }
837        if sessions_ago < 15 {
838            count.last_15 += 1;
839        }
840        if sessions_ago < 25 {
841            count.last_25 += 1;
842        }
843    }
844
845    count.severity = match count.live {
846        0..=2 => DistributionSeverity::Normal,
847        3..=4 => DistributionSeverity::Caution,
848        5 => DistributionSeverity::High,
849        _ => DistributionSeverity::Severe,
850    };
851    count
852}
853
854/// Parameters of the follow-through-day rule.
855#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
856pub struct FollowThroughParams {
857    /// Earliest rally day on which a follow-through can qualify.
858    pub min_rally_day: u32,
859    /// Minimum up-close fraction for a follow-through day.
860    pub gain_threshold: f64,
861}
862
863impl Default for FollowThroughParams {
864    /// O'Neil's published figures: day four at the earliest, at least 1.25% up.
865    fn default() -> Self {
866        Self {
867            min_rally_day: 4,
868            gain_threshold: 0.0125,
869        }
870    }
871}
872
873/// Where a rally attempt currently stands.
874#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
875#[serde(rename_all = "snake_case")]
876pub enum FollowThroughPhase {
877    /// No attempt underway and no live confirmation.
878    NoRally,
879    /// An attempt is underway, not yet confirmed.
880    RallyAttempt,
881    /// A follow-through day is live and its pivot low still holds.
882    ConfirmedUptrend,
883    /// A confirmed uptrend was broken by a lower low.
884    Undercut,
885}
886
887/// The follow-through-day reading over a series.
888#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
889pub struct FollowThroughSignal {
890    pub phase: FollowThroughPhase,
891    /// Bar index that began the current attempt, or `None`.
892    pub rally_start: Option<usize>,
893    /// Rally day count at the end of the series. 0 outside an attempt.
894    pub rally_day: u32,
895    /// Lowest low of the current attempt: the pivot a confirmation must hold.
896    pub rally_low: Option<f64>,
897    /// Bar index of the live follow-through day, or `None`.
898    pub follow_through: Option<usize>,
899    /// Whether any follow-through occurred anywhere in the series, even one
900    /// later undercut.
901    pub ever_confirmed: bool,
902}
903
904/// Follow-through day: the confirmation that a market bottom has taken.
905///
906/// **Source.** William J. O'Neil's follow-through rule, the companion to the
907/// distribution count in *How to Make Money in Stocks*. Day 1 of a rally attempt
908/// is the first up close after a new low. From day four onward, a session that
909/// closes up at least about 1.25% on volume higher than the prior session
910/// confirms the attempt. The confirmation is void the moment the index makes a
911/// lower low than the pivot the attempt started from, which is what turns the
912/// rule into a state machine rather than a screen.
913///
914/// **Parameters.** `min_rally_day` and `gain_threshold` carry O'Neil's published
915/// figures as defaults.
916///
917/// **Inputs.** The full bar series, oldest first. Stateless.
918pub fn follow_through_day(bars: &[IndexBar], p: &FollowThroughParams) -> FollowThroughSignal {
919    let empty = FollowThroughSignal {
920        phase: FollowThroughPhase::NoRally,
921        rally_start: None,
922        rally_day: 0,
923        rally_low: None,
924        follow_through: None,
925        ever_confirmed: false,
926    };
927    if bars.len() < 2 {
928        return empty;
929    }
930
931    let mut rally_low = f64::INFINITY;
932    let mut rally_low_index: Option<usize> = None;
933    let mut in_attempt = false;
934    let mut rally_start: Option<usize> = None;
935    let mut rally_day: u32 = 0;
936    let mut confirmed = false;
937    let mut follow_through: Option<usize> = None;
938    let mut ever_confirmed = false;
939    let mut last_event_undercut = false;
940
941    for i in 0..bars.len() {
942        let bar = bars[i];
943        if bar.low < rally_low {
944            // A fresh low voids any attempt or confirmation and restarts the
945            // search for a bottom.
946            if confirmed {
947                last_event_undercut = true;
948            }
949            rally_low = bar.low;
950            rally_low_index = Some(i);
951            in_attempt = false;
952            rally_start = None;
953            rally_day = 0;
954            confirmed = false;
955            follow_through = None;
956            continue;
957        }
958        let Some(low_index) = rally_low_index else {
959            continue;
960        };
961        let prev = bars[i - 1];
962
963        if !in_attempt {
964            if i > low_index && bar.close > prev.close {
965                in_attempt = true;
966                rally_start = Some(i);
967                rally_day = 1;
968                last_event_undercut = false;
969            }
970            continue;
971        }
972
973        rally_day += 1;
974        if !confirmed && rally_day >= p.min_rally_day && prev.close > 0.0 {
975            let gain = (bar.close - prev.close) / prev.close;
976            if gain >= p.gain_threshold && bar.volume > prev.volume {
977                confirmed = true;
978                ever_confirmed = true;
979                last_event_undercut = false;
980                follow_through = Some(i);
981            }
982        }
983    }
984
985    let phase = if confirmed {
986        FollowThroughPhase::ConfirmedUptrend
987    } else if in_attempt {
988        FollowThroughPhase::RallyAttempt
989    } else if ever_confirmed && last_event_undercut {
990        FollowThroughPhase::Undercut
991    } else {
992        FollowThroughPhase::NoRally
993    };
994
995    FollowThroughSignal {
996        phase,
997        rally_start,
998        rally_day: if in_attempt { rally_day } else { 0 },
999        rally_low: if rally_low.is_finite() {
1000            Some(rally_low)
1001        } else {
1002            None
1003        },
1004        follow_through,
1005        ever_confirmed,
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    // -- regime RSI ---------------------------------------------------------
1014
1015    #[test]
1016    fn regime_rsi_reads_the_same_number_differently_by_regime() {
1017        let p = RegimeRsiParams::default();
1018        // RSI 65 is inside the bull band (35, 75) but above the bear high (60).
1019        assert_eq!(
1020            regime_rsi(TrendRegime::Bull, 65.0, 20.0, &p).side,
1021            Side::None
1022        );
1023        assert_eq!(
1024            regime_rsi(TrendRegime::Bear, 65.0, 20.0, &p).side,
1025            Side::Short
1026        );
1027    }
1028
1029    #[test]
1030    fn a_strong_trend_widens_the_overbought_line_only() {
1031        let p = RegimeRsiParams::default();
1032        let calm = regime_rsi(TrendRegime::Bull, 78.0, 20.0, &p);
1033        let strong = regime_rsi(TrendRegime::Bull, 78.0, 40.0, &p);
1034        assert_eq!(calm.side, Side::Short, "78 clears the unmodulated 75");
1035        assert_eq!(strong.side, Side::None, "80 is the modulated line");
1036        assert_eq!(strong.effective_high, 80.0);
1037        assert_eq!(
1038            strong.effective_low, 35.0,
1039            "only one modulation applies at a time"
1040        );
1041    }
1042
1043    #[test]
1044    fn a_weak_trend_lowers_the_oversold_line_only() {
1045        let p = RegimeRsiParams::default();
1046        let weak = regime_rsi(TrendRegime::Bull, 32.0, 10.0, &p);
1047        assert_eq!(weak.effective_low, 30.0);
1048        assert_eq!(weak.effective_high, 75.0);
1049        assert_eq!(weak.side, Side::None, "32 no longer clears the moved line");
1050        assert_eq!(
1051            regime_rsi(TrendRegime::Bull, 32.0, 20.0, &p).side,
1052            Side::Long
1053        );
1054    }
1055
1056    #[test]
1057    fn idle_uses_the_symmetric_textbook_bands() {
1058        let p = RegimeRsiParams::default();
1059        let s = regime_rsi(TrendRegime::Idle, 50.0, 20.0, &p);
1060        assert_eq!((s.effective_low, s.effective_high), (30.0, 70.0));
1061        assert_eq!(s.side, Side::None);
1062    }
1063
1064    // -- bounce counter -----------------------------------------------------
1065
1066    /// Feed a series through the counter, returning every emitted side.
1067    fn run_bounces(series: &[f64], p: &BounceParams) -> Vec<Side> {
1068        let mut state = BounceState::default();
1069        series
1070            .iter()
1071            .map(|&v| {
1072                let (side, next) = bounce_counter(state, v, p);
1073                state = next;
1074                side
1075            })
1076            .collect()
1077    }
1078
1079    #[test]
1080    fn a_single_touch_of_an_extreme_does_not_fire() {
1081        let p = BounceParams::default();
1082        let sides = run_bounces(&[75.0, 76.0, 77.0, 78.0, 79.0], &p);
1083        assert!(
1084            sides.iter().all(|s| *s == Side::None),
1085            "one occupancy is one bounce short of the requirement"
1086        );
1087    }
1088
1089    #[test]
1090    fn the_second_re_entry_fires_once_the_zone_persists() {
1091        let p = BounceParams::default();
1092        // High, out to neutral, back to high and held for three bars.
1093        let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0], &p);
1094        assert_eq!(sides.iter().filter(|s| **s == Side::Short).count(), 1);
1095        assert_eq!(
1096            sides[6],
1097            Side::Short,
1098            "fires on the third bar of the second re-entry"
1099        );
1100    }
1101
1102    #[test]
1103    fn the_counter_fades_the_extreme_it_measures() {
1104        let p = BounceParams::default();
1105        let lows = run_bounces(&[25.0, 50.0, 25.0, 50.0, 25.0, 24.0, 23.0], &p);
1106        assert_eq!(lows[6], Side::Long, "a persisted oversold zone fires long");
1107    }
1108
1109    #[test]
1110    fn a_long_neutral_run_resets_the_bounce_count() {
1111        let p = BounceParams {
1112            reset_after_flats: 3,
1113            ..BounceParams::default()
1114        };
1115        let mut series = vec![75.0, 50.0, 75.0];
1116        series.extend([50.0; 5]); // longer than reset_after_flats
1117        series.extend([75.0, 76.0, 77.0]);
1118        let sides = run_bounces(&series, &p);
1119        assert!(
1120            sides.iter().all(|s| *s == Side::None),
1121            "the amnesia window put the count back to one bounce"
1122        );
1123    }
1124
1125    #[test]
1126    fn the_counter_fires_at_most_once_per_occupancy() {
1127        let p = BounceParams::default();
1128        let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 78.0, 79.0], &p);
1129        assert_eq!(sides.iter().filter(|s| **s != Side::None).count(), 1);
1130    }
1131
1132    #[test]
1133    fn the_counter_is_a_pure_function_of_state_and_input() {
1134        let p = BounceParams::default();
1135        let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0];
1136        assert_eq!(run_bounces(&series, &p), run_bounces(&series, &p));
1137    }
1138
1139    // -- signal gate --------------------------------------------------------
1140
1141    #[test]
1142    fn an_aligned_signal_goes_straight_through() {
1143        let (d, s) = signal_gate(GateState::default(), Side::Long, 11.0, 10.0, 100.0, 10);
1144        assert_eq!(d.execute, Side::Long);
1145        assert_eq!(d.status, GateStatus::ExecutedImmediately);
1146        assert_eq!(s, GateState::default(), "nothing is parked");
1147    }
1148
1149    #[test]
1150    fn a_disagreeing_signal_is_parked_and_released_on_confirmation() {
1151        let (d1, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1152        assert_eq!(d1.status, GateStatus::Pending);
1153        assert_eq!(s1.pending, Side::Long);
1154        assert_eq!(s1.pending_price, 100.0);
1155
1156        let (d2, s2) = signal_gate(s1, Side::None, 11.0, 10.0, 98.0, 10);
1157        assert_eq!(d2.execute, Side::Long);
1158        assert_eq!(d2.status, GateStatus::ExecutedAfterConfirmation);
1159        assert_eq!(
1160            d2.benefit,
1161            Some(0.02),
1162            "a long filled 2% lower is a 2% better fill"
1163        );
1164        assert_eq!(s2, GateState::default());
1165    }
1166
1167    #[test]
1168    fn waiting_that_costs_is_reported_as_a_negative_benefit() {
1169        let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1170        let (d2, _) = signal_gate(s1, Side::None, 11.0, 10.0, 105.0, 10);
1171        assert_eq!(d2.benefit, Some(-0.05));
1172    }
1173
1174    #[test]
1175    fn a_reversed_signal_cancels_the_parked_one() {
1176        let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1177        let (d2, s2) = signal_gate(s1, Side::Short, 9.5, 10.0, 99.0, 10);
1178        assert_eq!(d2.status, GateStatus::Cancelled);
1179        assert_eq!(d2.execute, Side::None);
1180        assert_eq!(s2, GateState::default());
1181    }
1182
1183    #[test]
1184    fn gate_reversal_preempts_confirmation_even_when_both_mas_agree() {
1185        for (pending, raw, fast) in [
1186            (Side::Long, Side::Short, 11.0),
1187            (Side::Short, Side::Long, 9.0),
1188            (Side::Long, Side::Short, 10.0),
1189        ] {
1190            let state = GateState {
1191                pending,
1192                pending_price: 100.0,
1193                waited: 1,
1194            };
1195            let (decision, next) = signal_gate(state, raw, fast, 10.0, 98.0, 10);
1196            assert_eq!(decision.status, GateStatus::Cancelled);
1197            assert_eq!(
1198                decision.execute,
1199                Side::None,
1200                "obsolete direction must not execute"
1201            );
1202            assert_eq!(decision.benefit, None);
1203            assert_eq!(next, GateState::default());
1204        }
1205    }
1206
1207    #[test]
1208    fn gate_expiry_preempts_confirmation_at_the_exact_wait_boundary() {
1209        let state = GateState {
1210            pending: Side::Long,
1211            pending_price: 100.0,
1212            waited: 1,
1213        };
1214        let (before, _) = signal_gate(state, Side::None, 11.0, 10.0, 98.0, 3);
1215        assert_eq!(before.status, GateStatus::ExecutedAfterConfirmation);
1216        assert_eq!(before.execute, Side::Long);
1217        let (at, next) = signal_gate(
1218            GateState { waited: 2, ..state },
1219            Side::None,
1220            11.0,
1221            10.0,
1222            98.0,
1223            3,
1224        );
1225        assert_eq!(at.status, GateStatus::Cancelled);
1226        assert_eq!(at.execute, Side::None);
1227        assert_eq!(at.benefit, None);
1228        assert_eq!(next, GateState::default());
1229    }
1230
1231    #[test]
1232    fn gate_zero_or_one_wait_never_parks_an_unconfirmed_signal() {
1233        for max_wait in [0, 1] {
1234            let (decision, next) =
1235                signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, max_wait);
1236            assert_eq!(decision.status, GateStatus::Cancelled);
1237            assert_eq!(decision.execute, Side::None);
1238            assert_eq!(next, GateState::default());
1239            let (immediate, _) = signal_gate(
1240                GateState::default(),
1241                Side::Long,
1242                11.0,
1243                10.0,
1244                100.0,
1245                max_wait,
1246            );
1247            assert_eq!(immediate.status, GateStatus::ExecutedImmediately);
1248            assert_eq!(immediate.execute, Side::Long);
1249        }
1250    }
1251
1252    #[test]
1253    fn gate_wait_counter_cannot_wrap_and_renew_an_expired_signal() {
1254        let state = GateState {
1255            pending: Side::Long,
1256            pending_price: 100.0,
1257            waited: u32::MAX,
1258        };
1259        let (decision, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, u32::MAX);
1260        assert_eq!(decision.status, GateStatus::Cancelled);
1261        assert_eq!(next, GateState::default());
1262    }
1263
1264    #[test]
1265    fn a_signal_that_waits_too_long_is_dropped() {
1266        // The parking call itself is wait 1, so max_wait 3 leaves two more bars.
1267        let mut state = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 3).1;
1268        let mut statuses = Vec::new();
1269        for _ in 0..2 {
1270            let (d, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, 3);
1271            statuses.push(d.status);
1272            state = next;
1273        }
1274        assert_eq!(statuses, vec![GateStatus::Pending, GateStatus::Cancelled]);
1275        assert_eq!(state, GateState::default(), "the stale signal is dropped");
1276    }
1277
1278    #[test]
1279    fn no_signal_passes_through_untouched() {
1280        let (d, s) = signal_gate(GateState::default(), Side::None, 9.0, 10.0, 100.0, 10);
1281        assert_eq!(d.status, GateStatus::Passthrough);
1282        assert_eq!(s, GateState::default());
1283    }
1284
1285    // -- time stop ----------------------------------------------------------
1286
1287    #[test]
1288    fn the_time_stop_forces_an_exit_at_the_bar_limit() {
1289        let mut state = ExposureState::default();
1290        let (entry, next) = max_exposure_timeout(state, Side::Long, 100.0, 3);
1291        assert_eq!(entry.action, Side::Long);
1292        state = next;
1293
1294        let mut forced = None;
1295        for bar in 0..3 {
1296            let (d, next) = max_exposure_timeout(state, Side::None, 101.0 + bar as f64, 3);
1297            state = next;
1298            if d.forced_exit {
1299                forced = Some(d);
1300                break;
1301            }
1302        }
1303        let d = forced.expect("the clock must eventually close the position");
1304        assert_eq!(d.action, Side::Short, "a forced exit sells the long");
1305        assert_eq!(state, ExposureState::default());
1306    }
1307
1308    #[test]
1309    fn an_opposing_signal_closes_without_being_called_forced() {
1310        let (_, state) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
1311        let (d, next) = max_exposure_timeout(state, Side::Short, 99.0, 50);
1312        assert_eq!(d.action, Side::Short);
1313        assert!(!d.forced_exit, "a thesis change is not a time stop");
1314        assert_eq!(next, ExposureState::default());
1315    }
1316
1317    #[test]
1318    fn a_same_side_signal_holds_and_advances_the_clock() {
1319        let (_, s0) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
1320        let (d, s1) = max_exposure_timeout(s0, Side::Long, 101.0, 50);
1321        assert_eq!(d.action, Side::None);
1322        assert_eq!(s1.bars_held, 1);
1323        assert_eq!(s1.entry_price, 100.0, "the entry price is not restamped");
1324    }
1325
1326    #[test]
1327    fn flat_with_no_signal_stays_flat() {
1328        let (d, s) = max_exposure_timeout(ExposureState::default(), Side::None, 100.0, 5);
1329        assert_eq!(d.action, Side::None);
1330        assert!(!d.forced_exit);
1331        assert_eq!(s, ExposureState::default());
1332    }
1333
1334    // -- ATR breakout -------------------------------------------------------
1335
1336    #[test]
1337    fn expanding_volatility_arms_symmetric_stops() {
1338        let p = AtrBreakoutParams::default();
1339        let s = atr_breakout(100.0, 3.0, 2.0, &p);
1340        assert_eq!(s.verdict, AtrBreakoutVerdict::Armed);
1341        let l = s.levels.expect("armed carries levels");
1342        assert_eq!(l.long_entry_stop, 105.0);
1343        assert_eq!(l.short_entry_stop, 95.0);
1344        assert_eq!(l.exit_stop_distance, 2.0);
1345        assert_eq!(l.long_stop_loss, 103.0);
1346        assert_eq!(l.short_stop_loss, 97.0);
1347    }
1348
1349    #[test]
1350    fn contracting_volatility_does_not_arm() {
1351        let p = AtrBreakoutParams::default();
1352        let s = atr_breakout(100.0, 1.5, 2.0, &p);
1353        assert_eq!(s.verdict, AtrBreakoutVerdict::FilteredNoExpansion);
1354        assert_eq!(s.levels, None);
1355        assert_eq!(s.expansion_ratio, 0.75);
1356    }
1357
1358    #[test]
1359    fn a_quiet_market_is_filtered_before_the_expansion_test() {
1360        let p = AtrBreakoutParams {
1361            min_atr_long: 5.0,
1362            ..AtrBreakoutParams::default()
1363        };
1364        assert_eq!(
1365            atr_breakout(100.0, 9.0, 2.0, &p).verdict,
1366            AtrBreakoutVerdict::FilteredLowVol,
1367            "expanding but tiny is still untradeably quiet"
1368        );
1369    }
1370
1371    #[test]
1372    fn non_finite_or_zero_atr_is_rejected_rather_than_propagated() {
1373        let p = AtrBreakoutParams::default();
1374        for (o, short, long) in [
1375            (f64::NAN, 3.0, 2.0),
1376            (100.0, f64::INFINITY, 2.0),
1377            (100.0, 3.0, 0.0),
1378            (100.0, -1.0, 2.0),
1379        ] {
1380            let s = atr_breakout(o, short, long, &p);
1381            assert_eq!(s.verdict, AtrBreakoutVerdict::InvalidInput);
1382            assert_eq!(s.levels, None);
1383        }
1384    }
1385
1386    #[test]
1387    fn the_exit_stop_is_tighter_than_the_entry_distance_by_default() {
1388        let p = AtrBreakoutParams::default();
1389        assert!(p.exit_multiplier < p.entry_multiplier);
1390    }
1391
1392    // -- distribution days --------------------------------------------------
1393
1394    fn bar(close: f64, volume: f64) -> IndexBar {
1395        IndexBar {
1396            high: close,
1397            low: close,
1398            close,
1399            volume,
1400        }
1401    }
1402
1403    #[test]
1404    fn a_down_close_on_rising_volume_counts() {
1405        let bars = [bar(100.0, 1000.0), bar(99.0, 1200.0)];
1406        let c = distribution_days(&bars, &DistributionParams::default());
1407        assert_eq!(c.live, 1);
1408        assert_eq!(c.last_5, 1);
1409        assert_eq!(c.severity, DistributionSeverity::Normal);
1410    }
1411
1412    #[test]
1413    fn a_down_close_on_falling_volume_does_not_count() {
1414        let bars = [bar(100.0, 1000.0), bar(99.0, 800.0)];
1415        assert_eq!(
1416            distribution_days(&bars, &DistributionParams::default()).live,
1417            0
1418        );
1419    }
1420
1421    #[test]
1422    fn a_shallow_down_close_does_not_count() {
1423        // -0.1% is above the -0.2% threshold.
1424        let bars = [bar(100.0, 1000.0), bar(99.9, 1200.0)];
1425        assert_eq!(
1426            distribution_days(&bars, &DistributionParams::default()).live,
1427            0
1428        );
1429    }
1430
1431    #[test]
1432    fn six_live_days_read_as_severe() {
1433        let mut bars = vec![bar(100.0, 1000.0)];
1434        let mut close = 100.0;
1435        for _ in 0..6 {
1436            close -= 1.0;
1437            bars.push(bar(close, 2000.0));
1438            bars.push(bar(close + 0.01, 1000.0)); // a flat-ish up bar on low volume
1439        }
1440        let c = distribution_days(&bars, &DistributionParams::default());
1441        assert_eq!(c.live, 6, "{c:?}");
1442        assert_eq!(c.severity, DistributionSeverity::Severe);
1443    }
1444
1445    #[test]
1446    fn the_rally_rule_cancels_an_absorbed_day() {
1447        // One distribution day, then a rally of more than 5% above its close.
1448        let bars = [
1449            bar(100.0, 1000.0),
1450            bar(99.0, 1200.0),
1451            bar(105.0, 900.0), // 105 >= 99 * 1.05 = 103.95
1452        ];
1453        let c = distribution_days(&bars, &DistributionParams::default());
1454        assert_eq!(c.live, 0);
1455        assert_eq!(c.invalidated, 1);
1456    }
1457
1458    #[test]
1459    fn a_day_outside_the_window_expires() {
1460        let p = DistributionParams {
1461            window: 2,
1462            ..DistributionParams::default()
1463        };
1464        let bars = [
1465            bar(100.0, 1000.0),
1466            bar(99.0, 1200.0), // the distribution day
1467            bar(99.1, 900.0),
1468            bar(99.2, 900.0),
1469            bar(99.3, 900.0),
1470        ];
1471        let c = distribution_days(&bars, &p);
1472        assert_eq!(c.live, 0, "aged out of a two-session window");
1473        assert_eq!(c.invalidated, 0, "expiry is not invalidation");
1474    }
1475
1476    #[test]
1477    fn too_few_bars_is_a_normal_zero_count() {
1478        let c = distribution_days(&[bar(100.0, 1.0)], &DistributionParams::default());
1479        assert_eq!(c.live, 0);
1480        assert_eq!(c.severity, DistributionSeverity::Normal);
1481    }
1482
1483    // -- follow-through day -------------------------------------------------
1484
1485    fn ohlc(low: f64, close: f64, volume: f64) -> IndexBar {
1486        IndexBar {
1487            high: close,
1488            low,
1489            close,
1490            volume,
1491        }
1492    }
1493
1494    #[test]
1495    fn a_qualifying_day_four_confirms_the_attempt() {
1496        let bars = [
1497            ohlc(90.0, 91.0, 1000.0), // the low
1498            ohlc(91.0, 92.0, 1000.0), // day 1: first up close
1499            ohlc(91.5, 92.5, 1000.0), // day 2
1500            ohlc(92.0, 93.0, 1000.0), // day 3
1501            ohlc(92.5, 95.0, 2000.0), // day 4: +2.15% on double volume
1502        ];
1503        let s = follow_through_day(&bars, &FollowThroughParams::default());
1504        assert_eq!(s.phase, FollowThroughPhase::ConfirmedUptrend);
1505        assert_eq!(s.follow_through, Some(4));
1506        assert!(s.ever_confirmed);
1507        assert_eq!(s.rally_start, Some(1));
1508    }
1509
1510    #[test]
1511    fn a_day_three_gain_is_too_early_to_confirm() {
1512        let bars = [
1513            ohlc(90.0, 91.0, 1000.0),
1514            ohlc(91.0, 92.0, 1000.0), // day 1
1515            ohlc(91.5, 92.5, 1000.0), // day 2
1516            ohlc(92.0, 95.0, 3000.0), // day 3: big, but too early
1517        ];
1518        let s = follow_through_day(&bars, &FollowThroughParams::default());
1519        assert_eq!(s.phase, FollowThroughPhase::RallyAttempt);
1520        assert_eq!(s.follow_through, None);
1521        assert!(!s.ever_confirmed);
1522    }
1523
1524    #[test]
1525    fn a_qualifying_gain_on_falling_volume_does_not_confirm() {
1526        let bars = [
1527            ohlc(90.0, 91.0, 3000.0),
1528            ohlc(91.0, 92.0, 2000.0),
1529            ohlc(91.5, 92.5, 2000.0),
1530            ohlc(92.0, 93.0, 2000.0),
1531            ohlc(92.5, 95.0, 1000.0), // +2.15% but volume fell
1532        ];
1533        assert_eq!(
1534            follow_through_day(&bars, &FollowThroughParams::default()).phase,
1535            FollowThroughPhase::RallyAttempt
1536        );
1537    }
1538
1539    #[test]
1540    fn a_lower_low_undercuts_a_confirmed_uptrend() {
1541        let bars = [
1542            ohlc(90.0, 91.0, 1000.0),
1543            ohlc(91.0, 92.0, 1000.0),
1544            ohlc(91.5, 92.5, 1000.0),
1545            ohlc(92.0, 93.0, 1000.0),
1546            ohlc(92.5, 95.0, 2000.0), // confirmed here
1547            ohlc(85.0, 86.0, 1500.0), // a new low voids it
1548        ];
1549        let s = follow_through_day(&bars, &FollowThroughParams::default());
1550        assert_eq!(s.phase, FollowThroughPhase::Undercut);
1551        assert!(s.ever_confirmed, "the event still happened");
1552        assert_eq!(s.follow_through, None, "but it is no longer live");
1553        assert_eq!(s.rally_low, Some(85.0));
1554    }
1555
1556    #[test]
1557    fn a_series_that_only_falls_never_starts_an_attempt() {
1558        let bars = [
1559            ohlc(100.0, 100.0, 1000.0),
1560            ohlc(99.0, 99.0, 1000.0),
1561            ohlc(98.0, 98.0, 1000.0),
1562        ];
1563        let s = follow_through_day(&bars, &FollowThroughParams::default());
1564        assert_eq!(s.phase, FollowThroughPhase::NoRally);
1565        assert_eq!(s.rally_start, None);
1566        assert_eq!(s.rally_day, 0);
1567    }
1568
1569    #[test]
1570    fn too_few_bars_is_no_rally() {
1571        let s = follow_through_day(&[ohlc(1.0, 1.0, 1.0)], &FollowThroughParams::default());
1572        assert_eq!(s.phase, FollowThroughPhase::NoRally);
1573        assert_eq!(s.rally_low, None);
1574    }
1575
1576    // -- house rules --------------------------------------------------------
1577
1578    #[test]
1579    fn every_stateful_primitive_replays_identically_from_the_same_state() {
1580        // The property the kernel needs: no hidden state anywhere.
1581        let bp = BounceParams::default();
1582        let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 40.0, 25.0];
1583        let replay = |_: ()| {
1584            let mut bounce = BounceState::default();
1585            let mut gate = GateState::default();
1586            let mut exposure = ExposureState::default();
1587            let mut out = Vec::new();
1588            for (i, &v) in series.iter().enumerate() {
1589                let (side, nb) = bounce_counter(bounce, v, &bp);
1590                bounce = nb;
1591                let price = 100.0 + i as f64;
1592                let (gd, ng) = signal_gate(gate, side, v, 60.0, price, 5);
1593                gate = ng;
1594                let (ed, ne) = max_exposure_timeout(exposure, gd.execute, price, 4);
1595                exposure = ne;
1596                out.push((side, gd.status, ed.action, ed.forced_exit));
1597            }
1598            out
1599        };
1600        assert_eq!(replay(()), replay(()));
1601    }
1602}