use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Side {
#[default]
None,
Long,
Short,
}
impl Side {
pub fn opposite(self) -> Side {
match self {
Side::Long => Side::Short,
Side::Short => Side::Long,
Side::None => Side::None,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrendRegime {
Bull,
Bear,
Idle,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct RsiBands {
pub rsi_high: f64,
pub rsi_low: f64,
pub mod_high: f64,
pub mod_low: f64,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct RegimeRsiParams {
pub bull: RsiBands,
pub bear: RsiBands,
pub idle: RsiBands,
pub adx_high: f64,
pub adx_low: f64,
}
impl Default for RegimeRsiParams {
fn default() -> Self {
Self {
bull: RsiBands {
rsi_high: 75.0,
rsi_low: 35.0,
mod_high: 5.0,
mod_low: -5.0,
},
bear: RsiBands {
rsi_high: 60.0,
rsi_low: 25.0,
mod_high: 5.0,
mod_low: -5.0,
},
idle: RsiBands {
rsi_high: 70.0,
rsi_low: 30.0,
mod_high: 3.0,
mod_low: -3.0,
},
adx_high: 30.0,
adx_low: 15.0,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct RegimeRsiSignal {
pub side: Side,
pub regime: TrendRegime,
pub effective_high: f64,
pub effective_low: f64,
}
pub fn regime_rsi(
regime: TrendRegime,
rsi: f64,
adx: f64,
params: &RegimeRsiParams,
) -> RegimeRsiSignal {
let bands = match regime {
TrendRegime::Bull => params.bull,
TrendRegime::Bear => params.bear,
TrendRegime::Idle => params.idle,
};
let mut effective_high = bands.rsi_high;
let mut effective_low = bands.rsi_low;
if adx > params.adx_high {
effective_high += bands.mod_high;
} else if adx < params.adx_low {
effective_low += bands.mod_low;
}
let side = if rsi > effective_high {
Side::Short
} else if rsi < effective_low {
Side::Long
} else {
Side::None
};
RegimeRsiSignal {
side,
regime,
effective_high,
effective_low,
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Zone {
High,
Low,
#[default]
Neutral,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BounceState {
pub zone: Zone,
pub duration: u32,
pub bounces: u32,
pub armed: bool,
pub flats: u32,
pub fired: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct BounceParams {
pub high: f64,
pub low: f64,
pub persistence: u32,
pub required_bounces: u32,
pub reset_after_flats: u32,
}
impl Default for BounceParams {
fn default() -> Self {
Self {
high: 70.0,
low: 30.0,
persistence: 3,
required_bounces: 2,
reset_after_flats: 50,
}
}
}
pub fn bounce_counter(prev: BounceState, oscillator: f64, p: &BounceParams) -> (Side, BounceState) {
let zone = if oscillator > p.high {
Zone::High
} else if oscillator < p.low {
Zone::Low
} else {
Zone::Neutral
};
let mut next = prev;
if zone != prev.zone {
if zone == Zone::Neutral {
next.armed = true;
} else if prev.armed {
next.bounces = prev.bounces + 1;
next.armed = false;
}
next.zone = zone;
next.duration = 1;
next.fired = false;
next.flats = if zone == Zone::Neutral {
prev.flats + 1
} else {
0
};
} else {
next.duration = prev.duration + 1;
if zone == Zone::Neutral {
next.flats = prev.flats + 1;
if next.flats >= p.reset_after_flats {
next.bounces = 0;
next.flats = 0;
next.armed = false;
}
} else {
next.flats = 0;
}
}
let mut side = Side::None;
if !next.fired
&& next.zone != Zone::Neutral
&& next.duration >= p.persistence
&& next.bounces >= p.required_bounces
{
side = if next.zone == Zone::High {
Side::Short
} else {
Side::Long
};
next.fired = true;
}
(side, next)
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GateStatus {
ExecutedImmediately,
ExecutedAfterConfirmation,
Passthrough,
Pending,
Cancelled,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct GateState {
pub pending: Side,
pub pending_price: f64,
pub waited: u32,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct GateDecision {
pub execute: Side,
pub status: GateStatus,
pub benefit: Option<f64>,
}
pub fn signal_gate(
prev: GateState,
raw: Side,
fast_ma: f64,
slow_ma: f64,
price: f64,
max_wait: u32,
) -> (GateDecision, GateState) {
let confirms = |side: Side| match side {
Side::Long => fast_ma >= slow_ma,
Side::Short => fast_ma <= slow_ma,
Side::None => false,
};
let cancelled = || {
(
GateDecision {
execute: Side::None,
status: GateStatus::Cancelled,
benefit: None,
},
GateState::default(),
)
};
if prev.pending != Side::None {
let waited = prev.waited.saturating_add(1);
if (raw != Side::None && raw != prev.pending) || waited >= max_wait {
return cancelled();
}
if confirms(prev.pending) {
let benefit = if prev.pending_price == 0.0 {
0.0
} else if prev.pending == Side::Long {
(prev.pending_price - price) / prev.pending_price
} else {
(price - prev.pending_price) / prev.pending_price
};
return (
GateDecision {
execute: prev.pending,
status: GateStatus::ExecutedAfterConfirmation,
benefit: Some(benefit),
},
GateState::default(),
);
}
return (
GateDecision {
execute: Side::None,
status: GateStatus::Pending,
benefit: None,
},
GateState { waited, ..prev },
);
}
if raw == Side::None {
return (
GateDecision {
execute: Side::None,
status: GateStatus::Passthrough,
benefit: None,
},
prev,
);
}
if confirms(raw) {
return (
GateDecision {
execute: raw,
status: GateStatus::ExecutedImmediately,
benefit: None,
},
prev,
);
}
if max_wait <= 1 {
return cancelled();
}
(
GateDecision {
execute: Side::None,
status: GateStatus::Pending,
benefit: None,
},
GateState {
pending: raw,
pending_price: price,
waited: 1,
},
)
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ExposureState {
pub side: Side,
pub entry_price: f64,
pub bars_held: u32,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct ExposureDecision {
pub action: Side,
pub forced_exit: bool,
}
pub fn max_exposure_timeout(
prev: ExposureState,
signal: Side,
price: f64,
max_bars: u32,
) -> (ExposureDecision, ExposureState) {
if prev.side == Side::None {
if signal == Side::None {
return (
ExposureDecision {
action: Side::None,
forced_exit: false,
},
prev,
);
}
return (
ExposureDecision {
action: signal,
forced_exit: false,
},
ExposureState {
side: signal,
entry_price: price,
bars_held: 0,
},
);
}
let bars_held = prev.bars_held + 1;
if bars_held >= max_bars {
return (
ExposureDecision {
action: prev.side.opposite(),
forced_exit: true,
},
ExposureState::default(),
);
}
if signal != Side::None && signal != prev.side {
return (
ExposureDecision {
action: signal,
forced_exit: false,
},
ExposureState::default(),
);
}
(
ExposureDecision {
action: Side::None,
forced_exit: false,
},
ExposureState { bars_held, ..prev },
)
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct AtrBreakoutParams {
pub entry_multiplier: f64,
pub exit_multiplier: f64,
pub expansion_ratio: f64,
pub min_atr_long: f64,
}
impl Default for AtrBreakoutParams {
fn default() -> Self {
Self {
entry_multiplier: 2.5,
exit_multiplier: 1.0,
expansion_ratio: 1.0,
min_atr_long: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AtrBreakoutVerdict {
Armed,
FilteredNoExpansion,
FilteredLowVol,
InvalidInput,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct AtrBreakoutLevels {
pub long_entry_stop: f64,
pub short_entry_stop: f64,
pub exit_stop_distance: f64,
pub long_stop_loss: f64,
pub short_stop_loss: f64,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct AtrBreakoutSignal {
pub verdict: AtrBreakoutVerdict,
pub expansion_ratio: f64,
pub levels: Option<AtrBreakoutLevels>,
}
pub fn atr_breakout(
bar_open: f64,
atr_short: f64,
atr_long: f64,
p: &AtrBreakoutParams,
) -> AtrBreakoutSignal {
if !bar_open.is_finite()
|| !atr_short.is_finite()
|| !atr_long.is_finite()
|| atr_long <= 0.0
|| atr_short < 0.0
{
return AtrBreakoutSignal {
verdict: AtrBreakoutVerdict::InvalidInput,
expansion_ratio: 0.0,
levels: None,
};
}
let ratio = atr_short / atr_long;
if atr_long < p.min_atr_long {
return AtrBreakoutSignal {
verdict: AtrBreakoutVerdict::FilteredLowVol,
expansion_ratio: ratio,
levels: None,
};
}
if ratio <= p.expansion_ratio {
return AtrBreakoutSignal {
verdict: AtrBreakoutVerdict::FilteredNoExpansion,
expansion_ratio: ratio,
levels: None,
};
}
let entry_distance = atr_long * p.entry_multiplier;
let exit_distance = atr_long * p.exit_multiplier;
let long_entry_stop = bar_open + entry_distance;
let short_entry_stop = bar_open - entry_distance;
AtrBreakoutSignal {
verdict: AtrBreakoutVerdict::Armed,
expansion_ratio: ratio,
levels: Some(AtrBreakoutLevels {
long_entry_stop,
short_entry_stop,
exit_stop_distance: exit_distance,
long_stop_loss: long_entry_stop - exit_distance,
short_stop_loss: short_entry_stop + exit_distance,
}),
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct IndexBar {
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct DistributionParams {
pub down_threshold: f64,
pub window: usize,
pub invalidation_gain: f64,
pub volume_must_rise: bool,
}
impl Default for DistributionParams {
fn default() -> Self {
Self {
down_threshold: -0.002,
window: 25,
invalidation_gain: 0.05,
volume_must_rise: true,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DistributionSeverity {
Normal,
Caution,
High,
Severe,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct DistributionCount {
pub live: usize,
pub last_5: usize,
pub last_15: usize,
pub last_25: usize,
pub invalidated: usize,
pub severity: DistributionSeverity,
}
pub fn distribution_days(bars: &[IndexBar], p: &DistributionParams) -> DistributionCount {
let mut count = DistributionCount {
live: 0,
last_5: 0,
last_15: 0,
last_25: 0,
invalidated: 0,
severity: DistributionSeverity::Normal,
};
if bars.len() < 2 {
return count;
}
let last = bars.len() - 1;
for i in 1..bars.len() {
let (bar, prev) = (bars[i], bars[i - 1]);
if prev.close <= 0.0 {
continue;
}
let change = (bar.close - prev.close) / prev.close;
if change > p.down_threshold {
continue;
}
if p.volume_must_rise && bar.volume <= prev.volume {
continue;
}
let sessions_ago = last - i;
let expired = sessions_ago >= p.window;
let invalidation_level = bar.close * (1.0 + p.invalidation_gain);
let invalidated = bars[i + 1..].iter().any(|b| b.close >= invalidation_level);
if invalidated {
count.invalidated += 1;
continue;
}
if expired {
continue;
}
count.live += 1;
if sessions_ago < 5 {
count.last_5 += 1;
}
if sessions_ago < 15 {
count.last_15 += 1;
}
if sessions_ago < 25 {
count.last_25 += 1;
}
}
count.severity = match count.live {
0..=2 => DistributionSeverity::Normal,
3..=4 => DistributionSeverity::Caution,
5 => DistributionSeverity::High,
_ => DistributionSeverity::Severe,
};
count
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct FollowThroughParams {
pub min_rally_day: u32,
pub gain_threshold: f64,
}
impl Default for FollowThroughParams {
fn default() -> Self {
Self {
min_rally_day: 4,
gain_threshold: 0.0125,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FollowThroughPhase {
NoRally,
RallyAttempt,
ConfirmedUptrend,
Undercut,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct FollowThroughSignal {
pub phase: FollowThroughPhase,
pub rally_start: Option<usize>,
pub rally_day: u32,
pub rally_low: Option<f64>,
pub follow_through: Option<usize>,
pub ever_confirmed: bool,
}
pub fn follow_through_day(bars: &[IndexBar], p: &FollowThroughParams) -> FollowThroughSignal {
let empty = FollowThroughSignal {
phase: FollowThroughPhase::NoRally,
rally_start: None,
rally_day: 0,
rally_low: None,
follow_through: None,
ever_confirmed: false,
};
if bars.len() < 2 {
return empty;
}
let mut rally_low = f64::INFINITY;
let mut rally_low_index: Option<usize> = None;
let mut in_attempt = false;
let mut rally_start: Option<usize> = None;
let mut rally_day: u32 = 0;
let mut confirmed = false;
let mut follow_through: Option<usize> = None;
let mut ever_confirmed = false;
let mut last_event_undercut = false;
for i in 0..bars.len() {
let bar = bars[i];
if bar.low < rally_low {
if confirmed {
last_event_undercut = true;
}
rally_low = bar.low;
rally_low_index = Some(i);
in_attempt = false;
rally_start = None;
rally_day = 0;
confirmed = false;
follow_through = None;
continue;
}
let Some(low_index) = rally_low_index else {
continue;
};
let prev = bars[i - 1];
if !in_attempt {
if i > low_index && bar.close > prev.close {
in_attempt = true;
rally_start = Some(i);
rally_day = 1;
last_event_undercut = false;
}
continue;
}
rally_day += 1;
if !confirmed && rally_day >= p.min_rally_day && prev.close > 0.0 {
let gain = (bar.close - prev.close) / prev.close;
if gain >= p.gain_threshold && bar.volume > prev.volume {
confirmed = true;
ever_confirmed = true;
last_event_undercut = false;
follow_through = Some(i);
}
}
}
let phase = if confirmed {
FollowThroughPhase::ConfirmedUptrend
} else if in_attempt {
FollowThroughPhase::RallyAttempt
} else if ever_confirmed && last_event_undercut {
FollowThroughPhase::Undercut
} else {
FollowThroughPhase::NoRally
};
FollowThroughSignal {
phase,
rally_start,
rally_day: if in_attempt { rally_day } else { 0 },
rally_low: if rally_low.is_finite() {
Some(rally_low)
} else {
None
},
follow_through,
ever_confirmed,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regime_rsi_reads_the_same_number_differently_by_regime() {
let p = RegimeRsiParams::default();
assert_eq!(
regime_rsi(TrendRegime::Bull, 65.0, 20.0, &p).side,
Side::None
);
assert_eq!(
regime_rsi(TrendRegime::Bear, 65.0, 20.0, &p).side,
Side::Short
);
}
#[test]
fn a_strong_trend_widens_the_overbought_line_only() {
let p = RegimeRsiParams::default();
let calm = regime_rsi(TrendRegime::Bull, 78.0, 20.0, &p);
let strong = regime_rsi(TrendRegime::Bull, 78.0, 40.0, &p);
assert_eq!(calm.side, Side::Short, "78 clears the unmodulated 75");
assert_eq!(strong.side, Side::None, "80 is the modulated line");
assert_eq!(strong.effective_high, 80.0);
assert_eq!(
strong.effective_low, 35.0,
"only one modulation applies at a time"
);
}
#[test]
fn a_weak_trend_lowers_the_oversold_line_only() {
let p = RegimeRsiParams::default();
let weak = regime_rsi(TrendRegime::Bull, 32.0, 10.0, &p);
assert_eq!(weak.effective_low, 30.0);
assert_eq!(weak.effective_high, 75.0);
assert_eq!(weak.side, Side::None, "32 no longer clears the moved line");
assert_eq!(
regime_rsi(TrendRegime::Bull, 32.0, 20.0, &p).side,
Side::Long
);
}
#[test]
fn idle_uses_the_symmetric_textbook_bands() {
let p = RegimeRsiParams::default();
let s = regime_rsi(TrendRegime::Idle, 50.0, 20.0, &p);
assert_eq!((s.effective_low, s.effective_high), (30.0, 70.0));
assert_eq!(s.side, Side::None);
}
fn run_bounces(series: &[f64], p: &BounceParams) -> Vec<Side> {
let mut state = BounceState::default();
series
.iter()
.map(|&v| {
let (side, next) = bounce_counter(state, v, p);
state = next;
side
})
.collect()
}
#[test]
fn a_single_touch_of_an_extreme_does_not_fire() {
let p = BounceParams::default();
let sides = run_bounces(&[75.0, 76.0, 77.0, 78.0, 79.0], &p);
assert!(
sides.iter().all(|s| *s == Side::None),
"one occupancy is one bounce short of the requirement"
);
}
#[test]
fn the_second_re_entry_fires_once_the_zone_persists() {
let p = BounceParams::default();
let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0], &p);
assert_eq!(sides.iter().filter(|s| **s == Side::Short).count(), 1);
assert_eq!(
sides[6],
Side::Short,
"fires on the third bar of the second re-entry"
);
}
#[test]
fn the_counter_fades_the_extreme_it_measures() {
let p = BounceParams::default();
let lows = run_bounces(&[25.0, 50.0, 25.0, 50.0, 25.0, 24.0, 23.0], &p);
assert_eq!(lows[6], Side::Long, "a persisted oversold zone fires long");
}
#[test]
fn a_long_neutral_run_resets_the_bounce_count() {
let p = BounceParams {
reset_after_flats: 3,
..BounceParams::default()
};
let mut series = vec![75.0, 50.0, 75.0];
series.extend([50.0; 5]); series.extend([75.0, 76.0, 77.0]);
let sides = run_bounces(&series, &p);
assert!(
sides.iter().all(|s| *s == Side::None),
"the amnesia window put the count back to one bounce"
);
}
#[test]
fn the_counter_fires_at_most_once_per_occupancy() {
let p = BounceParams::default();
let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 78.0, 79.0], &p);
assert_eq!(sides.iter().filter(|s| **s != Side::None).count(), 1);
}
#[test]
fn the_counter_is_a_pure_function_of_state_and_input() {
let p = BounceParams::default();
let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0];
assert_eq!(run_bounces(&series, &p), run_bounces(&series, &p));
}
#[test]
fn an_aligned_signal_goes_straight_through() {
let (d, s) = signal_gate(GateState::default(), Side::Long, 11.0, 10.0, 100.0, 10);
assert_eq!(d.execute, Side::Long);
assert_eq!(d.status, GateStatus::ExecutedImmediately);
assert_eq!(s, GateState::default(), "nothing is parked");
}
#[test]
fn a_disagreeing_signal_is_parked_and_released_on_confirmation() {
let (d1, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
assert_eq!(d1.status, GateStatus::Pending);
assert_eq!(s1.pending, Side::Long);
assert_eq!(s1.pending_price, 100.0);
let (d2, s2) = signal_gate(s1, Side::None, 11.0, 10.0, 98.0, 10);
assert_eq!(d2.execute, Side::Long);
assert_eq!(d2.status, GateStatus::ExecutedAfterConfirmation);
assert_eq!(
d2.benefit,
Some(0.02),
"a long filled 2% lower is a 2% better fill"
);
assert_eq!(s2, GateState::default());
}
#[test]
fn waiting_that_costs_is_reported_as_a_negative_benefit() {
let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
let (d2, _) = signal_gate(s1, Side::None, 11.0, 10.0, 105.0, 10);
assert_eq!(d2.benefit, Some(-0.05));
}
#[test]
fn a_reversed_signal_cancels_the_parked_one() {
let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
let (d2, s2) = signal_gate(s1, Side::Short, 9.5, 10.0, 99.0, 10);
assert_eq!(d2.status, GateStatus::Cancelled);
assert_eq!(d2.execute, Side::None);
assert_eq!(s2, GateState::default());
}
#[test]
fn gate_reversal_preempts_confirmation_even_when_both_mas_agree() {
for (pending, raw, fast) in [
(Side::Long, Side::Short, 11.0),
(Side::Short, Side::Long, 9.0),
(Side::Long, Side::Short, 10.0),
] {
let state = GateState {
pending,
pending_price: 100.0,
waited: 1,
};
let (decision, next) = signal_gate(state, raw, fast, 10.0, 98.0, 10);
assert_eq!(decision.status, GateStatus::Cancelled);
assert_eq!(
decision.execute,
Side::None,
"obsolete direction must not execute"
);
assert_eq!(decision.benefit, None);
assert_eq!(next, GateState::default());
}
}
#[test]
fn gate_expiry_preempts_confirmation_at_the_exact_wait_boundary() {
let state = GateState {
pending: Side::Long,
pending_price: 100.0,
waited: 1,
};
let (before, _) = signal_gate(state, Side::None, 11.0, 10.0, 98.0, 3);
assert_eq!(before.status, GateStatus::ExecutedAfterConfirmation);
assert_eq!(before.execute, Side::Long);
let (at, next) = signal_gate(
GateState { waited: 2, ..state },
Side::None,
11.0,
10.0,
98.0,
3,
);
assert_eq!(at.status, GateStatus::Cancelled);
assert_eq!(at.execute, Side::None);
assert_eq!(at.benefit, None);
assert_eq!(next, GateState::default());
}
#[test]
fn gate_zero_or_one_wait_never_parks_an_unconfirmed_signal() {
for max_wait in [0, 1] {
let (decision, next) =
signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, max_wait);
assert_eq!(decision.status, GateStatus::Cancelled);
assert_eq!(decision.execute, Side::None);
assert_eq!(next, GateState::default());
let (immediate, _) = signal_gate(
GateState::default(),
Side::Long,
11.0,
10.0,
100.0,
max_wait,
);
assert_eq!(immediate.status, GateStatus::ExecutedImmediately);
assert_eq!(immediate.execute, Side::Long);
}
}
#[test]
fn gate_wait_counter_cannot_wrap_and_renew_an_expired_signal() {
let state = GateState {
pending: Side::Long,
pending_price: 100.0,
waited: u32::MAX,
};
let (decision, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, u32::MAX);
assert_eq!(decision.status, GateStatus::Cancelled);
assert_eq!(next, GateState::default());
}
#[test]
fn a_signal_that_waits_too_long_is_dropped() {
let mut state = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 3).1;
let mut statuses = Vec::new();
for _ in 0..2 {
let (d, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, 3);
statuses.push(d.status);
state = next;
}
assert_eq!(statuses, vec![GateStatus::Pending, GateStatus::Cancelled]);
assert_eq!(state, GateState::default(), "the stale signal is dropped");
}
#[test]
fn no_signal_passes_through_untouched() {
let (d, s) = signal_gate(GateState::default(), Side::None, 9.0, 10.0, 100.0, 10);
assert_eq!(d.status, GateStatus::Passthrough);
assert_eq!(s, GateState::default());
}
#[test]
fn the_time_stop_forces_an_exit_at_the_bar_limit() {
let mut state = ExposureState::default();
let (entry, next) = max_exposure_timeout(state, Side::Long, 100.0, 3);
assert_eq!(entry.action, Side::Long);
state = next;
let mut forced = None;
for bar in 0..3 {
let (d, next) = max_exposure_timeout(state, Side::None, 101.0 + bar as f64, 3);
state = next;
if d.forced_exit {
forced = Some(d);
break;
}
}
let d = forced.expect("the clock must eventually close the position");
assert_eq!(d.action, Side::Short, "a forced exit sells the long");
assert_eq!(state, ExposureState::default());
}
#[test]
fn an_opposing_signal_closes_without_being_called_forced() {
let (_, state) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
let (d, next) = max_exposure_timeout(state, Side::Short, 99.0, 50);
assert_eq!(d.action, Side::Short);
assert!(!d.forced_exit, "a thesis change is not a time stop");
assert_eq!(next, ExposureState::default());
}
#[test]
fn a_same_side_signal_holds_and_advances_the_clock() {
let (_, s0) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
let (d, s1) = max_exposure_timeout(s0, Side::Long, 101.0, 50);
assert_eq!(d.action, Side::None);
assert_eq!(s1.bars_held, 1);
assert_eq!(s1.entry_price, 100.0, "the entry price is not restamped");
}
#[test]
fn flat_with_no_signal_stays_flat() {
let (d, s) = max_exposure_timeout(ExposureState::default(), Side::None, 100.0, 5);
assert_eq!(d.action, Side::None);
assert!(!d.forced_exit);
assert_eq!(s, ExposureState::default());
}
#[test]
fn expanding_volatility_arms_symmetric_stops() {
let p = AtrBreakoutParams::default();
let s = atr_breakout(100.0, 3.0, 2.0, &p);
assert_eq!(s.verdict, AtrBreakoutVerdict::Armed);
let l = s.levels.expect("armed carries levels");
assert_eq!(l.long_entry_stop, 105.0);
assert_eq!(l.short_entry_stop, 95.0);
assert_eq!(l.exit_stop_distance, 2.0);
assert_eq!(l.long_stop_loss, 103.0);
assert_eq!(l.short_stop_loss, 97.0);
}
#[test]
fn contracting_volatility_does_not_arm() {
let p = AtrBreakoutParams::default();
let s = atr_breakout(100.0, 1.5, 2.0, &p);
assert_eq!(s.verdict, AtrBreakoutVerdict::FilteredNoExpansion);
assert_eq!(s.levels, None);
assert_eq!(s.expansion_ratio, 0.75);
}
#[test]
fn a_quiet_market_is_filtered_before_the_expansion_test() {
let p = AtrBreakoutParams {
min_atr_long: 5.0,
..AtrBreakoutParams::default()
};
assert_eq!(
atr_breakout(100.0, 9.0, 2.0, &p).verdict,
AtrBreakoutVerdict::FilteredLowVol,
"expanding but tiny is still untradeably quiet"
);
}
#[test]
fn non_finite_or_zero_atr_is_rejected_rather_than_propagated() {
let p = AtrBreakoutParams::default();
for (o, short, long) in [
(f64::NAN, 3.0, 2.0),
(100.0, f64::INFINITY, 2.0),
(100.0, 3.0, 0.0),
(100.0, -1.0, 2.0),
] {
let s = atr_breakout(o, short, long, &p);
assert_eq!(s.verdict, AtrBreakoutVerdict::InvalidInput);
assert_eq!(s.levels, None);
}
}
#[test]
fn the_exit_stop_is_tighter_than_the_entry_distance_by_default() {
let p = AtrBreakoutParams::default();
assert!(p.exit_multiplier < p.entry_multiplier);
}
fn bar(close: f64, volume: f64) -> IndexBar {
IndexBar {
high: close,
low: close,
close,
volume,
}
}
#[test]
fn a_down_close_on_rising_volume_counts() {
let bars = [bar(100.0, 1000.0), bar(99.0, 1200.0)];
let c = distribution_days(&bars, &DistributionParams::default());
assert_eq!(c.live, 1);
assert_eq!(c.last_5, 1);
assert_eq!(c.severity, DistributionSeverity::Normal);
}
#[test]
fn a_down_close_on_falling_volume_does_not_count() {
let bars = [bar(100.0, 1000.0), bar(99.0, 800.0)];
assert_eq!(
distribution_days(&bars, &DistributionParams::default()).live,
0
);
}
#[test]
fn a_shallow_down_close_does_not_count() {
let bars = [bar(100.0, 1000.0), bar(99.9, 1200.0)];
assert_eq!(
distribution_days(&bars, &DistributionParams::default()).live,
0
);
}
#[test]
fn six_live_days_read_as_severe() {
let mut bars = vec![bar(100.0, 1000.0)];
let mut close = 100.0;
for _ in 0..6 {
close -= 1.0;
bars.push(bar(close, 2000.0));
bars.push(bar(close + 0.01, 1000.0)); }
let c = distribution_days(&bars, &DistributionParams::default());
assert_eq!(c.live, 6, "{c:?}");
assert_eq!(c.severity, DistributionSeverity::Severe);
}
#[test]
fn the_rally_rule_cancels_an_absorbed_day() {
let bars = [
bar(100.0, 1000.0),
bar(99.0, 1200.0),
bar(105.0, 900.0), ];
let c = distribution_days(&bars, &DistributionParams::default());
assert_eq!(c.live, 0);
assert_eq!(c.invalidated, 1);
}
#[test]
fn a_day_outside_the_window_expires() {
let p = DistributionParams {
window: 2,
..DistributionParams::default()
};
let bars = [
bar(100.0, 1000.0),
bar(99.0, 1200.0), bar(99.1, 900.0),
bar(99.2, 900.0),
bar(99.3, 900.0),
];
let c = distribution_days(&bars, &p);
assert_eq!(c.live, 0, "aged out of a two-session window");
assert_eq!(c.invalidated, 0, "expiry is not invalidation");
}
#[test]
fn too_few_bars_is_a_normal_zero_count() {
let c = distribution_days(&[bar(100.0, 1.0)], &DistributionParams::default());
assert_eq!(c.live, 0);
assert_eq!(c.severity, DistributionSeverity::Normal);
}
fn ohlc(low: f64, close: f64, volume: f64) -> IndexBar {
IndexBar {
high: close,
low,
close,
volume,
}
}
#[test]
fn a_qualifying_day_four_confirms_the_attempt() {
let bars = [
ohlc(90.0, 91.0, 1000.0), ohlc(91.0, 92.0, 1000.0), ohlc(91.5, 92.5, 1000.0), ohlc(92.0, 93.0, 1000.0), ohlc(92.5, 95.0, 2000.0), ];
let s = follow_through_day(&bars, &FollowThroughParams::default());
assert_eq!(s.phase, FollowThroughPhase::ConfirmedUptrend);
assert_eq!(s.follow_through, Some(4));
assert!(s.ever_confirmed);
assert_eq!(s.rally_start, Some(1));
}
#[test]
fn a_day_three_gain_is_too_early_to_confirm() {
let bars = [
ohlc(90.0, 91.0, 1000.0),
ohlc(91.0, 92.0, 1000.0), ohlc(91.5, 92.5, 1000.0), ohlc(92.0, 95.0, 3000.0), ];
let s = follow_through_day(&bars, &FollowThroughParams::default());
assert_eq!(s.phase, FollowThroughPhase::RallyAttempt);
assert_eq!(s.follow_through, None);
assert!(!s.ever_confirmed);
}
#[test]
fn a_qualifying_gain_on_falling_volume_does_not_confirm() {
let bars = [
ohlc(90.0, 91.0, 3000.0),
ohlc(91.0, 92.0, 2000.0),
ohlc(91.5, 92.5, 2000.0),
ohlc(92.0, 93.0, 2000.0),
ohlc(92.5, 95.0, 1000.0), ];
assert_eq!(
follow_through_day(&bars, &FollowThroughParams::default()).phase,
FollowThroughPhase::RallyAttempt
);
}
#[test]
fn a_lower_low_undercuts_a_confirmed_uptrend() {
let bars = [
ohlc(90.0, 91.0, 1000.0),
ohlc(91.0, 92.0, 1000.0),
ohlc(91.5, 92.5, 1000.0),
ohlc(92.0, 93.0, 1000.0),
ohlc(92.5, 95.0, 2000.0), ohlc(85.0, 86.0, 1500.0), ];
let s = follow_through_day(&bars, &FollowThroughParams::default());
assert_eq!(s.phase, FollowThroughPhase::Undercut);
assert!(s.ever_confirmed, "the event still happened");
assert_eq!(s.follow_through, None, "but it is no longer live");
assert_eq!(s.rally_low, Some(85.0));
}
#[test]
fn a_series_that_only_falls_never_starts_an_attempt() {
let bars = [
ohlc(100.0, 100.0, 1000.0),
ohlc(99.0, 99.0, 1000.0),
ohlc(98.0, 98.0, 1000.0),
];
let s = follow_through_day(&bars, &FollowThroughParams::default());
assert_eq!(s.phase, FollowThroughPhase::NoRally);
assert_eq!(s.rally_start, None);
assert_eq!(s.rally_day, 0);
}
#[test]
fn too_few_bars_is_no_rally() {
let s = follow_through_day(&[ohlc(1.0, 1.0, 1.0)], &FollowThroughParams::default());
assert_eq!(s.phase, FollowThroughPhase::NoRally);
assert_eq!(s.rally_low, None);
}
#[test]
fn every_stateful_primitive_replays_identically_from_the_same_state() {
let bp = BounceParams::default();
let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 40.0, 25.0];
let replay = |_: ()| {
let mut bounce = BounceState::default();
let mut gate = GateState::default();
let mut exposure = ExposureState::default();
let mut out = Vec::new();
for (i, &v) in series.iter().enumerate() {
let (side, nb) = bounce_counter(bounce, v, &bp);
bounce = nb;
let price = 100.0 + i as f64;
let (gd, ng) = signal_gate(gate, side, v, 60.0, price, 5);
gate = ng;
let (ed, ne) = max_exposure_timeout(exposure, gd.execute, price, 4);
exposure = ne;
out.push((side, gd.status, ed.action, ed.forced_exit));
}
out
};
assert_eq!(replay(()), replay(()));
}
}