optionchain_simulator 0.2.17

OptionChain-Simulator is a lightweight REST API service that simulates an evolving option chain with every request. It is designed for developers building or testing trading systems, backtesters, and visual tools that depend on option data streams but want to avoid relying on live data feeds.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
//! Which strikes a simulation quotes, and for how long.
//!
//! Upstream rebuilds the strike ladder around the CURRENT underlying at every
//! step, so the set of quoted contracts follows the spot. `chain_size` fixes how
//! many strikes are quoted; it does not fix which. Measured on a live v2
//! simulation, a spot move of 0.27 percent over two steps was enough for the
//! 4850 strike to leave the chain.
//!
//! For a client browsing a chain that is the right behaviour. For a client
//! HOLDING a position it is not: a leg opened at 4850 cannot be marked, closed
//! or settled at a step where 4850 does not exist, and over the 500 steps of the
//! reference scenario that is the normal case rather than an edge one. A
//! defined-risk structure loses its furthest wings first, which are exactly the
//! legs that cap its risk.
//!
//! [`StrikeLadder::Pinned`] answers that by fixing the contract universe at
//! creation: the ladder is computed once from `initial_price`, `chain_size` and
//! `strike_interval`, and every step quotes that same set. A simulation becomes
//! a closed world for its whole life, which is what makes a position in it well
//! defined. [`StrikeLadder::Rolling`] is the default and is exactly what the
//! service did before.
//!
//! # How a pinned ladder is served
//!
//! Upstream anchors every chain at `rounder(underlying, strike_interval)`, which
//! snaps to a MULTIPLE of the interval, so every ladder it builds — at any spot
//! — lies on the same grid. A pinned strike is therefore always reachable from
//! the current at-the-money strike by a whole number of intervals.
//!
//! Serving a pinned ladder is then: ask upstream for a chain wide enough to
//! reach the furthest pinned strike from where the spot is now, and keep the
//! pinned strikes out of it. The chain is priced by upstream exactly as it
//! always was, at the current spot, and nothing here builds a contract by hand.
//!
//! The cost is the strikes that get priced and dropped, which grows as the spot
//! drifts away from where it started. That is the price of the closed world, and
//! it is paid only by a simulation that asked for one.
//!
//! **A pinned ladder does not follow a large move.** If the spot leaves the
//! pinned range the chain becomes all calls or all puts, which is correct and
//! informative: a real chain does get listed further out, but a simulation that
//! silently invented new strikes would not be the closed world this exists to
//! provide. Widen the ladder at creation, with `chain_size`, rather than at step
//! time.

use crate::session::{ExpirationSchedule, SimulationParametersV2};
use crate::utils::ChainError;
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use utoipa::ToSchema;

/// Which strikes a simulation quotes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum StrikeLadder {
    /// The ladder is rebuilt around the current underlying at every step, so
    /// the quoted strikes stay near the money and a contract can leave the
    /// chain. The default, and what the service has always done.
    #[default]
    Rolling,
    /// The ladder is fixed at creation and every step quotes the same strikes,
    /// so a contract quoted once is quoted for the simulation's whole life.
    Pinned,
}

impl StrikeLadder {
    /// Whether this ladder fixes the contract universe.
    #[must_use]
    pub fn is_pinned(self) -> bool {
        matches!(self, StrikeLadder::Pinned)
    }
}

/// The strikes a pinned simulation quotes, resolved once.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PinnedLadder {
    /// The strikes, ascending. A `BTreeSet` because that is how a chain stores
    /// its contracts and because membership is what the filter asks.
    strikes: BTreeSet<Positive>,
    /// The grid the strikes sit on.
    interval: Positive,
}

