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}