Skip to main content

egui_charts/data/
option_chain.rs

1//! Host-supplied option contracts, quotes, risk observations and exposure snapshots.
2
3use super::options_volume::Snapshot;
4use super::{Datafeed, FeedError};
5use chrono::{DateTime, NaiveDate, Utc};
6use std::ops::RangeInclusive;
7use std::sync::{Arc, Weak};
8
9/// Contracts requested for one underlying, live or at a historical instant.
10#[derive(Clone, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct OptionChainQuery {
13    /// Underlying symbol as the feed knows it.
14    pub symbol: String,
15    /// Inclusive expiry dates. Use the same date at both ends for one expiry.
16    pub expiries: RangeInclusive<NaiveDate>,
17    /// One historical snapshot at this instant, or `None` for continuing live snapshots.
18    pub as_of: Option<DateTime<Utc>>,
19}
20
21impl OptionChainQuery {
22    /// Request live snapshots for `symbol` over the inclusive expiry range.
23    pub fn new(symbol: impl Into<String>, expiries: RangeInclusive<NaiveDate>) -> Self {
24        Self {
25            symbol: symbol.into(),
26            expiries,
27            as_of: None,
28        }
29    }
30}
31
32/// The right conveyed by an option contract.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum OptionKind {
36    /// The right to buy the underlying at the strike.
37    Call,
38    /// The right to sell the underlying at the strike.
39    Put,
40}
41
42/// One listed contract's observation. Unknown values stay `None`, including Greeks;
43/// consumers must not substitute zero. Prices use the underlying's price units.
44/// Greeks describe one unit of the underlying, without the contract multiplier applied.
45#[derive(Clone, Debug, PartialEq)]
46#[non_exhaustive]
47pub struct OptionQuote {
48    /// Contract symbol as the feed knows it, distinct from the underlying symbol.
49    pub symbol: String,
50    /// Contract expiry date.
51    pub expiry: NaiveDate,
52    /// Positive, finite strike in underlying price units.
53    pub strike: f64,
54    /// Call or put.
55    pub kind: OptionKind,
56    /// Positive, finite underlying units per contract, or unknown.
57    pub multiplier: Option<f64>,
58    /// UTC observation time of these values.
59    pub time: DateTime<Utc>,
60    /// Nonnegative, finite bid price, or unknown.
61    pub bid: Option<f64>,
62    /// Nonnegative, finite ask price, or unknown.
63    pub ask: Option<f64>,
64    /// Nonnegative, finite last traded price, or unknown.
65    pub last: Option<f64>,
66    /// Nonnegative, finite session volume in contracts, or unknown.
67    pub volume: Option<f64>,
68    /// Nonnegative, finite open interest in contracts, or unknown.
69    pub open_interest: Option<f64>,
70    /// Nonnegative, finite implied volatility as a fraction: 0.20 means 20%, or unknown.
71    pub implied_volatility: Option<f64>,
72    /// Finite delta for one unit of the underlying, or unknown.
73    pub delta: Option<f64>,
74    /// Finite gamma for one unit of the underlying per one unit of underlying price, or unknown.
75    pub gamma: Option<f64>,
76    /// Finite theta per calendar day for one unit of the underlying, or unknown.
77    pub theta: Option<f64>,
78    /// Finite vega per volatility point (one percentage point) for one unit of the underlying,
79    /// or unknown.
80    pub vega: Option<f64>,
81    /// Nonnegative, finite underlying price used to compute these values, or unknown.
82    pub underlying_price: Option<f64>,
83}
84
85impl OptionQuote {
86    /// Identify a contract and its observation time, leaving all optional figures unknown.
87    pub fn new(
88        symbol: impl Into<String>,
89        expiry: NaiveDate,
90        strike: f64,
91        kind: OptionKind,
92        time: DateTime<Utc>,
93    ) -> Self {
94        Self {
95            symbol: symbol.into(),
96            expiry,
97            strike,
98            kind,
99            multiplier: None,
100            time,
101            bid: None,
102            ask: None,
103            last: None,
104            volume: None,
105            open_interest: None,
106            implied_volatility: None,
107            delta: None,
108            gamma: None,
109            theta: None,
110            vega: None,
111            underlying_price: None,
112        }
113    }
114
115    fn validate(&self, query: &OptionChainQuery) -> Result<(), FeedError> {
116        let invalid = |field| FeedError::InvalidData(format!("contract {}: {field}", self.symbol));
117        if !query.expiries.contains(&self.expiry) {
118            return Err(invalid("expiry outside query range"));
119        }
120        for (field, value) in [
121            ("strike", Some(self.strike)),
122            ("multiplier", self.multiplier),
123        ] {
124            if value.is_some_and(|value| !value.is_finite() || value <= 0.0) {
125                return Err(invalid(field));
126            }
127        }
128        for (field, value) in [
129            ("bid", self.bid),
130            ("ask", self.ask),
131            ("last", self.last),
132            ("volume", self.volume),
133            ("open_interest", self.open_interest),
134            ("implied_volatility", self.implied_volatility),
135            ("underlying_price", self.underlying_price),
136        ] {
137            if value.is_some_and(|value| !value.is_finite() || value < 0.0) {
138                return Err(invalid(field));
139            }
140        }
141        for (field, value) in [
142            ("delta", self.delta),
143            ("gamma", self.gamma),
144            ("theta", self.theta),
145            ("vega", self.vega),
146        ] {
147            if value.is_some_and(|value| !value.is_finite()) {
148                return Err(invalid(field));
149            }
150        }
151        Ok(())
152    }
153}
154
155struct ChainState {
156    query: OptionChainQuery,
157    rows: Arc<[OptionQuote]>,
158    error: Option<FeedError>,
159    finished: bool,
160}
161
162/// A feed's destination for complete chain snapshots. Cheap to clone.
163/// Only the latest accepted rows and error are retained; updates never queue.
164#[derive(Clone)]
165pub struct OptionChainSink(Weak<Snapshot<ChainState>>);
166
167impl OptionChainSink {
168    /// Replace all rows, including clearing them with an empty snapshot, and wake the host.
169    /// Reject the whole snapshot with [`FeedError::InvalidData`] for an invalid field,
170    /// expiry outside the query or more than 100,000 rows. The error names the contract and
171    /// field, is exposed by [`OptionChain::error`], and leaves the previous rows untouched.
172    /// A successful push clears the last error. Inactive sinks ignore pushes.
173    pub fn push(&self, rows: Vec<OptionQuote>) -> Result<(), FeedError> {
174        let Some(snapshot) = self.0.upgrade() else {
175            return Ok(());
176        };
177        let result = snapshot.update(|state| {
178            if state.finished {
179                return None;
180            }
181            let result = if rows.len() > 100_000 {
182                Err(FeedError::InvalidData(format!(
183                    "contract {}: rows exceeds 100,000",
184                    rows[100_000].symbol
185                )))
186            } else {
187                rows.iter().try_for_each(|row| row.validate(&state.query))
188            };
189            Some(match result {
190                Ok(()) => {
191                    state.rows = rows.into();
192                    state.error = None;
193                    state.finished = state.query.as_of.is_some();
194                    Ok(())
195                }
196                Err(error) => {
197                    state.error = Some(error.clone());
198                    Err(error)
199                }
200            })
201        });
202        result.unwrap_or(Ok(()))
203    }
204
205    /// Report a feed failure and wake the host, retaining the last accepted rows.
206    /// A historical request ends on a reported error; a live request can recover on a later push.
207    pub fn report_error(&self, error: FeedError) {
208        if let Some(snapshot) = self.0.upgrade() {
209            snapshot.update(|state| {
210                if state.finished {
211                    return None;
212                }
213                state.error = Some(error);
214                state.finished = state.query.as_of.is_some();
215                Some(())
216            });
217        }
218    }
219
220    /// Whether a consumer remains and, for a historical request, still awaits an answer.
221    /// Stop producing when false, including after the last handle is dropped.
222    pub fn is_active(&self) -> bool {
223        self.0
224            .upgrade()
225            .is_some_and(|snapshot| snapshot.read(|state| !state.finished))
226    }
227}
228
229/// The latest option chain for one query, independent of egui.
230/// Clones share the subscription; dropping the last handle makes its sink inactive.
231#[derive(Clone)]
232pub struct OptionChain(Arc<Snapshot<ChainState>>);
233
234impl OptionChain {
235    /// Subscribe through `feed`, outside egui closures, and wake the host on each snapshot or
236    /// error. Returns `None` for an empty underlying symbol or reversed expiry range.
237    /// Unsupported feeds immediately report [`FeedError::NotSupported`] through [`Self::error`].
238    pub fn new(
239        feed: &dyn Datafeed,
240        query: OptionChainQuery,
241        wake: impl Fn() + Send + Sync + 'static,
242    ) -> Option<Self> {
243        if query.symbol.is_empty() || query.expiries.is_empty() {
244            return None;
245        }
246        let snapshot = Snapshot::new(
247            ChainState {
248                query: query.clone(),
249                rows: Arc::from([]),
250                error: None,
251                finished: false,
252            },
253            wake,
254        );
255        feed.option_chain(query, OptionChainSink(Arc::downgrade(&snapshot)));
256        Some(Self(snapshot))
257    }
258
259    /// Latest complete accepted snapshot, in the feed's order. Empty until one arrives.
260    /// Missing figures remain `None`; no calculation or aggregation is applied.
261    pub fn rows(&self) -> Arc<[OptionQuote]> {
262        self.0.read(|state| Arc::clone(&state.rows))
263    }
264
265    /// Last feed or validation error, cleared by the next accepted snapshot.
266    pub fn error(&self) -> Option<FeedError> {
267        self.0.read(|state| state.error.clone())
268    }
269}
270
271/// The sensitivity described by an exposure snapshot. Only gamma is computed locally.
272#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
273#[non_exhaustive]
274pub enum OptionExposureKind {
275    /// Exposure to a change in the underlying price through gamma.
276    Gamma,
277    /// Exposure through delta.
278    Delta,
279    /// Exposure to changes in volatility through vanna.
280    Vanna,
281    /// Exposure to the passage of time through charm.
282    Charm,
283}
284
285/// Exposure requested for one underlying and inclusive expiry range.
286#[derive(Clone, Debug, PartialEq, Eq)]
287#[non_exhaustive]
288pub struct OptionExposureQuery {
289    /// Underlying symbol as the feed knows it.
290    pub symbol: String,
291    /// Inclusive expiry dates. Use the same date at both ends for one expiry.
292    pub expiries: RangeInclusive<NaiveDate>,
293    /// Requested sensitivity.
294    pub kind: OptionExposureKind,
295    /// One historical snapshot at this instant, or `None` for continuing live snapshots.
296    pub as_of: Option<DateTime<Utc>>,
297}
298
299impl OptionExposureQuery {
300    /// Request live exposure snapshots for `symbol` over the inclusive expiry range.
301    pub fn new(
302        symbol: impl Into<String>,
303        expiries: RangeInclusive<NaiveDate>,
304        kind: OptionExposureKind,
305    ) -> Self {
306        Self {
307            symbol: symbol.into(),
308            expiries,
309            kind,
310            as_of: None,
311        }
312    }
313}
314
315/// Exposure at one expiry and strike, in the snapshot's units. Unknown sides stay absent.
316#[derive(Clone, Debug, PartialEq)]
317#[non_exhaustive]
318pub struct OptionExposureRow {
319    /// Contract expiry date; different expiries are never combined.
320    pub expiry: NaiveDate,
321    /// Positive, finite strike in underlying price units.
322    pub strike: f64,
323    /// Finite call exposure, or unknown.
324    pub calls: Option<f64>,
325    /// Finite put exposure, or unknown.
326    pub puts: Option<f64>,
327    /// Finite net exposure, or unknown. Supplied values need not equal calls plus puts.
328    pub net: Option<f64>,
329}
330
331impl OptionExposureRow {
332    /// Identify an expiry and strike, leaving all exposure figures unknown.
333    pub fn new(expiry: NaiveDate, strike: f64) -> Self {
334        Self {
335            expiry,
336            strike,
337            calls: None,
338            puts: None,
339            net: None,
340        }
341    }
342}
343
344/// A complete exposure observation, supplied by a feed or computed from an option chain.
345#[derive(Clone, Debug, PartialEq)]
346#[non_exhaustive]
347pub struct OptionExposure {
348    /// Sensitivity represented by these values.
349    pub kind: OptionExposureKind,
350    /// UTC observation time. Computed gamma uses the newest input observation.
351    pub time: DateTime<Utc>,
352    /// Display units, supplied by the feed or documented by [`Self::gamma`].
353    pub unit: String,
354    /// Optional name of the feed's valuation or position model.
355    pub model: Option<String>,
356    /// Exposure by expiry and strike; supplied rows keep their order and figures.
357    pub rows: Vec<OptionExposureRow>,
358    /// Contracts omitted because an input was unknown. Zero open interest is not missing.
359    pub missing_contracts: usize,
360}
361
362impl OptionExposure {
363    /// An observation with no model label or missing contracts.
364    pub fn new(
365        kind: OptionExposureKind,
366        time: DateTime<Utc>,
367        unit: impl Into<String>,
368        rows: Vec<OptionExposureRow>,
369    ) -> Self {
370        Self {
371            kind,
372            time,
373            unit: unit.into(),
374            model: None,
375            rows,
376            missing_contracts: 0,
377        }
378    }
379
380    /// Compute `gamma * open_interest * multiplier * underlying_price² * 0.01` per contract,
381    /// in the underlying's price currency per 1% underlying move. Calls use a positive sign
382    /// and puts a negative sign: this is a convention, not an inference about positions.
383    /// The quotes must belong to one underlying and currency, with unscaled contract Greeks.
384    ///
385    /// Missing inputs omit and count the contract; zero open interest contributes `Some(0)`.
386    /// Rows sort by expiry then strike, summing each side and taking their sum as net.
387    /// A side with no usable contracts stays `None`. Returns `Ok(None)` without observations,
388    /// or [`FeedError::InvalidData`] for invalid inputs or arithmetic overflow.
389    pub fn gamma<'a>(
390        quotes: impl IntoIterator<Item = &'a OptionQuote>,
391    ) -> Result<Option<Self>, FeedError> {
392        let mut time = None;
393        let mut missing_contracts = 0;
394        let mut contributions = Vec::new();
395        for quote in quotes {
396            time = Some(time.map_or(quote.time, |time: DateTime<Utc>| time.max(quote.time)));
397            let invalid =
398                |field| FeedError::InvalidData(format!("contract {}: {field}", quote.symbol));
399            if !quote.strike.is_finite() || quote.strike <= 0.0 {
400                return Err(invalid("strike"));
401            }
402            let (Some(gamma), Some(interest), Some(multiplier), Some(price)) = (
403                quote.gamma,
404                quote.open_interest,
405                quote.multiplier,
406                quote.underlying_price,
407            ) else {
408                missing_contracts += 1;
409                continue;
410            };
411            for (field, value, invalid_sign) in [
412                ("gamma", gamma, false),
413                ("open_interest", interest, interest < 0.0),
414                ("multiplier", multiplier, multiplier <= 0.0),
415                ("underlying_price", price, price < 0.0),
416            ] {
417                if !value.is_finite() || invalid_sign {
418                    return Err(invalid(field));
419                }
420            }
421            let value = if gamma == 0.0 || interest == 0.0 || price == 0.0 {
422                0.0
423            } else {
424                gamma * interest * multiplier * price * price * 0.01
425            };
426            if !value.is_finite() {
427                return Err(invalid("gamma exposure overflow"));
428            }
429            let mut row = OptionExposureRow::new(quote.expiry, quote.strike);
430            match quote.kind {
431                OptionKind::Call => row.calls = Some(value),
432                OptionKind::Put => row.puts = Some(-value),
433            }
434            contributions.push(row);
435        }
436        let Some(time) = time else {
437            return Ok(None);
438        };
439        contributions.sort_by(|a, b| {
440            a.expiry
441                .cmp(&b.expiry)
442                .then_with(|| a.strike.total_cmp(&b.strike))
443        });
444        let mut rows: Vec<OptionExposureRow> = Vec::new();
445        for contribution in contributions {
446            if let Some(row) = rows.last_mut()
447                && row.expiry == contribution.expiry
448                && row.strike == contribution.strike
449            {
450                if let Some(value) = contribution.calls {
451                    row.calls = Some(row.calls.unwrap_or(0.0) + value);
452                }
453                if let Some(value) = contribution.puts {
454                    row.puts = Some(row.puts.unwrap_or(0.0) + value);
455                }
456            } else {
457                rows.push(contribution);
458            }
459            let row = rows.last_mut().unwrap();
460            row.net = Some(row.calls.unwrap_or(0.0) + row.puts.unwrap_or(0.0));
461            if [row.calls, row.puts, row.net]
462                .into_iter()
463                .flatten()
464                .any(|value| !value.is_finite())
465            {
466                return Err(FeedError::InvalidData(format!(
467                    "exposure {} at {}: sum overflow",
468                    row.expiry, row.strike
469                )));
470            }
471        }
472        let mut exposure = Self::new(
473            OptionExposureKind::Gamma,
474            time,
475            "price currency per 1% move",
476            rows,
477        );
478        exposure.missing_contracts = missing_contracts;
479        Ok(Some(exposure))
480    }
481
482    fn validate(&self, query: &OptionExposureQuery) -> Result<(), FeedError> {
483        if self.kind != query.kind {
484            return Err(FeedError::InvalidData(
485                "exposure kind differs from query".into(),
486            ));
487        }
488        if self.rows.len() > 100_000 {
489            return Err(FeedError::InvalidData(
490                "exposure rows exceeds 100,000".into(),
491            ));
492        }
493        for row in &self.rows {
494            let invalid = |field| {
495                FeedError::InvalidData(format!(
496                    "exposure {} at {}: {field}",
497                    row.expiry, row.strike
498                ))
499            };
500            if !query.expiries.contains(&row.expiry) {
501                return Err(invalid("expiry outside query range"));
502            }
503            if !row.strike.is_finite() || row.strike <= 0.0 {
504                return Err(invalid("strike"));
505            }
506            for (field, value) in [("calls", row.calls), ("puts", row.puts), ("net", row.net)] {
507                if value.is_some_and(|value| !value.is_finite()) {
508                    return Err(invalid(field));
509                }
510            }
511        }
512        Ok(())
513    }
514}
515
516struct ExposureState {
517    query: OptionExposureQuery,
518    value: Option<Arc<OptionExposure>>,
519    error: Option<FeedError>,
520    finished: bool,
521}
522
523/// A feed's destination for complete exposure snapshots. Cheap to clone.
524/// Updates replace the latest snapshot without queuing or adding to computed gamma.
525#[derive(Clone)]
526pub struct OptionExposureSink(Weak<Snapshot<ExposureState>>);
527
528impl OptionExposureSink {
529    /// Replace the held snapshot and wake the host. Refuse the whole snapshot for a nonfinite
530    /// figure, invalid strike, expiry outside the query, different kind or over 100,000 rows.
531    /// Report [`FeedError::InvalidData`] without changing the previous snapshot; an accepted
532    /// push clears the error. Inactive sinks ignore pushes.
533    pub fn push(&self, value: OptionExposure) -> Result<(), FeedError> {
534        let Some(snapshot) = self.0.upgrade() else {
535            return Ok(());
536        };
537        snapshot
538            .update(|state| {
539                if state.finished {
540                    return None;
541                }
542                Some(match value.validate(&state.query) {
543                    Ok(()) => {
544                        state.value = Some(Arc::new(value));
545                        state.error = None;
546                        state.finished = state.query.as_of.is_some();
547                        Ok(())
548                    }
549                    Err(error) => {
550                        state.error = Some(error.clone());
551                        Err(error)
552                    }
553                })
554            })
555            .unwrap_or(Ok(()))
556    }
557
558    /// Report a failure, retaining the last accepted snapshot, and wake the host.
559    /// Historical requests finish on this error; live requests can recover on a later push.
560    pub fn report_error(&self, error: FeedError) {
561        if let Some(snapshot) = self.0.upgrade() {
562            snapshot.update(|state| {
563                if state.finished {
564                    return None;
565                }
566                state.error = Some(error);
567                state.finished = state.query.as_of.is_some();
568                Some(())
569            });
570        }
571    }
572
573    /// Whether a consumer remains and, for a historical request, still awaits an answer.
574    pub fn is_active(&self) -> bool {
575        self.0
576            .upgrade()
577            .is_some_and(|snapshot| snapshot.read(|state| !state.finished))
578    }
579}
580
581/// The latest exposure for one query, independent of egui. Clones share the subscription;
582/// dropping the last handle makes its sink inactive.
583#[derive(Clone)]
584pub struct OptionExposureHandle(Arc<Snapshot<ExposureState>>);
585
586impl OptionExposureHandle {
587    /// Subscribe outside egui closures, waking the host on every snapshot or error.
588    /// Returns `None` for an empty symbol or reversed expiry range. Unsupported feeds report
589    /// [`FeedError::NotSupported`] through [`Self::error`], leaving gamma fallback available.
590    pub fn new(
591        feed: &dyn Datafeed,
592        query: OptionExposureQuery,
593        wake: impl Fn() + Send + Sync + 'static,
594    ) -> Option<Self> {
595        if query.symbol.is_empty() || query.expiries.is_empty() {
596            return None;
597        }
598        let snapshot = Snapshot::new(
599            ExposureState {
600                query: query.clone(),
601                value: None,
602                error: None,
603                finished: false,
604            },
605            wake,
606        );
607        feed.option_exposure(query, OptionExposureSink(Arc::downgrade(&snapshot)));
608        Some(Self(snapshot))
609    }
610
611    /// Latest accepted snapshot, exactly as delivered, or `None` until one arrives.
612    pub fn snapshot(&self) -> Option<Arc<OptionExposure>> {
613        self.0.read(|state| state.value.clone())
614    }
615
616    /// Prefer a supplied snapshot, including one with no rows, over computed gamma.
617    /// Before one arrives, compute gamma from `quotes` within the query's expiry range.
618    /// Supply quotes for this underlying and, for historical requests, the requested instant.
619    /// Other exposure kinds have no local fallback. Never adds computed and supplied values.
620    pub fn snapshot_or_gamma(
621        &self,
622        quotes: &[OptionQuote],
623    ) -> Result<Option<Arc<OptionExposure>>, FeedError> {
624        let (value, query) = self
625            .0
626            .read(|state| (state.value.clone(), state.query.clone()));
627        if value.is_some() || query.kind != OptionExposureKind::Gamma {
628            return Ok(value);
629        }
630        OptionExposure::gamma(
631            quotes
632                .iter()
633                .filter(|quote| query.expiries.contains(&quote.expiry)),
634        )
635        .map(|value| value.map(Arc::new))
636    }
637
638    /// Last feed or validation error, cleared by the next accepted snapshot.
639    pub fn error(&self) -> Option<FeedError> {
640        self.0.read(|state| state.error.clone())
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::data::inbox::Inbox;
648    use crate::data::{HistoryPage, HistoryRequest, Reply};
649    use chrono::TimeZone;
650    use std::sync::Mutex;
651    use std::sync::atomic::{AtomicUsize, Ordering};
652
653    #[derive(Default)]
654    struct Feed {
655        chain: Mutex<Option<(OptionChainQuery, OptionChainSink)>>,
656        exposure: Mutex<Option<(OptionExposureQuery, OptionExposureSink)>>,
657    }
658
659    impl Datafeed for Feed {
660        fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
661            reply.send(Ok(HistoryPage::default()));
662        }
663
664        fn option_chain(&self, query: OptionChainQuery, sink: OptionChainSink) {
665            *self.chain.lock().unwrap() = Some((query, sink));
666        }
667
668        fn option_exposure(&self, query: OptionExposureQuery, sink: OptionExposureSink) {
669            *self.exposure.lock().unwrap() = Some((query, sink));
670        }
671    }
672
673    fn query() -> OptionChainQuery {
674        let start = NaiveDate::from_ymd_opt(2026, 12, 18).unwrap();
675        let end = NaiveDate::from_ymd_opt(2027, 1, 15).unwrap();
676        OptionChainQuery::new("SAMPLE", start..=end)
677    }
678
679    fn quote(symbol: &str) -> OptionQuote {
680        OptionQuote::new(
681            symbol,
682            *query().expiries.start(),
683            100.0,
684            OptionKind::Call,
685            Utc.with_ymd_and_hms(2026, 11, 2, 12, 0, 0).unwrap(),
686        )
687    }
688
689    #[test]
690    fn gamma_keeps_expiries_separate_counts_missing_inputs_and_preserves_zero_interest() {
691        let e1 = *query().expiries.start();
692        let e2 = *query().expiries.end();
693        let mut quotes: Vec<_> = [
694            (e2, 100.0, OptionKind::Call, 200.0, Some(0.01), 4.0),
695            (e1, 110.0, OptionKind::Call, 100.0, Some(0.01), 0.0),
696            (e1, 100.0, OptionKind::Put, 100.0, Some(0.01), 5.0),
697            (e1, 100.0, OptionKind::Call, 100.0, Some(0.02), 10.0),
698            (e1, 120.0, OptionKind::Call, 100.0, None, 10.0),
699        ]
700        .into_iter()
701        .map(|(expiry, strike, kind, price, gamma, interest)| {
702            let mut row = quote("CONTRACT");
703            row.expiry = expiry;
704            row.strike = strike;
705            row.kind = kind;
706            row.underlying_price = Some(price);
707            row.gamma = gamma;
708            row.open_interest = Some(interest);
709            row.multiplier = Some(100.0);
710            row
711        })
712        .collect();
713        let exposure = OptionExposure::gamma(&quotes).unwrap().unwrap();
714        assert_eq!(exposure.kind, OptionExposureKind::Gamma);
715        assert_eq!(exposure.time, quotes[0].time);
716        assert_eq!(exposure.unit, "price currency per 1% move");
717        assert_eq!(exposure.model, None);
718        assert_eq!(exposure.missing_contracts, 1);
719        let expected = [
720            (e1, 100.0, Some(2000.0), Some(-500.0), 1500.0),
721            (e1, 110.0, Some(0.0), None, 0.0),
722            (e2, 100.0, Some(1600.0), None, 1600.0),
723        ];
724        assert_eq!(exposure.rows.len(), expected.len());
725        for (row, (expiry, strike, calls, puts, net)) in exposure.rows.iter().zip(expected) {
726            assert_eq!((row.expiry, row.strike), (expiry, strike));
727            assert_eq!((row.calls, row.puts, row.net), (calls, puts, Some(net)));
728        }
729
730        type Field = fn(&mut OptionQuote) -> &mut Option<f64>;
731        for select in [
732            (|r| &mut r.gamma) as Field,
733            (|r| &mut r.open_interest) as Field,
734            (|r| &mut r.multiplier) as Field,
735            (|r| &mut r.underlying_price) as Field,
736        ] {
737            let mut row = quotes[0].clone();
738            *select(&mut row) = None;
739            let exposure = OptionExposure::gamma([&row]).unwrap().unwrap();
740            assert!(exposure.rows.is_empty());
741            assert_eq!(exposure.missing_contracts, 1);
742        }
743        assert_eq!(OptionExposure::gamma(&[]).unwrap(), None);
744
745        quotes[0].time += chrono::TimeDelta::seconds(1);
746        quotes.push(quotes[3].clone());
747        quotes.push(quotes[2].clone());
748        let exposure = OptionExposure::gamma(&quotes).unwrap().unwrap();
749        assert_eq!(exposure.time, quotes[0].time);
750        assert_eq!(exposure.rows[0].calls, Some(4000.0));
751        assert_eq!(exposure.rows[0].puts, Some(-1000.0));
752        assert_eq!(exposure.rows[0].net, Some(3000.0));
753
754        for (gamma, interest, multiplier, price, succeeds) in [
755            (f64::NAN, 1.0, 100.0, 100.0, false),
756            (0.01, -1.0, 100.0, 100.0, false),
757            (0.01, 1.0, 0.0, 100.0, false),
758            (0.01, 1.0, 100.0, f64::INFINITY, false),
759            (1.0, 1.0, 100.0, f64::MAX, false),
760            (1.0, 0.0, 100.0, f64::MAX, true),
761            (0.01, 1.0, 100.0, 0.0, true),
762            (f64::MAX, f64::MAX, 100.0, 0.0, true),
763        ] {
764            let mut row = quotes[0].clone();
765            row.gamma = Some(gamma);
766            row.open_interest = Some(interest);
767            row.multiplier = Some(multiplier);
768            row.underlying_price = Some(price);
769            let result = OptionExposure::gamma([&row]);
770            if succeeds {
771                assert_eq!(result.unwrap().unwrap().rows[0].calls, Some(0.0));
772            } else {
773                assert!(matches!(result, Err(FeedError::InvalidData(_))));
774            }
775        }
776        let mut row = quotes[0].clone();
777        row.gamma = Some(1.0e308);
778        row.open_interest = Some(1.0);
779        row.multiplier = Some(1.0);
780        row.underlying_price = Some(1.0);
781        assert!(matches!(
782            OptionExposure::gamma(std::iter::repeat_n(&row, 200)),
783            Err(FeedError::InvalidData(_))
784        ));
785    }
786
787    #[test]
788    fn supplied_exposure_replaces_gamma_and_invalid_snapshots_preserve_the_whole_observation() {
789        for kind in [
790            OptionExposureKind::Gamma,
791            OptionExposureKind::Delta,
792            OptionExposureKind::Vanna,
793            OptionExposureKind::Charm,
794        ] {
795            let feed = Feed::default();
796            let query = OptionExposureQuery::new("SAMPLE", query().expiries, kind);
797            let handle = OptionExposureHandle::new(&feed, query.clone(), || {}).unwrap();
798            let (received, sink) = feed.exposure.lock().unwrap().clone().unwrap();
799            assert_eq!(received, query);
800            assert!(handle.snapshot().is_none());
801            let mut contract = quote("CALL");
802            contract.gamma = Some(0.02);
803            contract.open_interest = Some(10.0);
804            contract.multiplier = Some(100.0);
805            contract.underlying_price = Some(100.0);
806            let fallback = handle
807                .snapshot_or_gamma(std::slice::from_ref(&contract))
808                .unwrap();
809            assert_eq!(fallback.is_some(), kind == OptionExposureKind::Gamma);
810            if let Some(fallback) = fallback {
811                assert_eq!(fallback.rows[0].calls, Some(2000.0));
812            }
813            contract.expiry = query.expiries.start().pred_opt().unwrap();
814            assert!(handle.snapshot_or_gamma(&[contract]).unwrap().is_none());
815
816            let mut row = OptionExposureRow::new(*query.expiries.end(), 110.0);
817            row.puts = Some(17.0);
818            row.net = Some(-42.0);
819            let mut supplied =
820                OptionExposure::new(kind, quote("CALL").time, "host units", vec![row]);
821            supplied.model = Some("host model".into());
822            supplied.missing_contracts = 3;
823            sink.push(supplied.clone()).unwrap();
824            let held = handle.snapshot().unwrap();
825            // Even invalid fallback quotes cannot change or prevent a supplied observation.
826            let mut invalid_quote = quote("INVALID");
827            invalid_quote.strike = f64::NAN;
828            let chosen = handle.snapshot_or_gamma(&[invalid_quote]).unwrap().unwrap();
829            assert!(Arc::ptr_eq(&held, &chosen));
830            assert_eq!(*chosen, supplied);
831
832            type Field = fn(&mut OptionExposureRow) -> &mut Option<f64>;
833            let mut invalid = Vec::new();
834            for select in [
835                (|r| &mut r.calls) as Field,
836                (|r| &mut r.puts) as Field,
837                (|r| &mut r.net) as Field,
838            ] {
839                for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
840                    let mut bad = supplied.clone();
841                    *select(&mut bad.rows[0]) = Some(value);
842                    bad.rows
843                        .insert(0, OptionExposureRow::new(*query.expiries.start(), 90.0));
844                    invalid.push(bad);
845                }
846            }
847            for strike in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
848                let mut bad = supplied.clone();
849                bad.rows[0].strike = strike;
850                invalid.push(bad);
851            }
852            let mut bad = supplied.clone();
853            bad.rows[0].expiry = query.expiries.end().succ_opt().unwrap();
854            invalid.push(bad);
855            let mut bad = supplied.clone();
856            bad.kind = if kind == OptionExposureKind::Gamma {
857                OptionExposureKind::Delta
858            } else {
859                OptionExposureKind::Gamma
860            };
861            invalid.push(bad);
862            let mut bad = supplied.clone();
863            bad.rows = vec![bad.rows[0].clone(); 100_001];
864            invalid.push(bad);
865            for bad in invalid {
866                let error = sink.push(bad).unwrap_err();
867                assert!(matches!(error, FeedError::InvalidData(_)));
868                assert_eq!(handle.error(), Some(error));
869                assert!(Arc::ptr_eq(&handle.snapshot().unwrap(), &held));
870            }
871            sink.report_error(FeedError::Network("offline".into()));
872            assert!(Arc::ptr_eq(&handle.snapshot().unwrap(), &held));
873            supplied.rows.clear();
874            sink.push(supplied.clone()).unwrap();
875            assert_eq!(*handle.snapshot_or_gamma(&[]).unwrap().unwrap(), supplied);
876            assert_eq!(handle.error(), None);
877        }
878    }
879
880    #[test]
881    fn exposure_snapshots_stop_after_historical_answers_or_the_last_consumer() {
882        for historical in [false, true] {
883            for fail in [false, true] {
884                let feed = Feed::default();
885                let mut query =
886                    OptionExposureQuery::new("SAMPLE", query().expiries, OptionExposureKind::Gamma);
887                query.as_of = historical.then_some(quote("CALL").time);
888                let wakes = Arc::new(AtomicUsize::new(0));
889                let counter = wakes.clone();
890                let handle = OptionExposureHandle::new(&feed, query, move || {
891                    counter.fetch_add(1, Ordering::Relaxed);
892                })
893                .unwrap();
894                let (_, sink) = feed.exposure.lock().unwrap().clone().unwrap();
895                let clone = handle.clone();
896                let supplied = OptionExposure::new(
897                    OptionExposureKind::Gamma,
898                    quote("CALL").time,
899                    "host units",
900                    Vec::new(),
901                );
902                if fail {
903                    sink.report_error(FeedError::NotSupported);
904                } else {
905                    sink.push(supplied.clone()).unwrap();
906                }
907                assert_eq!(sink.is_active(), !historical);
908                assert_eq!(wakes.load(Ordering::Relaxed), 1);
909                sink.push(supplied).unwrap();
910                sink.report_error(FeedError::Network("late".into()));
911                assert_eq!(
912                    wakes.load(Ordering::Relaxed),
913                    if historical { 1 } else { 3 }
914                );
915                assert_eq!(handle.snapshot(), clone.snapshot());
916                if historical {
917                    assert_eq!(handle.snapshot().is_none(), fail);
918                    assert_eq!(handle.error(), fail.then_some(FeedError::NotSupported));
919                }
920                drop(handle);
921                assert_eq!(sink.is_active(), !historical);
922                drop(clone);
923                assert!(!sink.is_active());
924                let before = wakes.load(Ordering::Relaxed);
925                sink.report_error(FeedError::NotSupported);
926                assert_eq!(wakes.load(Ordering::Relaxed), before);
927            }
928        }
929    }
930
931    #[test]
932    fn invalid_snapshots_name_the_contract_and_field_and_preserve_all_held_rows() {
933        let feed = Feed::default();
934        let chain = OptionChain::new(&feed, query(), || {}).unwrap();
935        let (_, sink) = feed.chain.lock().unwrap().clone().unwrap();
936        let mut put = quote("PUT");
937        put.kind = OptionKind::Put;
938        put.expiry = *query().expiries.end();
939        sink.push(vec![quote("CALL"), put]).unwrap();
940        let held = chain.rows();
941
942        type Field = fn(&mut OptionQuote) -> &mut Option<f64>;
943        let mut cases = Vec::new();
944        for (field, select, positive, signed) in [
945            ("bid", (|r| &mut r.bid) as Field, false, false),
946            ("ask", (|r| &mut r.ask) as Field, false, false),
947            ("last", (|r| &mut r.last) as Field, false, false),
948            ("volume", (|r| &mut r.volume) as Field, false, false),
949            (
950                "open_interest",
951                (|r| &mut r.open_interest) as Field,
952                false,
953                false,
954            ),
955            (
956                "implied_volatility",
957                (|r| &mut r.implied_volatility) as Field,
958                false,
959                false,
960            ),
961            (
962                "underlying_price",
963                (|r| &mut r.underlying_price) as Field,
964                false,
965                false,
966            ),
967            ("multiplier", (|r| &mut r.multiplier) as Field, true, false),
968            ("delta", (|r| &mut r.delta) as Field, false, true),
969            ("gamma", (|r| &mut r.gamma) as Field, false, true),
970            ("theta", (|r| &mut r.theta) as Field, false, true),
971            ("vega", (|r| &mut r.vega) as Field, false, true),
972        ] {
973            for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0] {
974                if value.is_finite() && (signed || (value == 0.0 && !positive)) {
975                    continue;
976                }
977                let mut row = quote("INVALID");
978                *select(&mut row) = Some(value);
979                cases.push((field, vec![quote("CHANGED"), row]));
980            }
981        }
982        for strike in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0] {
983            let mut row = quote("INVALID");
984            row.strike = strike;
985            cases.push(("strike", vec![quote("CHANGED"), row]));
986        }
987        for expiry in [
988            query().expiries.start().pred_opt().unwrap(),
989            query().expiries.end().succ_opt().unwrap(),
990        ] {
991            let mut row = quote("INVALID");
992            row.expiry = expiry;
993            cases.push(("expiry", vec![quote("CHANGED"), row]));
994        }
995        cases.push(("rows", vec![quote("INVALID"); 100_001]));
996
997        for (field, rows) in cases {
998            let error = sink.push(rows).unwrap_err();
999            assert!(
1000                matches!(&error, FeedError::InvalidData(message)
1001                    if message.contains("INVALID") && message.contains(field)),
1002                "{field}: {error:?}"
1003            );
1004            assert_eq!(chain.error(), Some(error));
1005            assert!(Arc::ptr_eq(&chain.rows(), &held), "{field}");
1006        }
1007        sink.push((0..100_000).map(|i| quote(&format!("CALL-{i}"))).collect())
1008            .unwrap();
1009        assert_eq!(chain.rows().len(), 100_000);
1010        assert_eq!(chain.error(), None);
1011    }
1012
1013    #[test]
1014    fn snapshots_replace_without_filling_missing_figures_and_end_with_the_last_consumer() {
1015        for historical in [false, true] {
1016            let feed = Feed::default();
1017            let mut query = query();
1018            query.as_of = historical.then_some(quote("CALL").time);
1019            let wakes = Arc::new(AtomicUsize::new(0));
1020            let counter = Arc::clone(&wakes);
1021            let chain = OptionChain::new(&feed, query.clone(), move || {
1022                counter.fetch_add(1, Ordering::Relaxed);
1023            })
1024            .unwrap();
1025            let (received, sink) = feed.chain.lock().unwrap().clone().unwrap();
1026            assert_eq!(received, query);
1027            assert!(chain.rows().is_empty());
1028            assert_eq!(chain.error(), None);
1029            assert!(sink.is_active());
1030            let clone = chain.clone();
1031
1032            sink.report_error(FeedError::Network("offline".into()));
1033            assert_eq!(chain.error(), Some(FeedError::Network("offline".into())));
1034            if historical {
1035                assert!(!sink.is_active());
1036                sink.push(vec![quote("LATE")]).unwrap();
1037                sink.report_error(FeedError::NotSupported);
1038                assert!(chain.rows().is_empty());
1039                assert_eq!(chain.error(), Some(FeedError::Network("offline".into())));
1040                assert_eq!(wakes.load(Ordering::Relaxed), 1);
1041                continue;
1042            }
1043            for interest in [0.0, 1200.0] {
1044                let mut row = quote("CALL");
1045                row.bid = Some(0.0);
1046                row.open_interest = Some(interest);
1047                row.gamma = Some(-0.04);
1048                row.theta = Some(-0.5);
1049                sink.push(vec![row.clone()]).unwrap();
1050                assert_eq!(chain.rows().as_ref(), [row]);
1051                let rows = chain.rows();
1052                assert!(
1053                    [
1054                        rows[0].multiplier,
1055                        rows[0].ask,
1056                        rows[0].last,
1057                        rows[0].volume,
1058                        rows[0].implied_volatility,
1059                        rows[0].delta,
1060                        rows[0].vega,
1061                        rows[0].underlying_price,
1062                    ]
1063                    .iter()
1064                    .all(Option::is_none)
1065                );
1066                assert_eq!(clone.rows(), chain.rows());
1067                assert_eq!(chain.error(), None);
1068                assert!(sink.is_active());
1069            }
1070            sink.push(Vec::new()).unwrap();
1071            assert!(chain.rows().is_empty());
1072            assert_eq!(wakes.load(Ordering::Relaxed), 4);
1073            drop(chain);
1074            assert!(sink.is_active());
1075            drop(clone);
1076            assert!(!sink.is_active());
1077            let before = wakes.load(Ordering::Relaxed);
1078            sink.push(vec![quote("LATE")]).unwrap();
1079            sink.report_error(FeedError::NotSupported);
1080            assert_eq!(wakes.load(Ordering::Relaxed), before);
1081        }
1082
1083        let feed = Feed::default();
1084        let mut query = query();
1085        query.as_of = Some(quote("CALL").time);
1086        let wakes = Arc::new(AtomicUsize::new(0));
1087        let counter = Arc::clone(&wakes);
1088        let chain = OptionChain::new(&feed, query, move || {
1089            counter.fetch_add(1, Ordering::Relaxed);
1090        })
1091        .unwrap();
1092        let (_, sink) = feed.chain.lock().unwrap().clone().unwrap();
1093        sink.push(vec![quote("CALL")]).unwrap();
1094        let held = chain.rows();
1095        assert!(!sink.is_active());
1096        sink.push(Vec::new()).unwrap();
1097        sink.report_error(FeedError::NotSupported);
1098        assert!(Arc::ptr_eq(&chain.rows(), &held));
1099        assert_eq!(chain.error(), None);
1100        assert_eq!(wakes.load(Ordering::Relaxed), 1);
1101    }
1102
1103    #[test]
1104    fn history_only_feeds_report_unsupported_options_and_bad_queries_never_subscribe() {
1105        struct HistoryOnly;
1106        impl Datafeed for HistoryOnly {
1107            fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
1108                reply.send(Ok(HistoryPage::default()));
1109            }
1110        }
1111
1112        let inbox = Arc::new(Inbox::default());
1113        HistoryOnly.option_expiries("SAMPLE", inbox.reply(0));
1114        let answers = inbox.drain().answers;
1115        assert_eq!(answers.len(), 1);
1116        assert_eq!(
1117            *answers
1118                .into_iter()
1119                .next()
1120                .unwrap()
1121                .1
1122                .downcast::<Result<Vec<NaiveDate>, FeedError>>()
1123                .unwrap(),
1124            Err(FeedError::NotSupported)
1125        );
1126        for historical in [false, true] {
1127            let mut query = query();
1128            query.as_of = historical.then_some(quote("CALL").time);
1129            let chain = OptionChain::new(&HistoryOnly, query.clone(), || {}).unwrap();
1130            assert!(chain.rows().is_empty());
1131            assert_eq!(chain.error(), Some(FeedError::NotSupported));
1132            for kind in [
1133                OptionExposureKind::Gamma,
1134                OptionExposureKind::Delta,
1135                OptionExposureKind::Vanna,
1136                OptionExposureKind::Charm,
1137            ] {
1138                let mut exposure_query =
1139                    OptionExposureQuery::new("SAMPLE", query.expiries.clone(), kind);
1140                exposure_query.as_of = query.as_of;
1141                let handle =
1142                    OptionExposureHandle::new(&HistoryOnly, exposure_query, || {}).unwrap();
1143                assert!(handle.snapshot().is_none());
1144                assert_eq!(handle.error(), Some(FeedError::NotSupported));
1145            }
1146        }
1147
1148        let feed = Feed::default();
1149        let mut empty_symbol = query();
1150        empty_symbol.symbol.clear();
1151        let mut reversed = query();
1152        reversed.expiries = *reversed.expiries.end()..=*reversed.expiries.start();
1153        for query in [empty_symbol, reversed] {
1154            let exposure_query = OptionExposureQuery::new(
1155                &query.symbol,
1156                query.expiries.clone(),
1157                OptionExposureKind::Gamma,
1158            );
1159            assert!(OptionChain::new(&feed, query, || {}).is_none());
1160            assert!(OptionExposureHandle::new(&feed, exposure_query, || {}).is_none());
1161        }
1162        assert!(feed.chain.lock().unwrap().is_none());
1163        assert!(feed.exposure.lock().unwrap().is_none());
1164    }
1165}