impl PinnedLadder {
    /// Resolves the ladder a simulation pinned at creation.
    ///
    /// A pure function of parameters that are already stored — the initial
    /// price, the chain size and the strike interval — so a replay reproduces
    /// it without anything extra being persisted.
    ///
    /// # Errors
    ///
    /// Returns [`ChainError::Validation`] when the parameters carry no
    /// `strike_interval`. A pinned ladder needs a fixed grid, and with the
    /// interval absent upstream derives a different one per expiration, so
    /// there would be no single ladder to pin. The request path refuses the
    /// combination, so this is the stored-document half of the same guard.
    pub(crate) fn resolve(parameters: &SimulationParametersV2) -> Result<Self, ChainError> {
        let Some(interval) = parameters.strike_interval else {
            return Err(ChainError::Validation {
                field: "strike_interval".to_string(),
                reason: "a pinned strike ladder needs an explicit strike_interval: without one \
                         upstream derives the interval from the expiration AND the chain size, \
                         and the pinned path varies the chain size per step, so the grid would \
                         move under the ladder it is meant to hold still"
                    .to_string(),
            });
        };

        let chain_size = parameters
            .chain_size
            .unwrap_or(crate::domain::simulator::DEFAULT_CHAIN_SIZE);
        let centre = at_the_money(parameters.initial_price, interval)?;

        // A centre of zero is not a strike, and every offset above it would be
        // a ladder hanging off nothing. It happens when the initial price is
        // below half an interval, which is a configuration to refuse rather
        // than to round away.
        if centre == Positive::ZERO {
            return Err(ChainError::Validation {
                field: "strike_interval".to_string(),
                reason: format!(
                    "an interval of {interval} rounds an initial price of {} to a strike of \
                     zero, so there is no ladder to pin",
                    parameters.initial_price
                ),
            });
        }

        // Checked throughout: `strike_interval` is client input with no upper
        // bound, and both `Positive`'s operators and `Decimal`'s panic on
        // overflow. Upstream's own loop guards the same multiplication and
        // addition; the mirror keeps the guards rather than only the maths.
        let mut strikes = BTreeSet::new();
        strikes.insert(centre);
        for step in 1..=chain_size {
            let offset = interval
                .checked_mul_dec(Decimal::from(step))
                .map_err(|error| ladder_overflow(interval, step, &error.to_string()))?;

            let upper = centre
                .checked_add(&offset)
                .map_err(|error| ladder_overflow(interval, step, &error.to_string()))?;
            strikes.insert(upper);

            // A strike at or below zero is not a contract, and upstream stops
            // extending downwards at the same point. `checked_sub` refuses to
            // go negative, which is exactly that rule.
            if let Ok(lower) = centre.checked_sub(&offset)
                && lower > Positive::ZERO
            {
                strikes.insert(lower);
            }
        }

        Ok(Self { strikes, interval })
    }

    /// The strikes, ascending.
    #[cfg(test)]
    #[must_use]
    pub(crate) fn strikes(&self) -> &BTreeSet<Positive> {
        &self.strikes
    }

    /// How wide a chain built at `spot` must be to contain every pinned strike.
    ///
    /// Measured in strikes per side, which is what `chain_size` means to
    /// upstream: the distance in intervals from the spot's at-the-money strike
    /// to whichever pinned strike is furthest from it.
    ///
    /// `maximum` is the ceiling [`resolve_pinned_ceiling`] fixed at creation
    /// and the parameters carry: the smaller of
    /// the simulation's own snapshot budget and the absolute ceiling
    /// [`MAX_PINNED_WIDTH`]. A pinned step must not be able to price more
    /// contracts than the configuration was validated for, nor a wider chain
    /// than a client could have requested, simply because the underlying
    /// drifted.
    ///
    /// # Errors
    ///
    /// Returns [`ChainError::Validation`] naming `strike_ladder` when the spot
    /// has fallen so far that its chain would be anchored at a strike of zero,
    /// and when the distance exceeds `maximum`. Reaching it takes a spot that has
    /// moved that many intervals from where the simulation started, and quoting
    /// a subset of the pinned ladder would silently break the guarantee the
    /// ladder exists to make. It is a validation failure rather than an
    /// internal one because it is a foreseeable consequence of the
    /// configuration the client chose, and the message says which.
    pub(crate) fn width_from(&self, spot: Positive, maximum: usize) -> Result<usize, ChainError> {
        let anchor = at_the_money(spot, self.interval)?;

        // A spot below half the interval anchors at zero, and upstream refuses
        // to build a chain around a strike of zero. Caught HERE, before
        // anything is priced: the same failure raised during a build reaches
        // an export after its 200 and its prologue have gone out, which leaves
        // the client a truncated file rather than a rejection.
        if anchor == Positive::ZERO {
            return Err(ChainError::Validation {
                field: "strike_ladder".to_string(),
                reason: format!(
                    "the underlying has fallen to {spot}, below half the {} interval this \
                     simulation pinned, so its chain would have to be built around a strike of \
                     zero; a pinned ladder cannot follow a move that far",
                    self.interval
                ),
            });
        }

        let centre = anchor.to_dec();
        let interval = self.interval.to_dec();

        let mut widest = 0_usize;
        for strike in &self.strikes {
            let distance = (strike.to_dec() - centre).abs();
            let steps = distance
                .checked_div(interval)
                .map(|steps| steps.ceil())
                .and_then(|steps| usize::try_from(steps).ok())
                .ok_or_else(|| ChainError::Validation {
                    field: "strike_ladder".to_string(),
                    reason: format!(
                        "the pinned strike {strike} is unreachable from a spot of {spot}"
                    ),
                })?;
            widest = widest.max(steps);
        }

        if widest > maximum {
            return Err(ChainError::Validation {
                field: "strike_ladder".to_string(),
                reason: format!(
                    "the underlying has moved {widest} strikes from the ladder this simulation \
                     pinned at creation, past the {maximum} a chain may carry; a pinned ladder \
                     does not follow a move that far, and widening chain_size cannot help \
                     because a wider ladder puts its far strikes further still from this spot; \
                     create the simulation with a smaller chain_size, around an initial_price \
                     nearer where the underlying trades, or with the rolling ladder, which \
                     re-centres on every step"
                ),
            });
        }
        Ok(widest)
    }

