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}