optionchain_simulator 0.2.0

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
//! Response DTOs for the v2 rolling-simulation API.
//!
//! Separate from [`crate::api::rest::responses`] because `/api/v1/chain` is
//! frozen (ADR 0001 §12.1): v1 serves one chain and stamps it with the wall
//! clock, v2 serves many chains stamped with the simulated one, and neither
//! shape can absorb the other without breaking a published contract.
//!
//! As in v1 the wire speaks `f64`, and the conversion from `Positive` /
//! `Decimal` happens here and nowhere else.
//!
//! # What is deliberately not on the wire
//!
//! Upstream's `OptionChain` carries a `YYYY-MM-DD` string it stamps from the
//! **host** clock, not the simulated one, and exposes no hook to change that
//! (see [`crate::domain::series`]). The expiration a client sees is
//! [`ExpiryChainResponse::expires_at`] — the planner's absolute instant, which
//! is deterministic. Surfacing the stamp would put a value in the contract that
//! changes between two otherwise-identical replays.

use crate::domain::series::SeriesSnapshot;
use crate::session::{ExpiryRule, ExpiryRuleKind, SessionV2};
use chrono::{DateTime, SecondsFormat, Utc};
use optionstratlib::chains::OptionData;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

/// Renders an instant the way every v2 timestamp is rendered.
///
/// Whole seconds and a `Z` suffix. The effective start is normalised to a whole
/// second at creation and the step interval is an integer number of seconds, so
/// no v2 instant has a sub-second part to lose — and pinning the format is what
/// keeps a repeated export byte-comparable (ADR 0001 §3.1).
#[must_use]
#[inline]
fn render_instant(instant: DateTime<Utc>) -> String {
    instant.to_rfc3339_opts(SecondsFormat::Secs, true)
}

/// Converts an optional decimal to the wire's `f64`.
#[must_use]
#[inline]
fn decimal_to_f64(value: Option<Decimal>) -> Option<f64> {
    value.and_then(|value| value.to_f64())
}

/// Where a simulation's cursor is.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CursorResponse {
    /// The 0-based index of the next snapshot to serve.
    pub current_step: usize,
    /// The total number of snapshots the simulation serves.
    pub total_steps: usize,
}

/// One expiration rule, echoed in the normalised form that is a replay input.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ScheduleRuleResponse {
    /// The rule's stable identifier, which is also its label on every chain.
    pub rule_id: String,
    /// `daily`, `weekly`, `monthly` or `yearly`.
    pub kind: String,
    /// How many non-expired expirations the rule keeps available.
    pub target_count: usize,
    /// The weekdays a `weekly` rule expires on, deduplicated and Monday-first.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub weekdays: Option<Vec<String>>,
    /// The weekday a `monthly` or `yearly` rule expires on.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub weekday: Option<String>,
    /// The month a `yearly` rule expires in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub month: Option<u32>,
}

/// The effective parameters of a simulation.
///
/// This is exactly the replay-input list of ADR 0001 §8: a client that records
/// this object can recreate the run without having kept the request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct SimulationParametersResponse {
    /// Ticker symbol of the underlying.
    pub symbol: String,
    /// Number of steps the simulation runs for.
    pub steps: usize,
    /// The resolved RNG seed. Never absent: a v2 simulation is always
    /// reproducible.
    pub seed: u64,
    /// The resolved simulated start, in whole-second UTC.
    pub effective_start: String,
    /// The resolved interval between simulated steps, in seconds.
    pub step_interval_seconds: u64,
    /// The time frame the stochastic model is scaled by.
    pub time_frame: String,
    /// The IANA zone the expiration time is expressed in.
    pub timezone: String,
    /// The calendar policy version the schedule is evaluated under.
    pub calendar: String,
    /// The IANA time-zone database release the expirations were resolved
    /// against. A replay against a different release is still a replay — it is
    /// just one the client can now detect.
    pub tzdb_version: String,
    /// The local time of day every expiration expires at.
    pub expiration_time: String,
    /// The normalised expiration rules, ordered by `rule_id`.
    pub schedules: Vec<ScheduleRuleResponse>,
    /// Initial price of the underlying.
    pub initial_price: f64,
    /// The volatility the simulation was created with, echoed back for replay.
    ///
    /// It is the one base volatility for every walk model that carries one. For
    /// `Historical` it prices nothing — that walk estimates a volatility per
    /// step from its own series (ADR 0001 §8.1) — and the values that did price
    /// the chains are each snapshot's `base_volatility`.
    pub volatility: f64,
    /// Annualised risk-free rate.
    pub risk_free_rate: f64,
    /// Annualised dividend yield.
    pub dividend_yield: f64,
    /// The stochastic model driving the underlying path.
    #[schema(value_type = Object)]
    pub method: serde_json::Value,
    /// Number of strikes per chain.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chain_size: Option<usize>,
    /// Interval between strikes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub strike_interval: Option<f64>,
    /// Slope of the volatility skew.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skew_slope: Option<f64>,
    /// Curvature of the volatility smile.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub smile_curve: Option<f64>,
    /// Bid-ask spread factor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spread: Option<f64>,
}