    /// Drops from a built chain every strike this ladder does not name, and
    /// refuses a chain that did not carry them all.
    ///
    /// The chain is built wide enough to contain the whole pinned set, but
    /// "wide enough" is a request: upstream stops extending its ladder for
    /// reasons of its own — a strike that would fall at or below zero, a
    /// genuine pricing failure on both wings — and a filter cannot tell a
    /// missing strike from one it dropped. Returning a partial ladder silently
    /// would reproduce the exact hole this feature exists to close, so a short
    /// chain is an error naming what is absent.
    ///
    /// # Errors
    ///
    /// Returns [`ChainError::Internal`] when a pinned strike is not in the
    /// built chain.
    pub(crate) fn keep_pinned(
        &self,
        chain: &mut optionstratlib::chains::chain::OptionChain,
    ) -> Result<(), ChainError> {
        let strikes = &self.strikes;
        let contracts = std::mem::take(&mut chain.options);
        chain.options = contracts
            .into_iter()
            .filter(|contract| strikes.contains(&contract.strike_price))
            .collect();

        if chain.options.len() == strikes.len() {
            return Ok(());
        }

        let present: BTreeSet<Positive> = chain
            .options
            .iter()
            .map(|contract| contract.strike_price)
            .collect();
        let missing: Vec<String> = strikes
            .iter()
            .filter(|strike| !present.contains(strike))
            .map(ToString::to_string)
            .collect();

        Err(ChainError::Internal(format!(
            "the pinned ladder is incomplete: the chain built at this step does not carry {}",
            missing.join(", ")
        )))
    }
}

/// The strike upstream anchors a chain at, for a given underlying.
///
/// Mirrors `optionstratlib::chains::utils::rounder`, which is `pub(crate)`
/// there: the nearest multiple of the interval, halves rounding up. It is
/// mirrored rather than guessed, and
/// `test_the_mirrored_anchor_matches_upstream` pins the two together by
/// building a real chain and reading the strike upstream chose.
/// # Errors
///
/// Returns [`ChainError::Validation`] naming `strike_interval` when the
/// rounding cannot be represented. `Decimal`'s operators panic on overflow and
/// the interval is unbounded client input, so every step is checked; upstream
/// guards the same arithmetic in its own loop.
pub(crate) fn at_the_money(
    underlying: Positive,
    interval: Positive,
) -> Result<Positive, ChainError> {
    if interval == Positive::ZERO {
        return Ok(underlying);
    }

    let price = underlying.to_dec();
    let interval = interval.to_dec();
    let unrepresentable = || ChainError::Validation {
        field: "strike_interval".to_string(),
        reason: format!("an interval of {interval} cannot anchor a ladder at {underlying}"),
    };

    let remainder = price.checked_rem(interval).ok_or_else(unrepresentable)?;
    let base = price.checked_sub(remainder).ok_or_else(unrepresentable)?;

    // Upstream compares the remainder against HALF the interval; mirrored in
    // that form rather than as `remainder + remainder >= interval`, which is
    // algebraically the same but parts company where the half is not
    // representable at 28 decimal places.
    let half = interval
        .checked_div(Decimal::TWO)
        .ok_or_else(unrepresentable)?;
    let rounds_up = remainder >= half;
    let rounded = if rounds_up {
        base.checked_add(interval).ok_or_else(unrepresentable)?
    } else {
        base
    };

    Positive::new_decimal(rounded).map_err(|_| unrepresentable())
}

