Skip to main content

optionstratlib_simulation/simulation/steps/
x.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/3/25
5******************************************************************************/
6use crate::error::SimulationError;
7use optionstratlib_core::model::ExpirationDate;
8use optionstratlib_core::model::Positive;
9use optionstratlib_core::utils::TimeFrame;
10use optionstratlib_core::utils::time::convert_time_frame;
11use serde::{Serialize, Serializer};
12use std::convert::TryInto;
13use std::fmt::{Display, Formatter};
14use std::ops::AddAssign;
15use tracing::debug;
16
17/// Represents a step in a time series with an indexed value at a specific time point.
18///
19/// This struct encapsulates a value at a specific point in a time series, tracking not only
20/// the value itself but also its position (index), the time unit it belongs to, and its
21/// associated datetime. It's designed to be used in financial and statistical analysis
22/// where tracking values across time is essential.
23///
24/// The generic parameter `T` allows for different value types while ensuring they can be
25/// incrementally accumulated (via `AddAssign`) and converted to a `Positive` type for
26/// calculations that require non-negative values.
27///
28/// # Type Parameters
29///
30/// * `T` - The type of the value being stored, which must implement `AddAssign`, be convertible
31///   to `Positive`, and be `Copy`.
32///
33/// # Fields
34///
35/// * `index` - An integer representing the position of this step in a sequence.
36///
37/// * `value` - The actual data value at this time step, of generic type `T`.
38///
39/// * `time_unit` - Defines the time granularity (e.g., day, hour, minute) for this step.
40///
41/// * `datetime` - The specific expiration date associated with this time step,
42///   which can be either a relative number of days or an absolute datetime.
43///
44#[derive(Debug, Clone, Copy)]
45pub struct Xstep<T>
46where
47    T: Copy + TryInto<Positive> + AddAssign + Display,
48{
49    index: i32,
50    step_size_in_time: T,
51    time_unit: TimeFrame,
52    datetime: ExpirationDate,
53}
54
55/// Implementation for the `Xstep<T>` struct, which represents a time step in a simulation.
56///
57/// The `Xstep<T>` struct is designed to handle sequential time steps in financial simulations,
58/// allowing for forward and backward movement in time. The struct maintains an index counter,
59/// a value of type T, a time unit, and an expiration date.
60///
61/// # Type Parameters
62///
63/// * `T` - A type that implements `AddAssign`, can be converted into `Positive`, and is `Copy`.
64///   This typically represents a numeric value used for calculating time differences.
65///
66/// # Examples
67///
68/// ```rust
69///
70/// // Create a step with 7 days as the value, using days as the time unit
71/// use optionstratlib_core::model::Positive;
72/// use optionstratlib_core::pos_or_panic;
73/// use optionstratlib_core::model::ExpirationDate;
74/// use optionstratlib_simulation::simulation::steps::Xstep;
75/// use optionstratlib_core::utils::TimeFrame;
76/// let step = Xstep::new(pos_or_panic!(7.0), TimeFrame::Day, ExpirationDate::Days(pos_or_panic!(30.0)));
77///
78/// // Move to the next step (forward in time)
79/// let next_step = step.next();
80///
81/// // Move to the previous step (backward in time)
82/// let prev_step = step.previous();
83/// ```
84impl<T> Xstep<T>
85where
86    T: Copy + TryInto<Positive> + AddAssign + Display,
87{
88    /// Creates a new `Xstep` with the specified value, time unit, and datetime.
89    ///
90    /// # Parameters
91    ///
92    /// * `value` - The step size value
93    /// * `time_unit` - The time unit for this step (e.g., Day, Week, Month)
94    /// * `datetime` - The expiration date. Stored as-is when the variant is
95    ///   [`ExpirationDate::Days`]; [`ExpirationDate::DateTime`] is normalized
96    ///   to `Days` via [`ExpirationDate::get_days`]. If that conversion fails
97    ///   (past date / invalid datetime), the step falls back to
98    ///   `ExpirationDate::Days(Positive::ZERO)` and emits `tracing::error!`.
99    ///
100    /// # Returns
101    ///
102    /// A new `Xstep<T>` instance initialized with the provided parameters and index set to 0.
103    pub fn new(value: T, time_unit: TimeFrame, datetime: ExpirationDate) -> Self {
104        let datetime = match datetime {
105            ExpirationDate::Days(_) => datetime,
106            ExpirationDate::DateTime(dt) => match datetime.get_days() {
107                Ok(days) => ExpirationDate::Days(days),
108                Err(e) => {
109                    tracing::error!(
110                        datetime = %dt,
111                        error = %e,
112                        "Xstep::new: could not convert ExpirationDate::DateTime to Days; defaulting to Days(0)"
113                    );
114                    ExpirationDate::Days(Positive::ZERO)
115                }
116            },
117        };
118        Self {
119            index: 0,
120            step_size_in_time: value,
121            time_unit,
122            datetime,
123        }
124    }
125
126    /// Returns a reference to the index of the time step.
127    ///
128    /// The index represents the position of this step in a sequence of time steps.
129    ///
130    /// # Returns
131    ///
132    /// * `&i32` - A reference to the index value.
133    pub fn index(&self) -> &i32 {
134        &self.index
135    }
136
137    /// Returns a reference to the step size in time units.
138    ///
139    /// This represents the magnitude of this time step in the context of its time frame,
140    /// such as the number of days, hours, or other time units.
141    ///
142    /// # Returns
143    ///
144    /// * `&T` - A reference to the step size value.
145    pub fn step_size_in_time(&self) -> &T {
146        &self.step_size_in_time
147    }
148
149    /// Returns a reference to the time unit associated with this step.
150    ///
151    /// The time unit defines the granularity of the time measurement (e.g., day, hour, minute).
152    ///
153    /// # Returns
154    ///
155    /// * `&TimeFrame` - A reference to the time unit enumeration.
156    pub fn time_unit(&self) -> &TimeFrame {
157        &self.time_unit
158    }
159
160    /// Returns a reference to the datetime associated with this step.
161    ///
162    /// This represents the specific point in time (as an expiration date) that
163    /// this step corresponds to, which can be either a relative value or an absolute datetime.
164    ///
165    /// # Returns
166    ///
167    /// * `&ExpirationDate` - A reference to the expiration date.
168    pub fn datetime(&self) -> &ExpirationDate {
169        &self.datetime
170    }
171
172    /// Calculates the number of days left until the expiry date associated with this step.
173    ///
174    /// This method delegates to the underlying `ExpirationDate` component to determine
175    /// the days remaining until expiration. It provides a consistent way to access time-to-expiry
176    /// regardless of how the date was originally specified (as days or as an absolute datetime).
177    ///
178    /// # Returns
179    ///
180    /// * `Result<Positive, SimulationError>` - A positive decimal value representing the number of days
181    ///   until expiration, or an error if the calculation cannot be performed.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`SimulationError::ExpirationDate`] when the stored
186    /// [`ExpirationDate`] cannot be resolved to a non-negative day
187    /// count (e.g. the datetime is in the past).
188    pub fn days_left(&self) -> Result<Positive, SimulationError> {
189        Ok(self.datetime.get_days()?)
190    }
191
192    /// Generates the next step by reducing the expiration days by the step value.
193    ///
194    /// This method calculates a new `Xstep` instance with its index incremented by 1,
195    /// and the expiration date reduced by the equivalent of `value` in days.
196    ///
197    /// # Returns
198    ///
199    /// A new `Xstep<T>` instance with updated index and datetime values.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`SimulationError::ExpirationReached`] when the
204    /// current step is already at expiration (no forward step is
205    /// possible), [`SimulationError::ExpirationDate`] when the
206    /// underlying [`ExpirationDate::get_days`] call fails,
207    /// [`SimulationError::PositiveError`] when the step cannot be expressed
208    /// in days (a custom time unit with no periods in a year, or a step
209    /// beyond the `Positive` range), and [`SimulationError::StepError`]
210    /// when the step index overflows `i32::MAX`.
211    pub fn next(&self) -> Result<Self, SimulationError> {
212        let days = self.datetime.get_days()?;
213        if days == Positive::ZERO {
214            return Err(SimulationError::ExpirationReached);
215        }
216        let days_to_rest = convert_time_frame(
217            self.step_size_in_time.try_into().map_err(|_| {
218                SimulationError::step_error("Failed to convert step size to Positive")
219            })?,
220            &self.time_unit,
221            &TimeFrame::Day,
222        )?;
223        let datetime = if days_to_rest <= days {
224            ExpirationDate::Days(days.checked_sub(&days_to_rest)?)
225        } else {
226            ExpirationDate::Days(Positive::ZERO)
227        };
228        debug!(
229            "days_to_rest: {}, days: {}, datetime: {}",
230            days_to_rest, days, datetime
231        );
232        Ok(Self {
233            index: self.index.checked_add(1).ok_or_else(|| {
234                SimulationError::step_error("step index overflowed advancing the walk")
235            })?,
236            step_size_in_time: self.step_size_in_time,
237            time_unit: self.time_unit,
238            datetime,
239        })
240    }
241
242    /// Generates the previous step by increasing the expiration days by the step value.
243    ///
244    /// This method calculates a new `Xstep` instance with its index decremented by 1,
245    /// and the expiration date increased by the equivalent of `value` in days.
246    ///
247    /// # Returns
248    ///
249    /// A new `Xstep<T>` instance with updated index and datetime values.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`SimulationError::ExpirationDate`] when the underlying
254    /// [`ExpirationDate::get_days`] call fails,
255    /// [`SimulationError::StepError`] when the step-size conversion to
256    /// `Positive` fails or when the step index underflows `i32::MIN`, and
257    /// [`SimulationError::PositiveError`] when the step cannot be
258    /// expressed in days or rewinding the expiration leaves the
259    /// representable `Positive` range.
260    pub fn previous(&self) -> Result<Self, SimulationError> {
261        let days = self.datetime.get_days()?;
262        let days_to_rest = convert_time_frame(
263            self.step_size_in_time.try_into().map_err(|_| {
264                SimulationError::step_error("Failed to convert step size to Positive")
265            })?,
266            &self.time_unit,
267            &TimeFrame::Day,
268        )?;
269        // Rewinding adds days back, so a step taken from an expiration
270        // already near `Positive::MAX` leaves the representable range.
271        let datetime = ExpirationDate::Days(days.checked_add(&days_to_rest)?);
272        Ok(Self {
273            index: self.index.checked_sub(1).ok_or_else(|| {
274                SimulationError::step_error("step index underflowed rewinding the walk")
275            })?,
276            step_size_in_time: self.step_size_in_time,
277            time_unit: self.time_unit,
278            datetime,
279        })
280    }
281}
282
283impl<T> Display for Xstep<T>
284where
285    T: Copy + TryInto<Positive> + AddAssign + Display,
286{
287    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
288        write!(
289            f,
290            "Xstep {{ index: {}, value: {}, time_unit: {:?}, datetime: {} }}",
291            self.index, self.step_size_in_time, self.time_unit, self.datetime
292        )
293    }
294}
295
296impl<T> Serialize for Xstep<T>
297where
298    T: Copy + TryInto<Positive> + AddAssign + Display + Serialize,
299{
300    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
301    where
302        S: Serializer,
303    {
304        // Use a struct with 4 fields to represent Xstep
305        use serde::ser::SerializeStruct;
306        let step_size_in_time: Positive =
307            self.step_size_in_time.try_into().unwrap_or(Positive::ZERO);
308        let mut state = serializer.serialize_struct("Xstep", 4)?;
309        state.serialize_field("index", &self.index)?;
310        state.serialize_field("step_size_in_time", &step_size_in_time)?;
311        state.serialize_field("time_unit", &self.time_unit)?;
312        state.serialize_field("datetime", &self.datetime)?;
313        state.end()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use optionstratlib_core::model::ExpirationDate;
321    use optionstratlib_core::pos_or_panic;
322
323    #[test]
324    fn test_days_left() {
325        let mut step = Xstep::new(
326            1.5f64,
327            TimeFrame::Day,
328            ExpirationDate::Days(pos_or_panic!(30.0)),
329        );
330        step.index = 42;
331
332        assert!(step.next().is_ok());
333
334        assert_eq!(step.days_left().unwrap(), pos_or_panic!(30.0));
335        let step1 = step.next().unwrap();
336        assert_eq!(*step1.index(), 43);
337        assert_eq!(step1.days_left().unwrap(), pos_or_panic!(28.5));
338        let step2 = step1.next().unwrap();
339        assert_eq!(*step2.index(), 44);
340        assert_eq!(step2.days_left().unwrap(), pos_or_panic!(27.0));
341        let step3 = step2.next().unwrap();
342        assert_eq!(*step3.index(), 45);
343        assert_eq!(step3.days_left().unwrap(), pos_or_panic!(25.5));
344        let step4 = step3.previous().unwrap();
345        assert_eq!(*step4.index(), 44);
346        assert_eq!(step4.days_left().unwrap(), pos_or_panic!(27.0));
347    }
348}
349
350#[cfg(test)]
351mod tests_serialize {
352    use super::*;
353    use optionstratlib_core::model::ExpirationDate;
354    use optionstratlib_core::pos_or_panic;
355
356    use rust_decimal_macros::dec;
357    use serde_json::{Value, json};
358
359    #[test]
360    fn test_serialized_structure() {
361        // Create an Xstep with f64
362        let mut step = Xstep::new(
363            1.5f64,
364            TimeFrame::Day,
365            ExpirationDate::Days(pos_or_panic!(30.0)),
366        );
367        step.index = 42;
368
369        // Serialize to JSON
370        let serialized = serde_json::to_string(&step).unwrap();
371        let parsed: Value = serde_json::from_str(&serialized).unwrap();
372
373        // Check all fields exist and have correct types
374        assert!(parsed.is_object());
375        assert!(parsed.get("index").unwrap().is_i64());
376        assert!(parsed.get("step_size_in_time").unwrap().is_string());
377        assert!(
378            parsed.get("time_unit").unwrap().is_object()
379                || parsed.get("time_unit").unwrap().is_string()
380        );
381        assert!(parsed.get("datetime").unwrap().is_object());
382
383        // Check field values
384        assert_eq!(parsed["index"], json!(42));
385        assert_eq!(parsed["step_size_in_time"], json!("1.5"));
386    }
387
388    #[test]
389    fn test_serialization_value_conversion() {
390        // Create instances with different types but same value
391        let step_f64 = Xstep::new(2.5f64, TimeFrame::Day, ExpirationDate::Days(Positive::ONE));
392        let step_decimal = Xstep::new(
393            dec!(2.5),
394            TimeFrame::Day,
395            ExpirationDate::Days(Positive::ONE),
396        );
397        let step_positive = Xstep::new(
398            pos_or_panic!(2.5),
399            TimeFrame::Day,
400            ExpirationDate::Days(Positive::ONE),
401        );
402
403        // Serialize all three
404        let json_f64 = serde_json::to_string(&step_f64).unwrap();
405        let json_decimal = serde_json::to_string(&step_decimal).unwrap();
406        let json_positive = serde_json::to_string(&step_positive).unwrap();
407
408        // Parse to access and check the step_size_in_time values
409        let parsed_f64: Value = serde_json::from_str(&json_f64).unwrap();
410        let parsed_decimal: Value = serde_json::from_str(&json_decimal).unwrap();
411        let parsed_positive: Value = serde_json::from_str(&json_positive).unwrap();
412
413        // All should serialize to the same value
414        assert_eq!(parsed_f64["step_size_in_time"], json!("2.5"));
415        assert_eq!(parsed_decimal["step_size_in_time"], json!("2.5"));
416        assert_eq!(parsed_positive["step_size_in_time"], json!("2.5"));
417    }
418
419    #[test]
420    fn test_serialization_format_identity() {
421        // Create instances with different types
422        let step_f64 = Xstep::new(3.1f64, TimeFrame::Hour, ExpirationDate::Days(Positive::ONE));
423        let step_decimal = Xstep::new(
424            dec!(3.1),
425            TimeFrame::Hour,
426            ExpirationDate::Days(Positive::ONE),
427        );
428        let step_positive = Xstep::new(
429            pos_or_panic!(3.1),
430            TimeFrame::Hour,
431            ExpirationDate::Days(Positive::ONE),
432        );
433
434        // Serialize all three
435        let json_f64 = serde_json::to_string(&step_f64).unwrap();
436        let json_decimal = serde_json::to_string(&step_decimal).unwrap();
437        let json_positive = serde_json::to_string(&step_positive).unwrap();
438
439        // They should all serialize to identical JSON
440        assert_eq!(json_f64, json_decimal);
441        assert_eq!(json_decimal, json_positive);
442    }
443
444    #[test]
445    fn test_serialization_edge_cases() {
446        // Test with zero
447        let step_zero = Xstep::new(
448            0.01f64,
449            TimeFrame::Minute,
450            ExpirationDate::Days(Positive::ZERO),
451        );
452        let json_zero = serde_json::to_string(&step_zero).unwrap();
453        let parsed_zero: Value = serde_json::from_str(&json_zero).unwrap();
454        assert_eq!(parsed_zero["step_size_in_time"], json!("0.01"));
455
456        // Test with very small number
457        let step_small = Xstep::new(
458            0.00001f64,
459            TimeFrame::Minute,
460            ExpirationDate::Days(Positive::ONE),
461        );
462        let json_small = serde_json::to_string(&step_small).unwrap();
463        let parsed_small: Value = serde_json::from_str(&json_small).unwrap();
464        assert!(
465            parsed_small["step_size_in_time"]
466                .as_str()
467                .unwrap()
468                .parse::<f64>()
469                .unwrap()
470                > 0.0
471        );
472        assert!(
473            parsed_small["step_size_in_time"]
474                .as_str()
475                .unwrap()
476                .parse::<f64>()
477                .unwrap()
478                < 0.0001
479        );
480
481        // Test with very large number
482        let step_large = Xstep::new(
483            1_000_000.01f64,
484            TimeFrame::Minute,
485            ExpirationDate::Days(Positive::ONE),
486        );
487        let json_large = serde_json::to_string(&step_large).unwrap();
488        let parsed_large: Value = serde_json::from_str(&json_large).unwrap();
489        assert_eq!(parsed_large["step_size_in_time"], json!("1000000.01"));
490    }
491
492    #[test]
493    fn test_serialization_precision() {
494        // Create a step with a value that has many decimal places
495        let step = Xstep::new(
496            1.23456789f64,
497            TimeFrame::Day,
498            ExpirationDate::Days(Positive::ONE),
499        );
500
501        // Serialize and parse
502        let serialized = serde_json::to_string(&step).unwrap();
503        let parsed: Value = serde_json::from_str(&serialized).unwrap();
504
505        // Check precision is maintained (to reasonable float precision)
506        let value = parsed["step_size_in_time"]
507            .as_str()
508            .unwrap()
509            .parse::<f64>()
510            .unwrap();
511        assert!((value - 1.23456789).abs() < 0.0000001);
512    }
513
514    #[test]
515    fn test_datetime_serialization() {
516        // Test with Days expiration
517        let step_days = Xstep::new(
518            1.0f64,
519            TimeFrame::Day,
520            ExpirationDate::Days(pos_or_panic!(30.0)),
521        );
522
523        let serialized_days = serde_json::to_string(&step_days).unwrap();
524        let parsed_days: Value = serde_json::from_str(&serialized_days).unwrap();
525
526        assert!(parsed_days["datetime"].is_object());
527        assert!(parsed_days["datetime"].get("days").is_some());
528
529        // Note: We don't test DateTime because the new constructor explicitly
530        // panics for DateTime variant, which matches the implementation
531    }
532
533    #[test]
534    fn test_datetime_constructor_normalizes_to_days() {
535        // `Xstep::new` now normalizes `ExpirationDate::DateTime` to
536        // `Days(N)` rather than panicking (issue #323).
537        let date_time = chrono::Utc::now() + chrono::Duration::days(10);
538        let step = Xstep::new(1.0f64, TimeFrame::Day, ExpirationDate::DateTime(date_time));
539        match step.datetime() {
540            ExpirationDate::Days(_) => {}
541            other => panic!("expected ExpirationDate::Days, got {other:?}"),
542        }
543    }
544}