/// A simulation's metadata, with no market data attached.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct SimulationResponse {
    /// The simulation's unique identifier.
    pub id: String,
    /// `initialized`, `in_progress` or `completed`.
    ///
    /// Deliberately `snake_case`, unlike v1's `Display` rendering — which stays
    /// `"In Progress"`, with a space, because it is frozen.
    pub state: String,
    /// The optimistic-concurrency revision.
    pub version: u64,
    /// Where the cursor is.
    pub cursor: CursorResponse,
    /// When the simulation was created, in real time.
    pub created_at: String,
    /// When the simulation was last written, in real time.
    pub updated_at: String,
    /// The effective parameters — the replay inputs.
    pub parameters: SimulationParametersResponse,
}

/// The state of the underlying at one step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct UnderlyingResponse {
    /// Ticker symbol.
    pub symbol: String,
    /// The simulated price at this step.
    pub price: f64,
    /// The base implied volatility every chain at this step is priced from,
    /// before skew and smile shape it per strike.
    pub base_volatility: f64,
}

/// A quoted side of one strike.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Default)]
pub struct OptionQuoteResponse {
    /// Bid price.
    pub bid: Option<f64>,
    /// Ask price.
    pub ask: Option<f64>,
    /// Mid price.
    pub mid: Option<f64>,
    /// Delta.
    pub delta: Option<f64>,
}

/// One strike of one expiration.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct ContractResponse {
    /// The strike price.
    pub strike: f64,
    /// The per-strike implied volatility, shaped by skew and smile.
    pub implied_volatility: f64,
    /// Gamma, shared by the call and the put.
    pub gamma: Option<f64>,
    /// The call side.
    pub call: OptionQuoteResponse,
    /// The put side.
    pub put: OptionQuoteResponse,
}

/// One live expiration at one step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct ExpiryChainResponse {
    /// The absolute expiration instant, in UTC. This is the authoritative
    /// expiration: it comes from the planner and is fully deterministic.
    pub expires_at: String,
    /// Fractional days remaining, from the same pair the planner used. Always
    /// strictly positive — an expired chain is never served.
    pub days_to_expiration: f64,
    /// Every rule this expiration satisfies, sorted. A date claimed by two
    /// rules appears once, with both labels.
    pub labels: Vec<String>,
    /// The strikes, ascending.
    pub contracts: Vec<ContractResponse>,
}

/// The whole simulated market at one step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct SnapshotResponse {
    /// The simulation's unique identifier.
    pub id: String,
    /// The lifecycle state at the time of the response.
    pub state: String,
    /// The optimistic-concurrency revision.
    pub version: u64,
    /// Where the cursor is.
    pub cursor: CursorResponse,
    /// The simulated instant of this snapshot — derived from the effective
    /// start and the cursor, never from the wall clock.
    pub simulated_at: String,
    /// The underlying's state at this step.
    pub underlying: UnderlyingResponse,
    /// The live chains, ordered by expiration.
    pub chains: Vec<ExpiryChainResponse>,
}

impl From<&OptionData> for ContractResponse {
    fn from(data: &OptionData) -> Self {
        Self {
            strike: data.strike_price.to_f64(),
            implied_volatility: data.implied_volatility.to_f64(),
            gamma: decimal_to_f64(data.gamma),
            call: OptionQuoteResponse {
                bid: data.call_bid.map(|value| value.to_f64()),
                ask: data.call_ask.map(|value| value.to_f64()),
                mid: data.call_middle.map(|value| value.to_f64()),
                delta: decimal_to_f64(data.delta_call),
            },
            put: OptionQuoteResponse {
                bid: data.put_bid.map(|value| value.to_f64()),
                ask: data.put_ask.map(|value| value.to_f64()),
                mid: data.put_middle.map(|value| value.to_f64()),
                delta: decimal_to_f64(data.delta_put),
            },
        }
    }
}

impl From<&ExpiryRule> for ScheduleRuleResponse {
    fn from(rule: &ExpiryRule) -> Self {
        let (kind, weekdays, weekday, month) = match rule.kind() {
            ExpiryRuleKind::Daily => ("daily", None, None, None),
            ExpiryRuleKind::Weekly { weekdays } => (
                "weekly",
                Some(weekdays.iter().map(ToString::to_string).collect()),
                None,
                None,
            ),
            ExpiryRuleKind::Monthly { weekday } => {
                ("monthly", None, Some(weekday.to_string()), None)
            }
            ExpiryRuleKind::Yearly { weekday, month } => {
                ("yearly", None, Some(weekday.to_string()), Some(*month))
            }
        };

        Self {
            rule_id: rule.rule_id().to_string(),
            kind: kind.to_string(),
            target_count: rule.target_count().get(),
            weekdays,
            weekday,
            month,
        }
    }
}

