Skip to main content

kestrel_chartkit/
scenario.rs

1//! Generic composite scenario state machine: a reusable multi-stage progression with per-stage
2//! expiry ("Ablauf") and explicit invalidation, generic over any caller-defined stage enum —
3//! rather than one hand-rolled state machine per scenario shape. Ships with three concrete
4//! presets matching the doc's named examples: Edge -> Setup -> Watch -> Trigger, Armed Balance ->
5//! Breakout -> Aftermath, and Direct/Pullback/Failure.
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ScenarioStatus {
9    Active,
10    Expired,
11    Invalidated,
12    /// Reached a configured terminal stage.
13    Completed,
14}
15
16/// Per-stage configuration: how many bars may elapse in this stage before it expires
17/// (`None` = no expiry).
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct StageConfig<S> {
20    pub stage: S,
21    pub max_bars: Option<u32>,
22}
23
24/// A generic multi-stage scenario progression.
25pub struct ScenarioStateMachine<S: Copy + PartialEq> {
26    stages: Vec<StageConfig<S>>,
27    terminal_stages: Vec<S>,
28    current: S,
29    bars_in_stage: u32,
30    status: ScenarioStatus,
31    history: Vec<(S, i64)>,
32}
33
34impl<S: Copy + PartialEq> ScenarioStateMachine<S> {
35    pub fn new(
36        stages: Vec<StageConfig<S>>,
37        terminal_stages: Vec<S>,
38        initial: S,
39        started_at: i64,
40    ) -> Self {
41        Self {
42            stages,
43            terminal_stages,
44            current: initial,
45            bars_in_stage: 0,
46            status: ScenarioStatus::Active,
47            history: vec![(initial, started_at)],
48        }
49    }
50
51    pub fn status(&self) -> ScenarioStatus {
52        self.status
53    }
54
55    pub fn current_stage(&self) -> S {
56        self.current
57    }
58
59    /// Every stage entered so far, oldest first, with the timestamp it was entered.
60    pub fn history(&self) -> &[(S, i64)] {
61        &self.history
62    }
63
64    pub fn bars_in_stage(&self) -> u32 {
65        self.bars_in_stage
66    }
67
68    /// Advances one bar without a stage change; expires the scenario if the current stage's
69    /// `max_bars` is exceeded. No-op once the machine is no longer `Active`.
70    pub fn tick(&mut self) {
71        if self.status != ScenarioStatus::Active {
72            return;
73        }
74        self.bars_in_stage += 1;
75        if let Some(cfg) = self.stages.iter().find(|c| c.stage == self.current) {
76            if let Some(max) = cfg.max_bars {
77                if self.bars_in_stage > max {
78                    self.status = ScenarioStatus::Expired;
79                }
80            }
81        }
82    }
83
84    /// Transitions to `next_stage`, resetting the per-stage bar counter. Returns `false` (no-op)
85    /// if the machine is not currently `Active`. Marks the machine `Completed` if `next_stage` is
86    /// one of the configured terminal stages.
87    pub fn advance(&mut self, next_stage: S, timestamp: i64) -> bool {
88        if self.status != ScenarioStatus::Active {
89            return false;
90        }
91        self.current = next_stage;
92        self.bars_in_stage = 0;
93        self.history.push((next_stage, timestamp));
94        if self.terminal_stages.contains(&next_stage) {
95            self.status = ScenarioStatus::Completed;
96        }
97        true
98    }
99
100    /// Explicitly invalidates the scenario (e.g. a structural break of its setup premise).
101    /// No-op once no longer `Active`.
102    pub fn invalidate(&mut self) {
103        if self.status == ScenarioStatus::Active {
104            self.status = ScenarioStatus::Invalidated;
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------------------------
110// Named presets
111// ---------------------------------------------------------------------------------------------
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum EdgeSetupStage {
115    Edge,
116    Setup,
117    Watch,
118    Trigger,
119}
120
121/// Edge -> Setup -> Watch -> Trigger, each stage sharing the same expiry window.
122pub fn edge_setup_watch_trigger_machine(
123    max_bars_per_stage: u32,
124    started_at: i64,
125) -> ScenarioStateMachine<EdgeSetupStage> {
126    let stages = [
127        EdgeSetupStage::Edge,
128        EdgeSetupStage::Setup,
129        EdgeSetupStage::Watch,
130        EdgeSetupStage::Trigger,
131    ]
132    .into_iter()
133    .map(|stage| StageConfig {
134        stage,
135        max_bars: Some(max_bars_per_stage),
136    })
137    .collect();
138    ScenarioStateMachine::new(
139        stages,
140        vec![EdgeSetupStage::Trigger],
141        EdgeSetupStage::Edge,
142        started_at,
143    )
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum BalanceBreakoutStage {
148    ArmedBalance,
149    Breakout,
150    Aftermath,
151}
152
153/// Armed Balance -> Breakout -> Aftermath.
154pub fn armed_balance_breakout_aftermath_machine(
155    armed_max_bars: u32,
156    breakout_max_bars: u32,
157    started_at: i64,
158) -> ScenarioStateMachine<BalanceBreakoutStage> {
159    let stages = vec![
160        StageConfig {
161            stage: BalanceBreakoutStage::ArmedBalance,
162            max_bars: Some(armed_max_bars),
163        },
164        StageConfig {
165            stage: BalanceBreakoutStage::Breakout,
166            max_bars: Some(breakout_max_bars),
167        },
168        StageConfig {
169            stage: BalanceBreakoutStage::Aftermath,
170            max_bars: None,
171        },
172    ];
173    ScenarioStateMachine::new(
174        stages,
175        vec![BalanceBreakoutStage::Aftermath],
176        BalanceBreakoutStage::ArmedBalance,
177        started_at,
178    )
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum PullbackOutcomeStage {
183    Direct,
184    Pullback,
185    Failure,
186}
187
188/// Direct/Pullback/Failure: three mutually-exclusive resolutions of one setup, all terminal (no
189/// further stage follows any of them).
190pub fn direct_pullback_failure_machine(
191    max_bars: u32,
192    started_at: i64,
193) -> ScenarioStateMachine<PullbackOutcomeStage> {
194    let stages = [
195        PullbackOutcomeStage::Direct,
196        PullbackOutcomeStage::Pullback,
197        PullbackOutcomeStage::Failure,
198    ]
199    .into_iter()
200    .map(|stage| StageConfig {
201        stage,
202        max_bars: Some(max_bars),
203    })
204    .collect();
205    ScenarioStateMachine::new(
206        stages,
207        vec![
208            PullbackOutcomeStage::Direct,
209            PullbackOutcomeStage::Pullback,
210            PullbackOutcomeStage::Failure,
211        ],
212        PullbackOutcomeStage::Direct,
213        started_at,
214    )
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_advances_through_stages_and_completes() {
223        let mut machine = edge_setup_watch_trigger_machine(10, 0);
224        assert_eq!(machine.current_stage(), EdgeSetupStage::Edge);
225        assert_eq!(machine.status(), ScenarioStatus::Active);
226
227        assert!(machine.advance(EdgeSetupStage::Setup, 60));
228        assert!(machine.advance(EdgeSetupStage::Watch, 120));
229        assert!(machine.advance(EdgeSetupStage::Trigger, 180));
230
231        assert_eq!(machine.status(), ScenarioStatus::Completed);
232        assert_eq!(machine.history().len(), 4);
233        // No further advance is accepted once completed.
234        assert!(!machine.advance(EdgeSetupStage::Edge, 240));
235    }
236
237    #[test]
238    fn test_expires_after_max_bars_in_stage() {
239        let mut machine = edge_setup_watch_trigger_machine(3, 0);
240        for _ in 0..3 {
241            machine.tick();
242            assert_eq!(machine.status(), ScenarioStatus::Active);
243        }
244        machine.tick();
245        assert_eq!(machine.status(), ScenarioStatus::Expired);
246    }
247
248    #[test]
249    fn test_tick_resets_on_advance() {
250        let mut machine = edge_setup_watch_trigger_machine(2, 0);
251        machine.tick();
252        machine.tick();
253        assert!(machine.advance(EdgeSetupStage::Setup, 60));
254        // Bar counter reset by the transition, so two more ticks must not expire it yet.
255        machine.tick();
256        machine.tick();
257        assert_eq!(machine.status(), ScenarioStatus::Active);
258    }
259
260    #[test]
261    fn test_invalidate_is_terminal_and_blocks_further_advance() {
262        let mut machine = edge_setup_watch_trigger_machine(10, 0);
263        machine.advance(EdgeSetupStage::Setup, 60);
264        machine.invalidate();
265        assert_eq!(machine.status(), ScenarioStatus::Invalidated);
266        assert!(!machine.advance(EdgeSetupStage::Watch, 120));
267        // Invalidating twice is a harmless no-op.
268        machine.invalidate();
269        assert_eq!(machine.status(), ScenarioStatus::Invalidated);
270    }
271
272    #[test]
273    fn test_armed_balance_breakout_aftermath_preset() {
274        let mut machine = armed_balance_breakout_aftermath_machine(5, 3, 0);
275        assert!(machine.advance(BalanceBreakoutStage::Breakout, 60));
276        assert!(machine.advance(BalanceBreakoutStage::Aftermath, 120));
277        assert_eq!(machine.status(), ScenarioStatus::Completed);
278    }
279
280    #[test]
281    fn test_direct_pullback_failure_preset_each_branch_is_terminal() {
282        let mut machine = direct_pullback_failure_machine(5, 0);
283        assert!(machine.advance(PullbackOutcomeStage::Failure, 60));
284        assert_eq!(machine.status(), ScenarioStatus::Completed);
285    }
286}