/// The absolute ceiling on the widening a pinned step may ask for.
///
/// This is the widest chain any client is allowed to REQUEST
/// (`OCS_MAX_CHAIN_SIZE`'s default), reused as the ceiling on the widening the
/// service performs on a client's behalf. The two are different quantities and
/// only the coincidence of the number is shared, so it lives here rather than
/// being read from the request caps: lowering the request cap must not
/// retroactively break simulations already running, and raising it must not
/// license the domain to price a wider chain than this.
///
/// It is a ceiling, never the bound on its own; [`resolve_pinned_ceiling`] takes it
/// together with the simulation's snapshot budget and keeps whichever is
/// smaller.
pub(crate) const MAX_PINNED_WIDTH: usize = 500;

/// The widest chain a pinned step may ask upstream for, resolved ONCE.
///
/// A pinned step builds wide enough to REACH its ladder and then filters, so
/// the widening is work the client never asked for and the request caps never
/// saw. Two separate things have to bound it, and the answer is the smaller:
///
/// The simulation's own snapshot budget, computed from the same numbers
/// `SimulationParametersV2::validate_snapshot_work` checked at creation: the
/// per-snapshot contract cap divided by the expirations the schedule can carry,
/// back through the `2n + 1` grid to a half-width. Without this, a
/// configuration validated for a handful of contracts per snapshot could price
/// far more of them once its spot drifted.
///
/// And [`MAX_PINNED_WIDTH`], because that budget is a SERVICE-WIDE cap rather
/// than a per-simulation one. At its default a single-expiration schedule
/// divides 200 000 contracts by one, which would license a widening of 99 999
/// strikes per side; a bound that large is not a bound.
///
/// This is called at CREATION, and the result is stored on the parameters. It
/// must not be called per step: `max_snapshot_contracts()` is a process-global
/// read, so a step-time call would make how far a pinned tape gets depend on
/// the instance serving it, and the same seed could then run to completion on
/// one deployment and refuse at step k on another (issue #109).
///
/// # Errors
///
/// Returns [`ChainError::Validation`] when the schedule's expiration count
/// overflows, which the creation path also refuses.
pub(crate) fn resolve_pinned_ceiling(schedule: &ExpirationSchedule) -> Result<usize, ChainError> {
    let expirations = schedule
        .rules()
        .iter()
        .try_fold(0usize, |total, rule| {
            total.checked_add(rule.target_count().get())
        })
        .ok_or_else(|| ChainError::Validation {
            field: "schedules".to_string(),
            reason: "the requested expiration counts overflow".to_string(),
        })?;

    Ok(pinned_width_for(
        crate::infrastructure::max_snapshot_contracts(),
        expirations,
    ))
}

/// The widening [`resolve_pinned_ceiling`] allows for a given cap and expiration
/// count, split out from the environment so both sides of the minimum can be
/// exercised: the process-wide cap is resolved once in a `OnceLock`, so a test
/// that could only call the public entry point would be stuck with whatever
/// the deployment happens to configure.
fn pinned_width_for(cap: usize, expirations: usize) -> usize {
    // A schedule with no rules cannot produce a chain, so the divisor is
    // irrelevant; one keeps the division defined.
    let divisor = if expirations == 0 { 1 } else { expirations };
    let strikes = cap.checked_div(divisor).unwrap_or(cap);

    // `strikes` covers the whole `2n + 1` grid, so the half-width is what
    // upstream calls `chain_size`. The subtraction is checked rather than
    // saturating, which `rules/global_rules.md` forbids: a budget of zero or
    // one strike is a real answer of zero, stated deliberately, and
    // `width_from` then refuses a ladder that cannot fit rather than the
    // arithmetic quietly rounding up past the budget it came from.
    //
    // Written as a match rather than `checked_sub(1).unwrap_or(0)` because
    // clippy's `manual_saturating_arithmetic` rewrites that form back into
    // `saturating_sub` under `make pre-push`, which is the very call the rule
    // forbids. The match says the same thing and survives the autofix.
    let budget = match strikes.checked_sub(1) {
        Some(grid) => grid.checked_div(2).unwrap_or(0),
        None => 0,
    };

    budget.min(MAX_PINNED_WIDTH)
}

