use crate::event::{DataKind, MarketEvent};
use crate::exchange::lse::PROVIDER_TIMESTAMP_FORMAT;
use crate::exchange::lse::error::LseError;
use crate::exchange::lse::historical::{VaultCandleRow, parse_candle_open};
use crate::exchange::lse::vault::LseVaultClient;
use crate::exchange::osi;
use crate::subscription::{
candle::{Candle, CandleInterval},
greeks::OptionGreeks,
trade::PublicTrade,
};
use async_stream::try_stream;
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeDelta, Timelike, Utc};
use futures::{Stream, StreamExt};
use rust_decimal::Decimal;
use rustrade_instrument::{exchange::ExchangeId, instrument::kind::option::OptionKind};
use serde::{Deserialize, Serialize};
use smol_str::{SmolStr, ToSmolStr};
use tracing::debug;
pub const OPTION_FLOW_SETTLE_MARGIN: TimeDelta = TimeDelta::seconds(60);
const WINDOW_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
const INITIAL_FLOW_WINDOW: TimeDelta = TimeDelta::seconds(60);
const MAX_FLOW_WINDOW: TimeDelta = TimeDelta::days(1);
const MIN_FLOW_WINDOW: TimeDelta = TimeDelta::seconds(1);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LseOptionContract {
pub ticker: SmolStr,
pub underlying: SmolStr,
pub kind: OptionKind,
pub strike: Decimal,
pub expiry: NaiveDate,
}
impl LseOptionContract {
fn from_row(ticker: SmolStr, underlying: SmolStr) -> Result<Self, LseError> {
let parsed = osi::parse(&ticker).ok_or_else(|| LseError::Deserialize {
message: format!(
"option ticker {ticker:?} is not an OSI contract symbol with a calendar expiry \
and a positive strike"
),
})?;
let (kind, strike, expiry) = (parsed.kind, parsed.strike, parsed.expiry);
Ok(Self {
ticker,
underlying,
kind,
strike,
expiry,
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LseOptionPrint {
pub id: u64,
pub time: DateTime<Utc>,
pub contract: LseOptionContract,
pub price: Decimal,
pub volume: u64,
pub premium: Decimal,
pub greeks: OptionGreeks,
}
impl LseOptionPrint {
#[must_use]
pub fn public_trade(&self) -> PublicTrade {
PublicTrade {
id: self.id.to_smolstr(),
price: self.price,
amount: Decimal::from(self.volume),
side: None,
}
}
#[must_use]
pub fn into_market_events<InstrumentKey: Clone>(
self,
instrument: InstrumentKey,
) -> [MarketEvent<InstrumentKey, DataKind>; 2] {
let trade = self.public_trade();
[
option_event(self.time, instrument.clone(), DataKind::Trade(trade)),
option_event(self.time, instrument, DataKind::OptionGreeks(self.greeks)),
]
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LseOptionCandle {
pub contract: LseOptionContract,
pub candle: Candle,
pub premium: Decimal,
pub greeks: OptionGreeks,
}
#[derive(Debug, Deserialize)]
struct FlowRow {
id: u64,
ts: String,
underlying: SmolStr,
ticker: SmolStr,
last_price: Decimal,
volume: u64,
premium: Decimal,
#[serde(default)]
underlying_price: Option<f64>,
#[serde(default)]
iv: Option<f64>,
#[serde(default)]
delta: Option<f64>,
#[serde(default)]
gamma: Option<f64>,
#[serde(default)]
theta: Option<f64>,
#[serde(default)]
vega: Option<f64>,
#[serde(default)]
rho: Option<f64>,
}
impl FlowRow {
fn time(&self) -> Result<DateTime<Utc>, LseError> {
NaiveDateTime::parse_from_str(&self.ts, PROVIDER_TIMESTAMP_FORMAT)
.map(|naive| naive.and_utc())
.map_err(|error| LseError::Deserialize {
message: format!("invalid option print timestamp {:?}: {error}", self.ts),
})
}
fn into_print(self, time: DateTime<Utc>) -> Result<LseOptionPrint, LseError> {
Ok(LseOptionPrint {
id: self.id,
time,
contract: LseOptionContract::from_row(self.ticker, self.underlying)?,
price: self.last_price,
volume: self.volume,
premium: self.premium,
greeks: OptionGreeks {
delta: self.delta,
gamma: self.gamma,
theta: self.theta,
vega: self.vega,
rho: self.rho,
implied_volatility: self.iv,
theoretical_price: None,
underlying_price: self.underlying_price,
},
})
}
}
#[derive(Debug, Deserialize)]
struct OptionCandleRow {
ticker: SmolStr,
underlying: SmolStr,
minute: String,
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: u64,
premium: Decimal,
print_count: u64,
#[serde(default)]
underlying_price: Option<f64>,
#[serde(default)]
iv_avg: Option<f64>,
#[serde(default)]
delta_avg: Option<f64>,
#[serde(default)]
gamma_avg: Option<f64>,
#[serde(default)]
theta_avg: Option<f64>,
#[serde(default)]
vega_avg: Option<f64>,
#[serde(default)]
rho_avg: Option<f64>,
}
impl VaultCandleRow for OptionCandleRow {
fn open_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_candle_open(&self.minute)
}
}
impl LseOptionCandle {
#[must_use]
pub fn into_market_events<InstrumentKey: Clone>(
self,
instrument: InstrumentKey,
) -> [MarketEvent<InstrumentKey, DataKind>; 2] {
let time = self.candle.close_time;
[
option_event(time, instrument.clone(), DataKind::Candle(self.candle)),
option_event(time, instrument, DataKind::OptionGreeks(self.greeks)),
]
}
}
fn option_event<InstrumentKey>(
time: DateTime<Utc>,
instrument: InstrumentKey,
kind: DataKind,
) -> MarketEvent<InstrumentKey, DataKind> {
MarketEvent {
time_exchange: time,
time_received: time,
exchange: ExchangeId::LseOptions,
instrument,
kind,
}
}
impl OptionCandleRow {
fn into_option_candle(self, close_time: DateTime<Utc>) -> Result<LseOptionCandle, LseError> {
Ok(LseOptionCandle {
contract: LseOptionContract::from_row(self.ticker, self.underlying)?,
candle: Candle {
close_time,
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: Some(Decimal::from(self.volume)),
trade_count: Some(self.print_count),
},
premium: self.premium,
greeks: OptionGreeks {
delta: self.delta_avg,
gamma: self.gamma_avg,
theta: self.theta_avg,
vega: self.vega_avg,
rho: self.rho_avg,
implied_volatility: self.iv_avg,
theoretical_price: None,
underlying_price: self.underlying_price,
},
})
}
}
impl LseVaultClient {
#[must_use = "fetch_option_candles returns a lazy Stream that does nothing unless polled"]
pub fn fetch_option_candles<'a>(
&'a self,
ticker: &'a str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> impl Stream<Item = Result<LseOptionCandle, LseError>> + 'a {
self.candle_rows::<OptionCandleRow>(
"options/candles",
ticker,
vec![("ticker", ticker.to_owned())],
CandleInterval::Min1,
start,
end,
)
.map(|row| row.and_then(|(row, close_time)| row.into_option_candle(close_time)))
}
pub async fn collect_option_candles(
&self,
ticker: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<LseOptionCandle>, LseError> {
let stream = self.fetch_option_candles(ticker, start, end);
futures::pin_mut!(stream);
let mut candles = Vec::new();
while let Some(candle) = stream.next().await {
candles.push(candle?);
}
Ok(candles)
}
#[must_use = "fetch_option_flow returns a lazy Stream that does nothing unless polled"]
pub fn fetch_option_flow<'a>(
&'a self,
underlying: Option<&'a str>,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> impl Stream<Item = Result<LseOptionPrint, LseError>> + 'a {
try_stream! {
if start > end {
Err(LseError::InvalidInput {
message: format!("start ({start}) must not be after end ({end})"),
})?;
}
let settled_until = Utc::now() - OPTION_FLOW_SETTLE_MARGIN;
if end > settled_until {
Err(LseError::InvalidInput {
message: format!(
"end ({end}) is within the {}s settle margin of now: the provider can \
serve a window that recent incomplete with no indication, so it is \
refused rather than returned short (see OPTION_FLOW_SETTLE_MARGIN)",
OPTION_FLOW_SETTLE_MARGIN.num_seconds()
),
})?;
}
let reported = self.usage().await?.max_rows_per_request;
if reported == 0 {
Err(LseError::Deserialize {
message: "usage reports max_rows_per_request = 0, which leaves no page size a \
truncated option-flow window could be recognised by"
.to_owned(),
})?;
}
let limit = self.page_limit().get().min(reported);
let cap = usize::try_from(limit).unwrap_or(usize::MAX);
let range_end = ceil_to_second(end);
let mut cursor = floor_to_second(start);
let mut window = INITIAL_FLOW_WINDOW;
while cursor < range_end {
let window_end = cursor
.checked_add_signed(window)
.map_or(range_end, |candidate| candidate.min(range_end));
let mut query = vec![
("start", cursor.format(WINDOW_FORMAT).to_string()),
("end", window_end.format(WINDOW_FORMAT).to_string()),
("limit", limit.to_string()),
];
if let Some(underlying) = underlying {
query.push(("underlying", underlying.to_owned()));
}
let rows: Vec<FlowRow> = self.get_json("options/flow", &query).await?;
debug!(
?underlying, %cursor, %window_end, rows = rows.len(),
"vault option flow window received"
);
if rows.len() >= cap {
let span = window_end - cursor;
if span <= MIN_FLOW_WINDOW {
Err(LseError::OptionFlowWindowSaturated {
window_start: cursor,
rows: rows.len(),
})?;
}
window = TimeDelta::seconds(span.num_seconds() / 2).max(MIN_FLOW_WINDOW);
continue;
}
let mut prints = Vec::with_capacity(rows.len());
for row in rows {
let time = row.time()?;
if time < cursor || time >= window_end {
Err(LseError::UnexpectedOptionFlowRow {
id: row.id,
message: format!(
"stamped {time}, outside the requested window [{cursor}, \
{window_end}): the range parameters appear to have been ignored"
),
})?;
}
if let Some(underlying) = underlying
&& !row.underlying.eq_ignore_ascii_case(underlying)
{
Err(LseError::UnexpectedOptionFlowRow {
id: row.id,
message: format!(
"underlying {:?} where {underlying:?} was requested: the \
underlying filter appears to have been ignored",
row.underlying
),
})?;
}
prints.push(row.into_print(time)?);
}
prints.sort_unstable_by_key(|print| (print.time, print.id));
let received = prints.len();
for print in prints {
if print.time >= start && print.time < end {
yield print;
}
}
cursor = window_end;
if received < cap / 4 {
window = (window * 2).min(MAX_FLOW_WINDOW);
}
}
}
}
pub async fn collect_option_flow(
&self,
underlying: Option<&str>,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<LseOptionPrint>, LseError> {
let stream = self.fetch_option_flow(underlying, start, end);
futures::pin_mut!(stream);
let mut prints = Vec::new();
while let Some(print) = stream.next().await {
prints.push(print?);
}
Ok(prints)
}
}
fn floor_to_second(time: DateTime<Utc>) -> DateTime<Utc> {
time.with_nanosecond(0).unwrap_or(time)
}
fn ceil_to_second(time: DateTime<Utc>) -> DateTime<Utc> {
let floor = floor_to_second(time);
if floor == time {
time
} else {
floor
.checked_add_signed(TimeDelta::seconds(1))
.unwrap_or(time)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
const FLOW_ROW: &str = r#"{"id":7,"ts":"2024-01-02 15:00:00.075000","underlying":"TEST",
"ticker":"TEST240105C00010500","strike":10.5,"expiry":"2024-01-05","contract_type":"call",
"last_price":1,"volume":3,"premium":300,"underlying_price":11.0,"dte":3,"iv":0.2,
"delta":0.5,"gamma":0.1,"theta":-0.01,"vega":0.02,"rho":0.003}"#;
#[test]
fn a_flow_row_decodes_integer_or_float_numbers_and_maps_every_greek() {
let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
let time = row.time().unwrap();
let print = row.into_print(time).unwrap();
assert_eq!(
time,
"2024-01-02T15:00:00.075Z".parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(print.contract.kind, OptionKind::Call);
assert_eq!(print.contract.strike, dec!(10.5));
assert_eq!(
print.contract.expiry,
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()
);
assert_eq!(print.price, dec!(1));
assert_eq!(print.premium, dec!(300));
assert_eq!(print.greeks.implied_volatility, Some(0.2));
assert_eq!(print.greeks.rho, Some(0.003));
assert_eq!(print.greeks.underlying_price, Some(11.0));
assert_eq!(print.greeks.theoretical_price, None);
}
#[test]
fn a_flow_row_with_null_greeks_decodes_them_as_none() {
let json = FLOW_ROW
.replace(r#""iv":0.2"#, r#""iv":null"#)
.replace(r#""delta":0.5"#, r#""delta":null"#)
.replace(r#""rho":0.003"#, r#""rho":null"#);
let row: FlowRow = serde_json::from_str(&json).unwrap();
let print = row.into_print(Utc::now()).unwrap();
assert_eq!(print.greeks.implied_volatility, None);
assert_eq!(print.greeks.delta, None);
assert_eq!(print.greeks.rho, None);
}
#[test]
fn a_print_is_a_public_trade_sized_in_contracts_with_no_side() {
let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
let trade = row.into_print(Utc::now()).unwrap().public_trade();
assert_eq!(trade.id, "7");
assert_eq!(trade.price, dec!(1));
assert_eq!(trade.amount, dec!(3));
assert_eq!(trade.side, None);
}
#[test]
fn an_option_candle_row_keeps_prints_as_trade_count_and_averages_as_greeks() {
let json = r#"{"ticker":"TEST240105P00010000","underlying":"TEST","strike":10,
"expiry":"2024-01-05","contract_type":"put","minute":"2024-01-02 15:00:00","dte":3,
"open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9,
"iv_avg":0.3,"delta_avg":-0.4,"gamma_avg":0.1,"theta_avg":-0.02,"vega_avg":0.05,
"rho_avg":-0.001,"underlying_price":10.25}"#;
let row: OptionCandleRow = serde_json::from_str(json).unwrap();
let open = row.open_time().unwrap();
let candle = row
.into_option_candle(open + TimeDelta::minutes(1))
.unwrap();
assert_eq!(candle.contract.kind, OptionKind::Put);
assert_eq!(candle.candle.trade_count, Some(9));
assert_eq!(candle.candle.volume, Some(dec!(40)));
assert_eq!(
candle.candle.close_time,
"2024-01-02T15:01:00Z".parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(candle.greeks.delta, Some(-0.4));
assert_eq!(candle.greeks.rho, Some(-0.001));
assert_eq!(candle.greeks.underlying_price, Some(10.25));
}
#[test]
fn a_print_becomes_a_trade_then_its_greeks_stamped_at_the_print() {
let row: FlowRow = serde_json::from_str(FLOW_ROW).unwrap();
let time = row.time().unwrap();
let [trade, greeks] = row.into_print(time).unwrap().into_market_events(0usize);
for event in [&trade, &greeks] {
assert_eq!(event.exchange, ExchangeId::LseOptions);
assert_eq!(event.time_exchange, time);
assert_eq!(event.time_received, time);
}
assert!(matches!(trade.kind, DataKind::Trade(_)));
assert!(matches!(greeks.kind, DataKind::OptionGreeks(ref g) if g.rho == Some(0.003)));
}
#[test]
fn an_option_candle_is_stamped_at_its_close_so_the_minute_is_never_seen_early() {
let json = r#"{"ticker":"TEST240105P00010000","underlying":"TEST","strike":10,
"expiry":"2024-01-05","contract_type":"put","minute":"2024-01-02 15:00:00",
"open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9}"#;
let row: OptionCandleRow = serde_json::from_str(json).unwrap();
let close = row.open_time().unwrap() + TimeDelta::minutes(1);
let [bar, greeks] = row
.into_option_candle(close)
.unwrap()
.into_market_events(0usize);
assert_eq!(bar.time_exchange, close);
assert_eq!(greeks.time_exchange, close);
assert!(matches!(bar.kind, DataKind::Candle(c) if c.close_time == close));
}
#[test]
fn the_contract_is_read_from_the_ticker_not_the_row_fields() {
let json = FLOW_ROW
.replace(r#""strike":10.5"#, r#""strike":10.499999999999998"#)
.replace(r#""contract_type":"call""#, r#""contract_type":"put""#)
.replace(r#""expiry":"2024-01-05""#, r#""expiry":"2024-01-06""#);
let row: FlowRow = serde_json::from_str(&json).unwrap();
let contract = row.into_print(Utc::now()).unwrap().contract;
assert_eq!(contract.strike.to_string(), "10.5");
assert_eq!(contract.kind, OptionKind::Call);
assert_eq!(
contract.expiry,
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()
);
}
#[test]
fn a_ticker_that_is_not_osi_is_a_typed_error() {
let json = FLOW_ROW.replace("TEST240105C00010500", "TEST");
let row: FlowRow = serde_json::from_str(&json).unwrap();
let error = row.into_print(Utc::now()).unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("\"TEST\""), "{error}");
}
#[test]
fn a_candle_row_whose_ticker_is_not_osi_is_a_typed_error() {
let json = r#"{"ticker":"TEST","underlying":"TEST","minute":"2024-01-02 15:00:00",
"open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":40,"premium":6000,"print_count":9}"#;
let row: OptionCandleRow = serde_json::from_str(json).unwrap();
let close = row.open_time().unwrap() + TimeDelta::minutes(1);
assert!(matches!(
row.into_option_candle(close),
Err(LseError::Deserialize { .. })
));
}
#[test]
fn a_malformed_print_timestamp_is_a_typed_error() {
let json = FLOW_ROW.replace("2024-01-02 15:00:00.075000", "not-a-time");
let row: FlowRow = serde_json::from_str(&json).unwrap();
assert!(matches!(row.time(), Err(LseError::Deserialize { .. })));
}
#[test]
fn whole_second_rounding() {
let exact = "2024-01-02T15:00:00Z".parse::<DateTime<Utc>>().unwrap();
let fractional = "2024-01-02T15:00:00.25Z".parse::<DateTime<Utc>>().unwrap();
assert_eq!(floor_to_second(fractional), exact);
assert_eq!(ceil_to_second(fractional), exact + TimeDelta::seconds(1));
assert_eq!(ceil_to_second(exact), exact);
}
}