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}