/// Refuses a pinned ladder whose lowest strike upstream cannot build.
///
/// Upstream stops extending a chain downwards once the offset passes the
/// at-the-money strike, so a ladder wider than the spot itself is one whose far
/// strikes are never created: at an initial price of 100 with a 5-point
/// interval, a `chain_size` of 25 asks for strikes down to 5 and back up to
/// 225, and upstream builds only as far as 200. Filtering cannot recover what
/// was never built, so the combination is refused AT CREATION rather than
/// failing on the first step of a simulation the client already has.
///
/// # Errors
///
/// Returns [`ChainError::Validation`] naming `chain_size` when the ladder
/// reaches past the anchor, and whatever [`at_the_money`] returns when the grid
/// itself is unrepresentable.
pub(crate) fn ensure_ladder_fits(
    initial_price: Positive,
    interval: Positive,
    chain_size: usize,
) -> Result<(), ChainError> {
    let anchor = at_the_money(initial_price, interval)?;
    let reach = interval
        .checked_mul_dec(Decimal::from(chain_size))
        .map_err(|error| ladder_overflow(interval, chain_size, &error.to_string()))?;

    if reach > anchor {
        return Err(ChainError::Validation {
            field: "chain_size".to_string(),
            reason: format!(
                "a pinned ladder of {chain_size} strikes at an interval of {interval} reaches \
                 {reach} below an anchor of {anchor}, and upstream stops building a chain once \
                 the offset passes the anchor, so the lowest strikes would never exist; lower \
                 chain_size or the interval"
            ),
        });
    }
    Ok(())
}

