Skip to main content

hyper_playbook/
fsm.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4#[serde(rename_all = "snake_case", tag = "state")]
5pub enum PlaybookState {
6    Idle,
7    PendingEntry {
8        order_id: String,
9        placed_at: u64,
10    },
11    InPosition {
12        position_id: String,
13        entry_price: f64,
14    },
15    PendingExit {
16        order_id: String,
17        placed_at: u64,
18    },
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum FsmError {
23    InvalidTransition { from: String, to: String },
24}
25
26impl std::fmt::Display for FsmError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            FsmError::InvalidTransition { from, to } => {
30                write!(f, "Invalid transition from {} to {}", from, to)
31            }
32        }
33    }
34}
35
36pub struct PlaybookFsm {
37    state: PlaybookState,
38}
39
40impl PlaybookFsm {
41    pub fn new() -> Self {
42        Self {
43            state: PlaybookState::Idle,
44        }
45    }
46
47    pub fn state(&self) -> &PlaybookState {
48        &self.state
49    }
50
51    pub fn is_idle(&self) -> bool {
52        matches!(self.state, PlaybookState::Idle)
53    }
54
55    pub fn is_in_position(&self) -> bool {
56        matches!(self.state, PlaybookState::InPosition { .. })
57    }
58
59    pub fn is_pending(&self) -> bool {
60        matches!(
61            self.state,
62            PlaybookState::PendingEntry { .. } | PlaybookState::PendingExit { .. }
63        )
64    }
65
66    /// Transition: Idle -> PendingEntry (entry signal triggered, order placed)
67    pub fn enter_pending_entry(
68        &mut self,
69        order_id: String,
70        placed_at: u64,
71    ) -> Result<(), FsmError> {
72        if !matches!(self.state, PlaybookState::Idle) {
73            return Err(FsmError::InvalidTransition {
74                from: self.state_name().into(),
75                to: "PendingEntry".into(),
76            });
77        }
78        self.state = PlaybookState::PendingEntry {
79            order_id,
80            placed_at,
81        };
82        Ok(())
83    }
84
85    /// Transition: PendingEntry -> InPosition (order filled)
86    pub fn confirm_entry(&mut self, position_id: String, entry_price: f64) -> Result<(), FsmError> {
87        if !matches!(self.state, PlaybookState::PendingEntry { .. }) {
88            return Err(FsmError::InvalidTransition {
89                from: self.state_name().into(),
90                to: "InPosition".into(),
91            });
92        }
93        self.state = PlaybookState::InPosition {
94            position_id,
95            entry_price,
96        };
97        Ok(())
98    }
99
100    /// Transition: PendingEntry -> Idle (order timeout/cancelled)
101    pub fn cancel_entry(&mut self) -> Result<(), FsmError> {
102        if !matches!(self.state, PlaybookState::PendingEntry { .. }) {
103            return Err(FsmError::InvalidTransition {
104                from: self.state_name().into(),
105                to: "Idle".into(),
106            });
107        }
108        self.state = PlaybookState::Idle;
109        Ok(())
110    }
111
112    /// Transition: InPosition -> PendingExit (exit signal or SL/TP triggered)
113    pub fn enter_pending_exit(&mut self, order_id: String, placed_at: u64) -> Result<(), FsmError> {
114        if !matches!(self.state, PlaybookState::InPosition { .. }) {
115            return Err(FsmError::InvalidTransition {
116                from: self.state_name().into(),
117                to: "PendingExit".into(),
118            });
119        }
120        self.state = PlaybookState::PendingExit {
121            order_id,
122            placed_at,
123        };
124        Ok(())
125    }
126
127    /// Transition: PendingExit -> Idle (close filled)
128    pub fn confirm_exit(&mut self) -> Result<(), FsmError> {
129        if !matches!(self.state, PlaybookState::PendingExit { .. }) {
130            return Err(FsmError::InvalidTransition {
131                from: self.state_name().into(),
132                to: "Idle".into(),
133            });
134        }
135        self.state = PlaybookState::Idle;
136        Ok(())
137    }
138
139    /// Transition: PendingExit -> InPosition (close timeout, retry next cycle)
140    pub fn cancel_exit(&mut self, position_id: String, entry_price: f64) -> Result<(), FsmError> {
141        if !matches!(self.state, PlaybookState::PendingExit { .. }) {
142            return Err(FsmError::InvalidTransition {
143                from: self.state_name().into(),
144                to: "InPosition".into(),
145            });
146        }
147        self.state = PlaybookState::InPosition {
148            position_id,
149            entry_price,
150        };
151        Ok(())
152    }
153
154    /// Force reset to Idle (regime change force close)
155    pub fn force_idle(&mut self) {
156        self.state = PlaybookState::Idle;
157    }
158
159    fn state_name(&self) -> &str {
160        match &self.state {
161            PlaybookState::Idle => "Idle",
162            PlaybookState::PendingEntry { .. } => "PendingEntry",
163            PlaybookState::InPosition { .. } => "InPosition",
164            PlaybookState::PendingExit { .. } => "PendingExit",
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    // === Happy path: full cycle ===
174
175    #[test]
176    fn test_happy_path_full_cycle() {
177        let mut fsm = PlaybookFsm::new();
178        assert!(fsm.is_idle());
179
180        // Idle -> PendingEntry
181        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
182        assert!(fsm.is_pending());
183        assert_eq!(
184            fsm.state(),
185            &PlaybookState::PendingEntry {
186                order_id: "order-1".into(),
187                placed_at: 1000,
188            }
189        );
190
191        // PendingEntry -> InPosition
192        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
193        assert!(fsm.is_in_position());
194        assert_eq!(
195            fsm.state(),
196            &PlaybookState::InPosition {
197                position_id: "pos-1".into(),
198                entry_price: 50000.0,
199            }
200        );
201
202        // InPosition -> PendingExit
203        fsm.enter_pending_exit("order-2".into(), 2000).unwrap();
204        assert!(fsm.is_pending());
205        assert_eq!(
206            fsm.state(),
207            &PlaybookState::PendingExit {
208                order_id: "order-2".into(),
209                placed_at: 2000,
210            }
211        );
212
213        // PendingExit -> Idle
214        fsm.confirm_exit().unwrap();
215        assert!(fsm.is_idle());
216    }
217
218    // === Cancel paths ===
219
220    #[test]
221    fn test_cancel_entry_returns_to_idle() {
222        let mut fsm = PlaybookFsm::new();
223        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
224        fsm.cancel_entry().unwrap();
225        assert!(fsm.is_idle());
226    }
227
228    #[test]
229    fn test_cancel_exit_returns_to_in_position() {
230        let mut fsm = PlaybookFsm::new();
231        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
232        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
233        fsm.enter_pending_exit("order-2".into(), 2000).unwrap();
234
235        fsm.cancel_exit("pos-1".into(), 50000.0).unwrap();
236        assert!(fsm.is_in_position());
237        assert_eq!(
238            fsm.state(),
239            &PlaybookState::InPosition {
240                position_id: "pos-1".into(),
241                entry_price: 50000.0,
242            }
243        );
244    }
245
246    // === force_idle from every state ===
247
248    #[test]
249    fn test_force_idle_from_idle() {
250        let mut fsm = PlaybookFsm::new();
251        fsm.force_idle();
252        assert!(fsm.is_idle());
253    }
254
255    #[test]
256    fn test_force_idle_from_pending_entry() {
257        let mut fsm = PlaybookFsm::new();
258        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
259        fsm.force_idle();
260        assert!(fsm.is_idle());
261    }
262
263    #[test]
264    fn test_force_idle_from_in_position() {
265        let mut fsm = PlaybookFsm::new();
266        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
267        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
268        fsm.force_idle();
269        assert!(fsm.is_idle());
270    }
271
272    #[test]
273    fn test_force_idle_from_pending_exit() {
274        let mut fsm = PlaybookFsm::new();
275        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
276        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
277        fsm.enter_pending_exit("order-2".into(), 2000).unwrap();
278        fsm.force_idle();
279        assert!(fsm.is_idle());
280    }
281
282    // === Invalid transitions ===
283
284    #[test]
285    fn test_invalid_idle_to_in_position() {
286        let mut fsm = PlaybookFsm::new();
287        let err = fsm.confirm_entry("pos-1".into(), 50000.0).unwrap_err();
288        assert_eq!(
289            err,
290            FsmError::InvalidTransition {
291                from: "Idle".into(),
292                to: "InPosition".into(),
293            }
294        );
295    }
296
297    #[test]
298    fn test_invalid_idle_to_pending_exit() {
299        let mut fsm = PlaybookFsm::new();
300        let err = fsm.enter_pending_exit("order-1".into(), 1000).unwrap_err();
301        assert_eq!(
302            err,
303            FsmError::InvalidTransition {
304                from: "Idle".into(),
305                to: "PendingExit".into(),
306            }
307        );
308    }
309
310    #[test]
311    fn test_invalid_pending_entry_to_pending_exit() {
312        let mut fsm = PlaybookFsm::new();
313        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
314        let err = fsm.enter_pending_exit("order-2".into(), 2000).unwrap_err();
315        assert_eq!(
316            err,
317            FsmError::InvalidTransition {
318                from: "PendingEntry".into(),
319                to: "PendingExit".into(),
320            }
321        );
322    }
323
324    #[test]
325    fn test_invalid_in_position_to_idle() {
326        let mut fsm = PlaybookFsm::new();
327        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
328        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
329        let err = fsm.confirm_exit().unwrap_err();
330        assert_eq!(
331            err,
332            FsmError::InvalidTransition {
333                from: "InPosition".into(),
334                to: "Idle".into(),
335            }
336        );
337    }
338
339    #[test]
340    fn test_invalid_in_position_to_pending_entry() {
341        let mut fsm = PlaybookFsm::new();
342        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
343        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
344        let err = fsm.enter_pending_entry("order-2".into(), 2000).unwrap_err();
345        assert_eq!(
346            err,
347            FsmError::InvalidTransition {
348                from: "InPosition".into(),
349                to: "PendingEntry".into(),
350            }
351        );
352    }
353
354    #[test]
355    fn test_invalid_idle_cancel_entry() {
356        let mut fsm = PlaybookFsm::new();
357        let err = fsm.cancel_entry().unwrap_err();
358        assert_eq!(
359            err,
360            FsmError::InvalidTransition {
361                from: "Idle".into(),
362                to: "Idle".into(),
363            }
364        );
365    }
366
367    #[test]
368    fn test_invalid_idle_cancel_exit() {
369        let mut fsm = PlaybookFsm::new();
370        let err = fsm.cancel_exit("pos-1".into(), 50000.0).unwrap_err();
371        assert_eq!(
372            err,
373            FsmError::InvalidTransition {
374                from: "Idle".into(),
375                to: "InPosition".into(),
376            }
377        );
378    }
379
380    #[test]
381    fn test_invalid_idle_confirm_exit() {
382        let mut fsm = PlaybookFsm::new();
383        let err = fsm.confirm_exit().unwrap_err();
384        assert_eq!(
385            err,
386            FsmError::InvalidTransition {
387                from: "Idle".into(),
388                to: "Idle".into(),
389            }
390        );
391    }
392
393    // === Helper methods ===
394
395    #[test]
396    fn test_helpers_idle() {
397        let fsm = PlaybookFsm::new();
398        assert!(fsm.is_idle());
399        assert!(!fsm.is_in_position());
400        assert!(!fsm.is_pending());
401    }
402
403    #[test]
404    fn test_helpers_pending_entry() {
405        let mut fsm = PlaybookFsm::new();
406        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
407        assert!(!fsm.is_idle());
408        assert!(!fsm.is_in_position());
409        assert!(fsm.is_pending());
410    }
411
412    #[test]
413    fn test_helpers_in_position() {
414        let mut fsm = PlaybookFsm::new();
415        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
416        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
417        assert!(!fsm.is_idle());
418        assert!(fsm.is_in_position());
419        assert!(!fsm.is_pending());
420    }
421
422    #[test]
423    fn test_helpers_pending_exit() {
424        let mut fsm = PlaybookFsm::new();
425        fsm.enter_pending_entry("order-1".into(), 1000).unwrap();
426        fsm.confirm_entry("pos-1".into(), 50000.0).unwrap();
427        fsm.enter_pending_exit("order-2".into(), 2000).unwrap();
428        assert!(!fsm.is_idle());
429        assert!(!fsm.is_in_position());
430        assert!(fsm.is_pending());
431    }
432
433    // === Serialization roundtrip ===
434
435    #[test]
436    fn test_serde_idle() {
437        let state = PlaybookState::Idle;
438        let json = serde_json::to_string(&state).unwrap();
439        let deserialized: PlaybookState = serde_json::from_str(&json).unwrap();
440        assert_eq!(state, deserialized);
441    }
442
443    #[test]
444    fn test_serde_pending_entry() {
445        let state = PlaybookState::PendingEntry {
446            order_id: "order-1".into(),
447            placed_at: 1000,
448        };
449        let json = serde_json::to_string(&state).unwrap();
450        assert!(json.contains("\"state\":\"pending_entry\""));
451        let deserialized: PlaybookState = serde_json::from_str(&json).unwrap();
452        assert_eq!(state, deserialized);
453    }
454
455    #[test]
456    fn test_serde_in_position() {
457        let state = PlaybookState::InPosition {
458            position_id: "pos-1".into(),
459            entry_price: 50000.0,
460        };
461        let json = serde_json::to_string(&state).unwrap();
462        assert!(json.contains("\"state\":\"in_position\""));
463        let deserialized: PlaybookState = serde_json::from_str(&json).unwrap();
464        assert_eq!(state, deserialized);
465    }
466
467    #[test]
468    fn test_serde_pending_exit() {
469        let state = PlaybookState::PendingExit {
470            order_id: "order-2".into(),
471            placed_at: 2000,
472        };
473        let json = serde_json::to_string(&state).unwrap();
474        assert!(json.contains("\"state\":\"pending_exit\""));
475        let deserialized: PlaybookState = serde_json::from_str(&json).unwrap();
476        assert_eq!(state, deserialized);
477    }
478
479    #[test]
480    fn test_fsm_error_display() {
481        let err = FsmError::InvalidTransition {
482            from: "Idle".into(),
483            to: "InPosition".into(),
484        };
485        assert_eq!(
486            err.to_string(),
487            "Invalid transition from Idle to InPosition"
488        );
489    }
490}