impl From<&SessionV2> for SimulationParametersResponse {
    fn from(simulation: &SessionV2) -> Self {
        let parameters = &simulation.parameters;
        let schedule = &parameters.schedule;

        Self {
            symbol: parameters.symbol.clone(),
            steps: parameters.steps,
            seed: parameters.seed,
            effective_start: render_instant(parameters.effective_start),
            step_interval_seconds: parameters.step_interval_seconds,
            time_frame: parameters.time_frame.to_string(),
            timezone: schedule.timezone().name().to_string(),
            calendar: schedule.calendar().as_str().to_string(),
            tzdb_version: parameters.tzdb_version.clone(),
            expiration_time: schedule.expiration_time().format("%H:%M:%S").to_string(),
            schedules: schedule.rules().iter().map(Into::into).collect(),
            initial_price: parameters.initial_price.to_f64(),
            volatility: parameters.volatility.to_f64(),
            risk_free_rate: parameters.risk_free_rate.to_f64().unwrap_or_default(),
            dividend_yield: parameters.dividend_yield.to_f64(),
            // The walk model is upstream's type; it is echoed as the JSON it
            // serialises to rather than mirrored into a second enum, because
            // there is nothing v2-specific to say about it.
            method: serde_json::to_value(&parameters.method).unwrap_or(serde_json::Value::Null),
            chain_size: parameters.chain_size,
            strike_interval: parameters.strike_interval.map(|value| value.to_f64()),
            skew_slope: parameters.skew_slope.and_then(|value| value.to_f64()),
            smile_curve: parameters.smile_curve.and_then(|value| value.to_f64()),
            spread: parameters.spread.map(|value| value.to_f64()),
        }
    }
}

impl From<&SessionV2> for SimulationResponse {
    fn from(simulation: &SessionV2) -> Self {
        Self {
            id: simulation.id.to_string(),
            state: render_state(simulation),
            version: simulation.version,
            cursor: CursorResponse {
                current_step: simulation.current_step,
                total_steps: simulation.total_steps,
            },
            created_at: render_system_time(simulation.created_at),
            updated_at: render_system_time(simulation.updated_at),
            parameters: simulation.into(),
        }
    }
}

/// Renders a simulation's lifecycle state in v2's `snake_case`.
///
/// v1 renders the `Display` form and must keep doing so; v2 does not inherit
/// that spelling (ADR 0001 §7).
#[must_use]
fn render_state(simulation: &SessionV2) -> String {
    use crate::session::SessionState;

    match simulation.state {
        SessionState::Initialized => "initialized",
        SessionState::InProgress => "in_progress",
        SessionState::Completed => "completed",
        SessionState::Error => "error",
        // Unreachable for a v2 simulation, which is immutable after creation
        // and whose stored form rejects both states on load. Rendered rather
        // than panicked on, because a response is not the place to discover it.
        SessionState::Modified => "modified",
        SessionState::Reinitialized => "reinitialized",
    }
    .to_string()
}

/// Renders a real-time timestamp the same way as every other v2 instant.
#[must_use]
fn render_system_time(time: std::time::SystemTime) -> String {
    render_instant(DateTime::<Utc>::from(time))
}

/// Builds a snapshot response from a simulation and the snapshot it served.
///
/// A free function rather than a `From` impl because it needs both, and the
/// pairing is the point: the cursor and state come from the simulation as it
/// was when the snapshot was taken.
#[must_use]
pub(crate) fn snapshot_response(
    simulation: &SessionV2,
    snapshot: &SeriesSnapshot,
) -> SnapshotResponse {
    SnapshotResponse {
        id: simulation.id.to_string(),
        state: render_state(simulation),
        version: simulation.version,
        cursor: CursorResponse {
            current_step: simulation.current_step,
            total_steps: simulation.total_steps,
        },
        simulated_at: render_instant(snapshot.simulated_at),
        underlying: UnderlyingResponse {
            symbol: simulation.parameters.symbol.clone(),
            price: snapshot.spot.to_f64(),
            base_volatility: snapshot.base_volatility.to_f64(),
        },
        chains: snapshot
            .chains
            .iter()
            .map(|chain| ExpiryChainResponse {
                expires_at: render_instant(chain.expires_at),
                days_to_expiration: chain.days_to_expiration.to_f64(),
                labels: chain.labels.clone(),
                contracts: chain.chain.iter().map(Into::into).collect(),
            })
            .collect(),
    }
}