/// The failure a ladder that cannot be laid out on the grid produces.
fn ladder_overflow(interval: Positive, step: usize, detail: &str) -> ChainError {
    ChainError::Validation {
        field: "strike_interval".to_string(),
        reason: format!(
            "an interval of {interval} cannot reach strike {step} of the pinned ladder: {detail}"
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use positive::pos_or_panic;

    /// The anchor rounds to the nearest multiple, halves up.
    #[test]
    fn test_the_anchor_rounds_to_the_grid() {
        let interval = pos_or_panic!(25.0);

        for (underlying, expected) in [
            (5000.0, 5000.0),
            (5004.95, 5000.0),
            (5013.54, 5025.0),
            (5012.5, 5025.0),
            (5012.49, 5000.0),
        ] {
            match at_the_money(pos_or_panic!(underlying), interval) {
                Ok(anchor) => assert_eq!(anchor, pos_or_panic!(expected), "for {underlying}"),
                Err(error) => panic!("{underlying} must anchor: {error}"),
            }
        }
    }

    /// A zero interval leaves the price alone rather than dividing by it.
    #[test]
    fn test_a_zero_interval_is_not_a_grid() {
        match at_the_money(pos_or_panic!(5000.0), Positive::ZERO) {
            Ok(anchor) => assert_eq!(anchor, pos_or_panic!(5000.0)),
            Err(error) => panic!("a zero interval must not fail: {error}"),
        }
    }

    /// The ladder is the grid around the initial price, `2n + 1` wide.
    #[test]
    fn test_the_ladder_is_the_grid_around_the_initial_price() {
        let ladder = ladder_of(5000.0, 25.0, 2);

        assert_eq!(
            ladder.strikes().iter().copied().collect::<Vec<_>>(),
            vec![
                pos_or_panic!(4950.0),
                pos_or_panic!(4975.0),
                pos_or_panic!(5000.0),
                pos_or_panic!(5025.0),
                pos_or_panic!(5050.0),
            ]
        );
    }

    /// A ladder never carries a strike at or below zero.
    #[test]
    fn test_the_ladder_never_reaches_a_zero_strike() {
        // Two below an anchor of 50 reaches exactly zero, which `checked_sub`
        // refuses, so the ladder stops at 25.
        let ladder = ladder_of(50.0, 25.0, 2);

        assert!(
            ladder
                .strikes()
                .iter()
                .all(|strike| *strike > Positive::ZERO),
            "a strike of zero is not a contract: {:?}",
            ladder.strikes()
        );
        assert!(ladder.strikes().contains(&pos_or_panic!(25.0)));
    }

    /// A ladder that reaches past its anchor is refused at creation.
    ///
    /// Upstream stops building a chain once the offset passes the anchor, so
    /// those strikes would never exist to be filtered to. The measured case:
    /// an initial price of 100 with a 5-point interval and a chain size of 25
    /// asks for strikes down to 5, and upstream builds only to 200 on the way
    /// up.
    #[test]
    fn test_a_ladder_reaching_past_the_anchor_is_refused() {
        match ensure_ladder_fits(pos_or_panic!(100.0), pos_or_panic!(5.0), 25) {
            Ok(()) => panic!("a ladder wider than its anchor must be refused"),
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "chain_size");
                assert!(reason.contains("never exist"), "{reason}");
            }
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }

        // One that fits is accepted, including the exact boundary.
        match ensure_ladder_fits(pos_or_panic!(100.0), pos_or_panic!(5.0), 20) {
            Ok(()) => {}
            Err(error) => panic!("a ladder that ends at the anchor must fit: {error}"),
        }
    }

    /// The overflow guards hold on a ladder no grid can lay out.
    ///
    /// `strike_interval` is client input with no upper bound, and both
    /// `Positive` and `Decimal` panic on overflow.
    #[test]
    fn test_an_unrepresentable_grid_is_refused_rather_than_panicking() {
        let huge = match Positive::new_decimal(Decimal::MAX / Decimal::TWO) {
            Ok(value) => value,
            Err(error) => panic!("the fixture must be positive: {error}"),
        };

        match ensure_ladder_fits(huge, huge, 500) {
            Ok(()) => panic!("a grid this wide cannot be laid out"),
            Err(ChainError::Validation { .. }) => {}
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }
    }

    /// The width a chain needs grows with the distance the spot has moved.
    #[test]
    fn test_the_width_grows_with_the_distance_from_the_ladder() {
        let ladder = ladder_of(5000.0, 25.0, 2);

        // At the centre, the ladder's own half-width is enough.
        match ladder.width_from(pos_or_panic!(5000.0), 500) {
            Ok(width) => assert_eq!(width, 2),
            Err(error) => panic!("the centre must resolve: {error}"),
        }
        // Four intervals up, the furthest strike is six intervals away.
        match ladder.width_from(pos_or_panic!(5100.0), 500) {
            Ok(width) => assert_eq!(width, 6),
            Err(error) => panic!("a drifted spot must resolve: {error}"),
        }
    }

    /// A spot that has left the pinned range entirely fails loudly.
    #[test]
    fn test_a_spot_beyond_the_maximum_is_refused() {
        let ladder = ladder_of(5000.0, 25.0, 2);

        match ladder.width_from(pos_or_panic!(9000.0), 10) {
            Ok(width) => panic!("a spot 160 intervals away must not resolve, got {width}"),
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "strike_ladder");
                assert!(
                    reason.contains("pinned at creation"),
                    "the failure must explain: {reason}"
                );
                // Widening is the one remedy that cannot work, since a wider
                // ladder puts its far strikes further from this spot still.
                assert!(
                    reason.contains("widening chain_size cannot help"),
                    "the failure must rule out the wrong remedy: {reason}"
                );
                assert!(
                    reason.contains("smaller chain_size")
                        && reason.contains("initial_price")
                        && reason.contains("rolling"),
                    "the failure must name a remedy that works: {reason}"
                );
            }
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }
    }

    /// A spot below half the interval anchors at zero, which upstream cannot
    /// build a chain around. It has to fail as a validation error here, before
    /// pricing, rather than as an internal one halfway through an export.
    #[test]
    fn test_a_spot_below_half_the_interval_is_refused() {
        let ladder = ladder_of(5000.0, 25.0, 2);

        match ladder.width_from(pos_or_panic!(0.01), usize::MAX) {
            Ok(width) => panic!("a spot under the grid must not resolve, got {width}"),
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "strike_ladder");
                assert!(
                    reason.contains("strike of zero"),
                    "the failure must say why: {reason}"
                );
            }
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }

        // Half an interval is the first spot that anchors on the grid.
        match ladder.width_from(pos_or_panic!(12.5), usize::MAX) {
            Ok(width) => assert_eq!(width, 201),
            Err(error) => panic!("the first quotable spot must resolve: {error}"),
        }
    }

    /// The widening ceiling is the smaller of the simulation's snapshot budget
    /// and the absolute ceiling, and both sides of that minimum bind.
    #[test]
    fn test_the_widening_ceiling_is_the_smaller_of_both_bounds() {
        // The service-wide cap is not a bound on its own. At its default a
        // single-expiration schedule divides 200 000 contracts by one, which
        // would license 99 999 strikes per side; the absolute ceiling is what
        // holds. Remove the `min` and this assertion fails.
        assert_eq!(pinned_width_for(200_000, 1), MAX_PINNED_WIDTH);

        // Split across enough expirations, the budget is the tighter of the
        // two and the ceiling stops binding. Remove the budget term and this
        // one fails.
        assert_eq!(pinned_width_for(200_000, 400), 249);
        assert_eq!(pinned_width_for(101, 1), 50);

        // A budget too small to carry even a three-strike chain answers zero
        // rather than rounding itself up past the budget it came from.
        assert_eq!(pinned_width_for(2, 1), 0);
        assert_eq!(pinned_width_for(1, 1), 0);
        assert_eq!(pinned_width_for(0, 1), 0);

        // And a schedule with no rules still divides.
        assert_eq!(pinned_width_for(200_000, 0), MAX_PINNED_WIDTH);
    }

    /// The creation-time resolver agrees with the pure core it delegates to,
    /// and the parameters carry what it resolved.
    #[test]
    fn test_the_ceiling_is_resolved_once_and_carried() {
        let parameters = parameters(5000.0, 25.0, 2);
        let cap = crate::infrastructure::max_snapshot_contracts();

        // One expiration in this schedule, so the whole per-snapshot budget is
        // one chain and the `2n + 1` grid must fit inside it.
        let resolved = match resolve_pinned_ceiling(&parameters.schedule) {
            Ok(width) => width,
            Err(error) => panic!("the budget must resolve: {error}"),
        };
        assert_eq!(resolved, pinned_width_for(cap, 1));
        assert!(
            resolved * 2 < cap,
            "{resolved} strikes per side exceeds {cap}"
        );
        assert!(
            resolved <= MAX_PINNED_WIDTH,
            "{resolved} strikes per side exceeds the {MAX_PINNED_WIDTH} ceiling"
        );

        // And the request conversion stored exactly that.
        assert_eq!(parameters.pinned_width_ceiling, resolved);
    }

    /// The stored ceiling is what a step obeys, whatever this instance would
    /// resolve now. A session created under a tighter cap keeps refusing the
    /// widening that cap forbade, on any instance that later serves it.
    #[test]
    fn test_a_step_obeys_the_stored_ceiling_not_the_environment() {
        let mut parameters = parameters(5000.0, 25.0, 2);
        parameters.strike_ladder = StrikeLadder::Pinned;
        parameters.pinned_width_ceiling = 3;

        let ladder = match PinnedLadder::resolve(&parameters) {
            Ok(ladder) => ladder,
            Err(error) => panic!("the ladder must resolve: {error}"),
        };

        // Two intervals of drift needs six strikes per side, past the three the
        // simulation was created under, even though this instance's own budget
        // would allow far more.
        match ladder.width_from(pos_or_panic!(5100.0), parameters.pinned_width_ceiling) {
            Ok(width) => panic!("the stored ceiling must bind, got {width}"),
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "strike_ladder");
                assert!(reason.contains("past the 3"), "{reason}");
            }
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }
    }

    /// A pinned ladder needs an explicit interval, and says so.
    #[test]
    fn test_a_ladder_without_an_interval_is_refused() {
        let mut parameters = parameters(5000.0, 25.0, 2);
        parameters.strike_interval = None;

        match PinnedLadder::resolve(&parameters) {
            Ok(ladder) => panic!("a ladder with no grid must not resolve, got {ladder:?}"),
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "strike_interval");
                assert!(reason.contains("varies the chain size"), "{reason}");
            }
            Err(error) => panic!("expected a validation failure, got {error:?}"),
        }
    }

    /// The default is the behaviour the service already had.
    #[test]
    fn test_rolling_is_the_default() {
        assert_eq!(StrikeLadder::default(), StrikeLadder::Rolling);
        assert!(!StrikeLadder::default().is_pinned());
        assert!(StrikeLadder::Pinned.is_pinned());
    }

    /// The wire words are the ones the issue named.
    #[test]
    fn test_the_wire_form_is_snake_case() {
        for (ladder, wire) in [
            (StrikeLadder::Rolling, "\"rolling\""),
            (StrikeLadder::Pinned, "\"pinned\""),
        ] {
            match serde_json::to_string(&ladder) {
                Ok(json) => assert_eq!(json, wire),
                Err(error) => panic!("must serialize: {error}"),
            }
            match serde_json::from_str::<StrikeLadder>(wire) {
                Ok(parsed) => assert_eq!(parsed, ladder),
                Err(error) => panic!("must deserialize: {error}"),
            }
        }
    }

    /// Effective parameters from a request, so the tests go through the same
    /// conversion a client does rather than hand-building a state the request
    /// path would refuse.
    fn parameters(initial_price: f64, interval: f64, chain_size: usize) -> SimulationParametersV2 {
        use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
        use crate::api::rest::requests_v2::CreateSimulationRequest;
        use crate::session::{ExpiryRule, ExpiryRuleKind};
        use chrono::{TimeZone, Utc};

        let start_at = match Utc.with_ymd_and_hms(2026, 1, 5, 14, 30, 0).single() {
            Some(instant) => instant,
            None => panic!("the test instant must be valid"),
        };
        let rule = match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, 1) {
            Ok(rule) => rule,
            Err(error) => panic!("the test rule must be valid: {error}"),
        };

        let request = CreateSimulationRequest {
            symbol: "SPX".to_string(),
            steps: 4,
            start_at: Some(start_at),
            step_interval_seconds: Some(86_400),
            timezone: "America/New_York".to_string(),
            calendar: None,
            expiration_time: "17:00".to_string(),
            schedules: vec![rule],
            initial_price,
            volatility: 0.2,
            risk_free_rate: 0.04,
            dividend_yield: 0.0,
            method: ApiWalkType::Brownian {
                dt: 1.0 / 252.0,
                drift: 0.0,
                volatility: 0.2,
            },
            time_frame: ApiTimeFrame::Day,
            chain_size: Some(chain_size),
            strike_interval: Some(interval),
            skew_slope: None,
            smile_curve: None,
            spread: Some(0.02),
            strike_ladder: Some(StrikeLadder::Pinned),
            spread_proportional: None,
            spread_moneyness_widening: None,
            spread_tenor_widening: None,
            spread_tick: None,
            seed: Some(42),
        };

        match SimulationParametersV2::try_from(request) {
            Ok(parameters) => parameters,
            Err(error) => panic!("the request must convert: {error}"),
        }
    }

    fn ladder_of(initial_price: f64, interval: f64, chain_size: usize) -> PinnedLadder {
        match PinnedLadder::resolve(&parameters(initial_price, interval, chain_size)) {
            Ok(ladder) => ladder,
            Err(error) => panic!("the ladder must resolve: {error}"),
        }
    }

    /// The mirrored anchor is the strike upstream actually chooses.
    ///
    /// `rounder` is `pub(crate)` upstream, so this mirrors it; the mirror is
    /// only worth having if it agrees, and this builds a real chain to check.
    #[test]
    fn test_the_mirrored_anchor_matches_upstream() {
        use optionstratlib::ExpirationDate;

        let interval = pos_or_panic!(25.0);
        for spot in [5000.0, 5004.95, 5013.54, 4987.5] {
            let mut parameters = parameters(5000.0, 25.0, 1);
            parameters.strike_ladder = StrikeLadder::Rolling;

            let chain = match crate::domain::factors::build_chain(
                &parameters,
                pos_or_panic!(spot),
                pos_or_panic!(0.2),
                ExpirationDate::Days(pos_or_panic!(30.0)),
                false,
            ) {
                Ok(chain) => chain,
                Err(error) => panic!("the chain must build at {spot}: {error}"),
            };

            // A chain of half-width one is [atm - interval, atm, atm + interval],
            // so the middle strike is upstream's anchor.
            let strikes: Vec<Positive> =
                chain.iter().map(|contract| contract.strike_price).collect();
            let middle = strikes[strikes.len() / 2];

            match at_the_money(pos_or_panic!(spot), interval) {
                Ok(anchor) => assert_eq!(
                    anchor, middle,
                    "the mirror disagrees with upstream at a spot of {spot}: {strikes:?}"
                ),
                Err(error) => panic!("the anchor must resolve at {spot}: {error}"),
            }
        }
    }
}