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