Skip to main content

rustrade_data/exchange/lse/
options.rs

1//! US equity and ETF option prints and one-minute option candles from the London Strategic Edge
2//! vault.
3//!
4//! ```no_run
5//! # use chrono::{Duration, TimeZone, Utc};
6//! # use futures::StreamExt;
7//! # use rustrade_data::exchange::lse::error::LseError;
8//! # use rustrade_data::exchange::lse::vault::LseVaultClient;
9//! # async fn example() -> Result<(), LseError> {
10//! let client = LseVaultClient::from_env()?;
11//! let start = Utc.with_ymd_and_hms(2026, 9, 22, 15, 0, 0).unwrap();
12//! let end = start + Duration::minutes(5);
13//!
14//! let prints = client.fetch_option_flow(Some("SPY"), start, end);
15//! futures::pin_mut!(prints);
16//!
17//! while let Some(print) = prints.next().await {
18//!     let print = print?;
19//!     println!("{} {} x{} delta={:?}", print.contract.ticker, print.price, print.volume, print.greeks.delta);
20//! }
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! # ⚠️ Licensing
26//! Data retrieved here is **not redistributable**. See the [module documentation](super) and
27//! <https://londonstrategicedge.com/terms>.
28//!
29//! # What this feed is — and is not
30//! - **Prints, not quotes.** Every row is an executed option trade. There is no bid, ask or book
31//!   anywhere on these paths, so nothing here can build an [`OrderBookL1`](crate::subscription::book::OrderBookL1).
32//! - **⚠️ Greeks are PRINT-TRIGGERED, not continuous.** Each print carries the greeks the provider
33//!   computed at that print, and nothing publishes them in between. A contract's greeks are
34//!   therefore only as fresh as its last trade, which on an illiquid strike can be weeks old — and
35//!   an unheld contract that never trades is never marked at all. This is a property of the source:
36//!   the provider's chain snapshot endpoint, which would be the continuous alternative, serves
37//!   stale stored fields (expired contracts, a `dte` that disagrees with the expiry) and is
38//!   deliberately not wrapped. Do not treat the latest print's greeks as a current risk figure
39//!   without checking its age.
40//! - **Timestamps are batch stamps at whole-second granularity.** Every print inside one second
41//!   carries a stamp within a few milliseconds of every other, so the sub-second part is not a
42//!   print time. Never order, de-duplicate or join on the timestamp alone; the provider's print
43//!   [`id`](LseOptionPrint::id) is the unique key.
44//! - **Strikes are not always round.** Contracts adjusted for a corporate action carry fractional
45//!   strikes (`2.67`, `9.85`), so the strike is a [`Decimal`] and the contract's identity is its
46//!   [`ticker`](LseOptionContract::ticker), not a strike rebuilt from integers.
47//! - **⚠️ A contract is read from its ticker, not from the row's separate fields.** The served
48//!   `strike` is a float and has carried noise (`504.99999999999994` for a 505 strike), so
49//!   [`kind`](LseOptionContract::kind), [`strike`](LseOptionContract::strike) and
50//!   [`expiry`](LseOptionContract::expiry) are all read exactly from the OSI ticker. A row whose
51//!   ticker is not an OSI symbol is a decode error.
52//! - **An unknown underlying or ticker is an empty result, not an error.** The provider answers
53//!   `200` with no rows for a symbol it has never heard of, indistinguishable here from a quiet
54//!   period.
55//! - **Exercise style is not reported**, so none is claimed: most listed US equity options are
56//!   American, but index options are European, and guessing would be this library inventing a fact.
57
58use crate::event::{DataKind, MarketEvent};
59use crate::exchange::lse::PROVIDER_TIMESTAMP_FORMAT;
60use crate::exchange::lse::error::LseError;
61use crate::exchange::lse::historical::{VaultCandleRow, parse_candle_open};
62use crate::exchange::lse::vault::LseVaultClient;
63use crate::exchange::osi;
64use crate::subscription::{
65    candle::{Candle, CandleInterval},
66    greeks::OptionGreeks,
67    trade::PublicTrade,
68};
69use async_stream::try_stream;
70use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeDelta, Timelike, Utc};
71use futures::{Stream, StreamExt};
72use rust_decimal::Decimal;
73use rustrade_instrument::{exchange::ExchangeId, instrument::kind::option::OptionKind};
74use serde::{Deserialize, Serialize};
75use smol_str::{SmolStr, ToSmolStr};
76use tracing::debug;
77
78/// How long after a moment the provider's print tape can be relied on to be complete up to it.
79///
80/// Ingestion lag on `/options/flow` is variable and **episodic**: a window is usually complete
81/// about fifteen seconds after it closes, but during a bad minute one was measured returning **no
82/// rows at all** thirty seconds after closing, and another sat at roughly a fifth of its final count
83/// across two consecutive reads. Both answered `200`. A partial window holds steady, so completeness
84/// cannot be detected by polling until the count stops changing.
85///
86/// [`fetch_option_flow`](LseVaultClient::fetch_option_flow) therefore refuses a range ending
87/// inside this margin rather than returning rows that look final and are not. Sixty seconds is
88/// twice the longest outlier measured, which makes it a floor informed by observation rather than a
89/// guarantee — a fetch near it during provider degradation can still come back short.
90pub const OPTION_FLOW_SETTLE_MARGIN: TimeDelta = TimeDelta::seconds(60);
91
92/// Format accepted by the flow endpoint's `start` / `end` parameters: whole seconds only. A
93/// fractional second, and the ISO `T…Z` form, are rejected with a `400`.
94const WINDOW_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
95
96/// The first adaptive window [`fetch_option_flow`](LseVaultClient::fetch_option_flow) tries.
97///
98/// One minute of the busiest underlying measured is roughly two and a half thousand prints —
99/// inside the provider's 5,000-row cap — so a busy underlying rarely needs a retry on its first
100/// window, and a quiet one grows out of it in a few doublings.
101const INITIAL_FLOW_WINDOW: TimeDelta = TimeDelta::seconds(60);
102
103/// The widest window the adaptive walk grows to.
104///
105/// Bounds how far a sparse stretch — a weekend, a quiet underlying — is covered by one request.
106/// Growth is only ever a request-count optimisation: correctness comes from the cap check, not from
107/// the window size.
108const MAX_FLOW_WINDOW: TimeDelta = TimeDelta::days(1);
109
110/// The narrowest window the walk can shrink to, set by the bounds' whole-second precision.
111const MIN_FLOW_WINDOW: TimeDelta = TimeDelta::seconds(1);
112
113/// One option contract, as the provider identifies it.
114#[non_exhaustive]
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub struct LseOptionContract {
117    /// The OSI contract symbol, e.g. `SPY260928C00764000` — the contract's identity.
118    pub ticker: SmolStr,
119    /// The underlying's symbol, e.g. `SPY`.
120    pub underlying: SmolStr,
121    /// Read from [`ticker`](Self::ticker), like `strike` and `expiry`.
122    pub kind: OptionKind,
123    /// Fractional on contracts adjusted for a corporate action. Read from
124    /// [`ticker`](Self::ticker), so it is exact and spells the ticker back.
125    pub strike: Decimal,
126    /// Read from [`ticker`](Self::ticker), like `kind` and `strike`.
127    pub expiry: NaiveDate,
128}
129
130impl LseOptionContract {
131    /// Build a contract from a served row, reading its kind, strike and expiry from the ticker.
132    ///
133    /// ⚠️ The row's own `strike`, `contract_type` and `expiry` fields are deliberately not decoded.
134    /// The strike is a JSON float, and the provider has served `504.99999999999994` on
135    /// `SPY261130P00505000`: carried through, that strike spells no OSI symbol, so a subscription
136    /// built from the contract is rejected. The ticker is the contract's identity and carries all
137    /// three exactly, so reading them from it is what guarantees the contract spells the ticker
138    /// back.
139    ///
140    /// `underlying` stays the row's: an adjusted contract's OSI root (`SPY1`) is not its
141    /// underlying's symbol.
142    fn from_row(ticker: SmolStr, underlying: SmolStr) -> Result<Self, LseError> {
143        let parsed = osi::parse(&ticker).ok_or_else(|| LseError::Deserialize {
144            message: format!(
145                "option ticker {ticker:?} is not an OSI contract symbol with a calendar expiry \
146                 and a positive strike"
147            ),
148        })?;
149        let (kind, strike, expiry) = (parsed.kind, parsed.strike, parsed.expiry);
150
151        Ok(Self {
152            ticker,
153            underlying,
154            kind,
155            strike,
156            expiry,
157        })
158    }
159}
160
161/// One executed option trade, with the greeks the provider computed at that print.
162#[non_exhaustive]
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct LseOptionPrint {
165    /// The provider's print identifier — unique, and the only reliable key; see the
166    /// [module documentation](self).
167    pub id: u64,
168    /// When the print was recorded, UTC — a whole-second batch stamp, not an execution time.
169    pub time: DateTime<Utc>,
170    pub contract: LseOptionContract,
171    /// Premium per share.
172    pub price: Decimal,
173    /// Size in contracts.
174    pub volume: u64,
175    /// Notional premium in the quote currency, as the provider reports it: `price × volume ×` the
176    /// contract multiplier.
177    pub premium: Decimal,
178    /// Greeks at this print, with the underlying's price at the time in
179    /// [`underlying_price`](OptionGreeks::underlying_price). Any greek may be `None`, and on a
180    /// small share of prints all of them are. See the
181    /// [module documentation](self) on staleness.
182    pub greeks: OptionGreeks,
183}
184
185impl LseOptionPrint {
186    /// This print as a library [`PublicTrade`], sized in contracts.
187    ///
188    /// `side` is `None`: the provider does not report an aggressor.
189    #[must_use]
190    pub fn public_trade(&self) -> PublicTrade {
191        PublicTrade {
192            id: self.id.to_smolstr(),
193            price: self.price,
194            amount: Decimal::from(self.volume),
195            side: None,
196        }
197    }
198
199    /// This print as the market events an engine consumes: its trade, then its greeks.
200    ///
201    /// Both are stamped with the print's [`time`](Self::time) as `time_exchange` **and**
202    /// `time_received` — a historical fetch has no receipt instant of its own — on
203    /// [`ExchangeId::LseOptions`]. The greeks event is emitted even when every greek is `None`; a
204    /// consumer such as the engine's option state decides whether an empty update means anything.
205    #[must_use]
206    pub fn into_market_events<InstrumentKey: Clone>(
207        self,
208        instrument: InstrumentKey,
209    ) -> [MarketEvent<InstrumentKey, DataKind>; 2] {
210        let trade = self.public_trade();
211        [
212            option_event(self.time, instrument.clone(), DataKind::Trade(trade)),
213            option_event(self.time, instrument, DataKind::OptionGreeks(self.greeks)),
214        ]
215    }
216}
217
218/// One minute of trading in one option contract.
219#[non_exhaustive]
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct LseOptionCandle {
222    pub contract: LseOptionContract,
223    /// Premium OHLC for the minute, with [`volume`](Candle::volume) in contracts and
224    /// [`trade_count`](Candle::trade_count) the number of prints.
225    pub candle: Candle,
226    /// Total notional premium traded in the minute.
227    pub premium: Decimal,
228    /// Greeks **averaged over the minute's prints**, not sampled at its close, with the averaged
229    /// underlying price in [`underlying_price`](OptionGreeks::underlying_price).
230    pub greeks: OptionGreeks,
231}
232
233/// One `/options/flow` row as served. Unknown fields — `dte`, derivable from `expiry` — are ignored
234/// rather than rejected, so an added field does not break decoding.
235#[derive(Debug, Deserialize)]
236struct FlowRow {
237    id: u64,
238    ts: String,
239    underlying: SmolStr,
240    ticker: SmolStr,
241    last_price: Decimal,
242    volume: u64,
243    premium: Decimal,
244    #[serde(default)]
245    underlying_price: Option<f64>,
246    #[serde(default)]
247    iv: Option<f64>,
248    #[serde(default)]
249    delta: Option<f64>,
250    #[serde(default)]
251    gamma: Option<f64>,
252    #[serde(default)]
253    theta: Option<f64>,
254    #[serde(default)]
255    vega: Option<f64>,
256    #[serde(default)]
257    rho: Option<f64>,
258}
259
260impl FlowRow {
261    fn time(&self) -> Result<DateTime<Utc>, LseError> {
262        NaiveDateTime::parse_from_str(&self.ts, PROVIDER_TIMESTAMP_FORMAT)
263            .map(|naive| naive.and_utc())
264            .map_err(|error| LseError::Deserialize {
265                message: format!("invalid option print timestamp {:?}: {error}", self.ts),
266            })
267    }
268
269    fn into_print(self, time: DateTime<Utc>) -> Result<LseOptionPrint, LseError> {
270        Ok(LseOptionPrint {
271            id: self.id,
272            time,
273            contract: LseOptionContract::from_row(self.ticker, self.underlying)?,
274            price: self.last_price,
275            volume: self.volume,
276            premium: self.premium,
277            greeks: OptionGreeks {
278                delta: self.delta,
279                gamma: self.gamma,
280                theta: self.theta,
281                vega: self.vega,
282                rho: self.rho,
283                implied_volatility: self.iv,
284                theoretical_price: None,
285                underlying_price: self.underlying_price,
286            },
287        })
288    }
289}
290
291/// One `/options/candles` row as served.
292#[derive(Debug, Deserialize)]
293struct OptionCandleRow {
294    ticker: SmolStr,
295    underlying: SmolStr,
296    /// The bar's **open**, UTC, with no zone suffix.
297    minute: String,
298    open: Decimal,
299    high: Decimal,
300    low: Decimal,
301    close: Decimal,
302    volume: u64,
303    premium: Decimal,
304    print_count: u64,
305    #[serde(default)]
306    underlying_price: Option<f64>,
307    #[serde(default)]
308    iv_avg: Option<f64>,
309    #[serde(default)]
310    delta_avg: Option<f64>,
311    #[serde(default)]
312    gamma_avg: Option<f64>,
313    #[serde(default)]
314    theta_avg: Option<f64>,
315    #[serde(default)]
316    vega_avg: Option<f64>,
317    #[serde(default)]
318    rho_avg: Option<f64>,
319}
320
321impl VaultCandleRow for OptionCandleRow {
322    fn open_time(&self) -> Result<DateTime<Utc>, LseError> {
323        parse_candle_open(&self.minute)
324    }
325}
326
327impl LseOptionCandle {
328    /// This candle as the market events an engine consumes: the bar, then its averaged greeks.
329    ///
330    /// Both are stamped with the bar's [`close_time`](Candle::close_time) — the instant its
331    /// contents, the averages included, became knowable — as `time_exchange` and `time_received`,
332    /// on [`ExchangeId::LseOptions`]. Stamping any earlier would hand an engine the minute's outcome
333    /// before the minute has ended.
334    #[must_use]
335    pub fn into_market_events<InstrumentKey: Clone>(
336        self,
337        instrument: InstrumentKey,
338    ) -> [MarketEvent<InstrumentKey, DataKind>; 2] {
339        let time = self.candle.close_time;
340        [
341            option_event(time, instrument.clone(), DataKind::Candle(self.candle)),
342            option_event(time, instrument, DataKind::OptionGreeks(self.greeks)),
343        ]
344    }
345}
346
347fn option_event<InstrumentKey>(
348    time: DateTime<Utc>,
349    instrument: InstrumentKey,
350    kind: DataKind,
351) -> MarketEvent<InstrumentKey, DataKind> {
352    MarketEvent {
353        time_exchange: time,
354        time_received: time,
355        exchange: ExchangeId::LseOptions,
356        instrument,
357        kind,
358    }
359}
360
361impl OptionCandleRow {
362    fn into_option_candle(self, close_time: DateTime<Utc>) -> Result<LseOptionCandle, LseError> {
363        Ok(LseOptionCandle {
364            contract: LseOptionContract::from_row(self.ticker, self.underlying)?,
365            candle: Candle {
366                close_time,
367                open: self.open,
368                high: self.high,
369                low: self.low,
370                close: self.close,
371                volume: Some(Decimal::from(self.volume)),
372                trade_count: Some(self.print_count),
373            },
374            premium: self.premium,
375            greeks: OptionGreeks {
376                delta: self.delta_avg,
377                gamma: self.gamma_avg,
378                theta: self.theta_avg,
379                vega: self.vega_avg,
380                rho: self.rho_avg,
381                implied_volatility: self.iv_avg,
382                theoretical_price: None,
383                underlying_price: self.underlying_price,
384            },
385        })
386    }
387}
388
389impl LseVaultClient {
390    /// Fetch one-minute candles for one option contract, paginating automatically.
391    ///
392    /// Same range contract as [`fetch_candles`](Self::fetch_candles): yields exactly the bars whose
393    /// [`close_time`](Candle::close_time) falls in `[start, end]` (**both inclusive**), ascending,
394    /// with the same ordering, resolution and range checks and the same errors. Minutes with no
395    /// print are absent rather than zero-filled.
396    ///
397    /// # Arguments
398    /// * `ticker` - The OSI contract symbol, e.g. `"SPY260922P00773000"`. Case-insensitive.
399    /// * `start` / `end` - Inclusive `close_time` bounds.
400    ///
401    /// # Errors
402    /// See [`fetch_candles`](Self::fetch_candles). A ticker the provider does not know yields an
403    /// empty stream, not an error. A row whose ticker is not an OSI symbol is an
404    /// [`LseError::Deserialize`].
405    #[must_use = "fetch_option_candles returns a lazy Stream that does nothing unless polled"]
406    pub fn fetch_option_candles<'a>(
407        &'a self,
408        ticker: &'a str,
409        start: DateTime<Utc>,
410        end: DateTime<Utc>,
411    ) -> impl Stream<Item = Result<LseOptionCandle, LseError>> + 'a {
412        self.candle_rows::<OptionCandleRow>(
413            "options/candles",
414            ticker,
415            vec![("ticker", ticker.to_owned())],
416            CandleInterval::Min1,
417            start,
418            end,
419        )
420        .map(|row| row.and_then(|(row, close_time)| row.into_option_candle(close_time)))
421    }
422
423    /// Fetch option candles into a `Vec`. **Buffers the whole range.**
424    ///
425    /// # Errors
426    /// Fails on the first error, discarding any candles already received. See
427    /// [`fetch_option_candles`](Self::fetch_option_candles).
428    pub async fn collect_option_candles(
429        &self,
430        ticker: &str,
431        start: DateTime<Utc>,
432        end: DateTime<Utc>,
433    ) -> Result<Vec<LseOptionCandle>, LseError> {
434        let stream = self.fetch_option_candles(ticker, start, end);
435        futures::pin_mut!(stream);
436
437        let mut candles = Vec::new();
438        while let Some(candle) = stream.next().await {
439            candles.push(candle?);
440        }
441
442        Ok(candles)
443    }
444
445    /// Fetch every option print in `[start, end)`, oldest first, walking the range automatically.
446    ///
447    /// # Range and order
448    /// Yields exactly the prints whose [`time`](LseOptionPrint::time) falls in `[start, end)` —
449    /// half-open, so adjacent ranges tile without overlap — ordered by time and then by
450    /// [`id`](LseOptionPrint::id). Sub-second bounds are honoured by trimming: the provider accepts
451    /// only whole seconds, so the requested range is widened to them and cut back here.
452    ///
453    /// # How the range is walked
454    /// The provider answers newest-first and silently truncates at a 5,000-row cap, with no offset
455    /// or cursor, so a range can only be read in windows small enough to come back whole. This
456    /// method walks forward in adaptive windows: a window that returns a full page is halved and
457    /// read again, and one that comes back sparse lets the next window grow. Each complete window is
458    /// emitted oldest-first, so memory holds at most one page. The extra requests fall only on
459    /// windows that hit the cap.
460    ///
461    /// A full page is the only sign of truncation, so the fetch first reads the provider's own
462    /// [`max_rows_per_request`](super::quota::QuotaStatus::max_rows_per_request) from
463    /// [`usage`](Self::usage) — one extra request per fetch — and treats the smaller of that and
464    /// [`with_page_limit`](Self::with_page_limit) as a full page. A page limit set above the
465    /// provider's cap therefore cannot hide a truncated window.
466    ///
467    /// # Known limitation
468    /// The walk trusts the reported cap to be the one `/options/flow` enforces. They agreed when
469    /// measured, and the live options canary asserts they still do; were the provider to enforce a
470    /// lower cap than it reports, a truncated window would read as complete.
471    ///
472    /// # Arguments
473    /// * `underlying` - Restrict to one underlying (case-insensitive), or `None` for every
474    ///   underlying the provider carries. The endpoint cannot filter by contract — it silently
475    ///   ignores a `ticker` parameter — so select contracts from the result.
476    /// * `start` / `end` - The half-open time range.
477    ///
478    /// # Errors
479    /// Each yielded item is a `Result`, and the stream ends after the first error.
480    /// - [`LseError::InvalidInput`] for an inverted range, or one ending within
481    ///   [`OPTION_FLOW_SETTLE_MARGIN`] of now: the provider can serve a recent window
482    ///   incomplete with a `200`, and returning it would hand back a short tape that looks final.
483    /// - [`LseError::OptionFlowWindowSaturated`] when a single second holds more prints than one
484    ///   page can carry, so the window cannot be narrowed further without losing some.
485    /// - [`LseError::UnexpectedOptionFlowRow`] when a row falls outside the window or underlying it
486    ///   was requested for — the provider ignoring a parameter, which it is known to do silently.
487    ///   Checked before any row of that window is yielded.
488    /// - On `429`, [`LseError::RateLimited`]; resume by re-calling with `start` set to the last
489    ///   print's time. Prints sharing that time are then yielded again; de-duplicate on
490    ///   [`id`](LseOptionPrint::id).
491    /// - Otherwise [`LseError::Api`] / [`Http`](LseError::Http) /
492    ///   [`Deserialize`](LseError::Deserialize) — the last also when the reported row cap is zero,
493    ///   or when a row's ticker is not an OSI symbol. Like the row checks above, that fails the
494    ///   whole window before any of it is yielded.
495    #[must_use = "fetch_option_flow returns a lazy Stream that does nothing unless polled"]
496    pub fn fetch_option_flow<'a>(
497        &'a self,
498        underlying: Option<&'a str>,
499        start: DateTime<Utc>,
500        end: DateTime<Utc>,
501    ) -> impl Stream<Item = Result<LseOptionPrint, LseError>> + 'a {
502        try_stream! {
503            if start > end {
504                Err(LseError::InvalidInput {
505                    message: format!("start ({start}) must not be after end ({end})"),
506                })?;
507            }
508
509            let settled_until = Utc::now() - OPTION_FLOW_SETTLE_MARGIN;
510            if end > settled_until {
511                Err(LseError::InvalidInput {
512                    message: format!(
513                        "end ({end}) is within the {}s settle margin of now: the provider can \
514                         serve a window that recent incomplete with no indication, so it is \
515                         refused rather than returned short (see OPTION_FLOW_SETTLE_MARGIN)",
516                        OPTION_FLOW_SETTLE_MARGIN.num_seconds()
517                    ),
518                })?;
519            }
520
521            // A full page is the only truncation signal, so the walk must know the size a full page
522            // has. The provider caps silently, and `page_limit` is only what this client ASKS for: a
523            // caller may raise it past the cap, and the provider may lower the cap under the
524            // default. Either way a truncated window would come back shorter than `page_limit`,
525            // read as complete, and silently lose its oldest prints. The provider's own figure is
526            // read once per fetch -- it is a static request-shaping limit, not a running total --
527            // and the smaller of the two is what a full page means here.
528            let reported = self.usage().await?.max_rows_per_request;
529            if reported == 0 {
530                Err(LseError::Deserialize {
531                    message: "usage reports max_rows_per_request = 0, which leaves no page size a \
532                              truncated option-flow window could be recognised by"
533                        .to_owned(),
534                })?;
535            }
536            let limit = self.page_limit().get().min(reported);
537            let cap = usize::try_from(limit).unwrap_or(usize::MAX);
538            let range_end = ceil_to_second(end);
539            let mut cursor = floor_to_second(start);
540            let mut window = INITIAL_FLOW_WINDOW;
541
542            while cursor < range_end {
543                let window_end = cursor
544                    .checked_add_signed(window)
545                    .map_or(range_end, |candidate| candidate.min(range_end));
546
547                let mut query = vec![
548                    ("start", cursor.format(WINDOW_FORMAT).to_string()),
549                    ("end", window_end.format(WINDOW_FORMAT).to_string()),
550                    ("limit", limit.to_string()),
551                ];
552                if let Some(underlying) = underlying {
553                    query.push(("underlying", underlying.to_owned()));
554                }
555
556                let rows: Vec<FlowRow> = self.get_json("options/flow", &query).await?;
557                debug!(
558                    ?underlying, %cursor, %window_end, rows = rows.len(),
559                    "vault option flow window received"
560                );
561
562                // A full page may be a truncated one: the cap is applied silently, and the rows it
563                // drops are the OLDEST in the window. Narrow and read the same window start again.
564                if rows.len() >= cap {
565                    let span = window_end - cursor;
566                    if span <= MIN_FLOW_WINDOW {
567                        Err(LseError::OptionFlowWindowSaturated {
568                            window_start: cursor,
569                            rows: rows.len(),
570                        })?;
571                    }
572                    // Halved in WHOLE seconds: every bound sent must be one the endpoint can express,
573                    // and an odd span halved exactly would put the next window's start mid-second.
574                    window = TimeDelta::seconds(span.num_seconds() / 2).max(MIN_FLOW_WINDOW);
575                    continue;
576                }
577
578                // Validate the whole window before yielding any of it, so an ignored parameter
579                // never leaks a partial window into the caller's tape.
580                let mut prints = Vec::with_capacity(rows.len());
581                for row in rows {
582                    let time = row.time()?;
583
584                    if time < cursor || time >= window_end {
585                        Err(LseError::UnexpectedOptionFlowRow {
586                            id: row.id,
587                            message: format!(
588                                "stamped {time}, outside the requested window [{cursor}, \
589                                 {window_end}): the range parameters appear to have been ignored"
590                            ),
591                        })?;
592                    }
593                    if let Some(underlying) = underlying
594                        && !row.underlying.eq_ignore_ascii_case(underlying)
595                    {
596                        Err(LseError::UnexpectedOptionFlowRow {
597                            id: row.id,
598                            message: format!(
599                                "underlying {:?} where {underlying:?} was requested: the \
600                                 underlying filter appears to have been ignored",
601                                row.underlying
602                            ),
603                        })?;
604                    }
605
606                    prints.push(row.into_print(time)?);
607                }
608
609                // The provider serves newest-first; sort rather than reverse, so the order promised
610                // does not rest on how the response happens to be sorted today.
611                prints.sort_unstable_by_key(|print| (print.time, print.id));
612                let received = prints.len();
613
614                for print in prints {
615                    if print.time >= start && print.time < end {
616                        yield print;
617                    }
618                }
619
620                cursor = window_end;
621                // Grow only out of a clearly sparse window, so the next one is unlikely to hit the
622                // cap and cost a retry.
623                if received < cap / 4 {
624                    window = (window * 2).min(MAX_FLOW_WINDOW);
625                }
626            }
627        }
628    }
629
630    /// Fetch option prints into a `Vec`, oldest first. **Buffers the whole range** — a full session
631    /// for one busy underlying is several hundred thousand prints; prefer the stream for long
632    /// ranges.
633    ///
634    /// # Errors
635    /// Fails on the first error, discarding any prints already received. See
636    /// [`fetch_option_flow`](Self::fetch_option_flow).
637    pub async fn collect_option_flow(
638        &self,
639        underlying: Option<&str>,
640        start: DateTime<Utc>,
641        end: DateTime<Utc>,
642    ) -> Result<Vec<LseOptionPrint>, LseError> {
643        let stream = self.fetch_option_flow(underlying, start, end);
644        futures::pin_mut!(stream);
645
646        let mut prints = Vec::new();
647        while let Some(print) = stream.next().await {
648            prints.push(print?);
649        }
650
651        Ok(prints)
652    }
653}
654
655/// Truncate to the whole second at or before `time`.
656fn floor_to_second(time: DateTime<Utc>) -> DateTime<Utc> {
657    time.with_nanosecond(0).unwrap_or(time)
658}
659
660/// Round up to the whole second at or after `time`, saturating at the representable maximum.
661fn ceil_to_second(time: DateTime<Utc>) -> DateTime<Utc> {
662    let floor = floor_to_second(time);
663    if floor == time {
664        time
665    } else {
666        floor
667            .checked_add_signed(TimeDelta::seconds(1))
668            .unwrap_or(time)
669    }
670}
671
672#[cfg(test)]
673#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
674mod tests {
675    use super::*;
676    use rust_decimal_macros::dec;
677
678    // Every value in these rows is invented. No row in this repository may be mistaken for provider
679    // data, which is not licensed for redistribution (<https://londonstrategicedge.com/terms>).
680    // Decoding is value-independent, so a synthetic row proves what a captured one would.
681
682    const FLOW_ROW: &str = r#"{"id":7,"ts":"2024-01-02 15:00:00.075000","underlying":"TEST",
683        "ticker":"TEST240105C00010500","strike":10.5,"expiry":"2024-01-05","contract_type":"call",
684        "last_price":1,"volume":3,"premium":300,"underlying_price":11.0,"dte":3,"iv":0.2,
685        "delta":0.5,"gamma":0.1,"theta":-0.01,"vega":0.02,"rho":0.003}"#;
686
687    #[test]
688    fn a_flow_row_decodes_integer_or_float_numbers_and_maps_every_greek() {
689        let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
690        let time = row.time().unwrap();
691        let print = row.into_print(time).unwrap();
692
693        assert_eq!(
694            time,
695            "2024-01-02T15:00:00.075Z".parse::<DateTime<Utc>>().unwrap()
696        );
697        assert_eq!(print.contract.kind, OptionKind::Call);
698        assert_eq!(print.contract.strike, dec!(10.5));
699        assert_eq!(
700            print.contract.expiry,
701            NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()
702        );
703        // `last_price` arrived as a JSON integer.
704        assert_eq!(print.price, dec!(1));
705        assert_eq!(print.premium, dec!(300));
706        assert_eq!(print.greeks.implied_volatility, Some(0.2));
707        assert_eq!(print.greeks.rho, Some(0.003));
708        assert_eq!(print.greeks.underlying_price, Some(11.0));
709        assert_eq!(print.greeks.theoretical_price, None);
710    }
711
712    #[test]
713    fn a_flow_row_with_null_greeks_decodes_them_as_none() {
714        let json = FLOW_ROW
715            .replace(r#""iv":0.2"#, r#""iv":null"#)
716            .replace(r#""delta":0.5"#, r#""delta":null"#)
717            .replace(r#""rho":0.003"#, r#""rho":null"#);
718        let row: FlowRow = serde_json::from_str(&json).unwrap();
719        let print = row.into_print(Utc::now()).unwrap();
720
721        assert_eq!(print.greeks.implied_volatility, None);
722        assert_eq!(print.greeks.delta, None);
723        assert_eq!(print.greeks.rho, None);
724    }
725
726    #[test]
727    fn a_print_is_a_public_trade_sized_in_contracts_with_no_side() {
728        let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
729        let trade = row.into_print(Utc::now()).unwrap().public_trade();
730
731        assert_eq!(trade.id, "7");
732        assert_eq!(trade.price, dec!(1));
733        assert_eq!(trade.amount, dec!(3));
734        assert_eq!(trade.side, None);
735    }
736
737    #[test]
738    fn an_option_candle_row_keeps_prints_as_trade_count_and_averages_as_greeks() {
739        let json = r#"{"ticker":"TEST240105P00010000","underlying":"TEST","strike":10,
740            "expiry":"2024-01-05","contract_type":"put","minute":"2024-01-02 15:00:00","dte":3,
741            "open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9,
742            "iv_avg":0.3,"delta_avg":-0.4,"gamma_avg":0.1,"theta_avg":-0.02,"vega_avg":0.05,
743            "rho_avg":-0.001,"underlying_price":10.25}"#;
744        let row: OptionCandleRow = serde_json::from_str(json).unwrap();
745        let open = row.open_time().unwrap();
746        let candle = row
747            .into_option_candle(open + TimeDelta::minutes(1))
748            .unwrap();
749
750        assert_eq!(candle.contract.kind, OptionKind::Put);
751        assert_eq!(candle.candle.trade_count, Some(9));
752        assert_eq!(candle.candle.volume, Some(dec!(40)));
753        assert_eq!(
754            candle.candle.close_time,
755            "2024-01-02T15:01:00Z".parse::<DateTime<Utc>>().unwrap()
756        );
757        assert_eq!(candle.greeks.delta, Some(-0.4));
758        assert_eq!(candle.greeks.rho, Some(-0.001));
759        assert_eq!(candle.greeks.underlying_price, Some(10.25));
760    }
761
762    #[test]
763    fn a_print_becomes_a_trade_then_its_greeks_stamped_at_the_print() {
764        let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
765        let time = row.time().unwrap();
766        let [trade, greeks] = row.into_print(time).unwrap().into_market_events(0usize);
767
768        for event in [&trade, &greeks] {
769            assert_eq!(event.exchange, ExchangeId::LseOptions);
770            assert_eq!(event.time_exchange, time);
771            assert_eq!(event.time_received, time);
772        }
773        assert!(matches!(trade.kind, DataKind::Trade(_)));
774        assert!(matches!(greeks.kind, DataKind::OptionGreeks(ref g) if g.rho == Some(0.003)));
775    }
776
777    #[test]
778    fn an_option_candle_is_stamped_at_its_close_so_the_minute_is_never_seen_early() {
779        let json = r#"{"ticker":"TEST240105P00010000","underlying":"TEST","strike":10,
780            "expiry":"2024-01-05","contract_type":"put","minute":"2024-01-02 15:00:00",
781            "open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9}"#;
782        let row: OptionCandleRow = serde_json::from_str(json).unwrap();
783        let close = row.open_time().unwrap() + TimeDelta::minutes(1);
784        let [bar, greeks] = row
785            .into_option_candle(close)
786            .unwrap()
787            .into_market_events(0usize);
788
789        assert_eq!(bar.time_exchange, close);
790        assert_eq!(greeks.time_exchange, close);
791        assert!(matches!(bar.kind, DataKind::Candle(c) if c.close_time == close));
792    }
793
794    /// The served `strike` is a float, and the provider has put float noise on it. The contract is
795    /// read from the ticker, so neither that noise nor a row field disagreeing with the ticker can
796    /// reach it.
797    #[test]
798    fn the_contract_is_read_from_the_ticker_not_the_row_fields() {
799        let json = FLOW_ROW
800            .replace(r#""strike":10.5"#, r#""strike":10.499999999999998"#)
801            .replace(r#""contract_type":"call""#, r#""contract_type":"put""#)
802            .replace(r#""expiry":"2024-01-05""#, r#""expiry":"2024-01-06""#);
803        let row: FlowRow = serde_json::from_str(&json).unwrap();
804        let contract = row.into_print(Utc::now()).unwrap().contract;
805
806        assert_eq!(contract.strike.to_string(), "10.5");
807        assert_eq!(contract.kind, OptionKind::Call);
808        assert_eq!(
809            contract.expiry,
810            NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()
811        );
812    }
813
814    #[test]
815    fn a_ticker_that_is_not_osi_is_a_typed_error() {
816        let json = FLOW_ROW.replace("TEST240105C00010500", "TEST");
817        let row: FlowRow = serde_json::from_str(&json).unwrap();
818        let error = row.into_print(Utc::now()).unwrap_err();
819
820        assert!(matches!(error, LseError::Deserialize { .. }));
821        assert!(error.to_string().contains("\"TEST\""), "{error}");
822    }
823
824    #[test]
825    fn a_candle_row_whose_ticker_is_not_osi_is_a_typed_error() {
826        let json = r#"{"ticker":"TEST","underlying":"TEST","minute":"2024-01-02 15:00:00",
827            "open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9}"#;
828        let row: OptionCandleRow = serde_json::from_str(json).unwrap();
829        let close = row.open_time().unwrap() + TimeDelta::minutes(1);
830
831        assert!(matches!(
832            row.into_option_candle(close),
833            Err(LseError::Deserialize { .. })
834        ));
835    }
836
837    #[test]
838    fn a_malformed_print_timestamp_is_a_typed_error() {
839        let json = FLOW_ROW.replace("2024-01-02 15:00:00.075000", "not-a-time");
840        let row: FlowRow = serde_json::from_str(&json).unwrap();
841
842        assert!(matches!(row.time(), Err(LseError::Deserialize { .. })));
843    }
844
845    #[test]
846    fn whole_second_rounding() {
847        let exact = "2024-01-02T15:00:00Z".parse::<DateTime<Utc>>().unwrap();
848        let fractional = "2024-01-02T15:00:00.25Z".parse::<DateTime<Utc>>().unwrap();
849
850        assert_eq!(floor_to_second(fractional), exact);
851        assert_eq!(ceil_to_second(fractional), exact + TimeDelta::seconds(1));
852        assert_eq!(ceil_to_second(exact), exact);
853    }
854}