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 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 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 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 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 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 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 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 #[test]
176 fn test_happy_path_full_cycle() {
177 let mut fsm = PlaybookFsm::new();
178 assert!(fsm.is_idle());
179
180 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 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 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 fsm.confirm_exit().unwrap();
215 assert!(fsm.is_idle());
216 }
217
218 #[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 #[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 #[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 #[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 #[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}