use std::fmt::Debug;
use nautilus_bybit::{
common::{
consts::{BYBIT_CLIENT_ID, BYBIT_VENUE},
enums::BybitProductType,
},
config::BybitDataClientConfig,
factories::BybitDataClientFactory,
};
use nautilus_common::{
actor::{DataActor, DataActorConfig, DataActorCore},
enums::Environment,
nautilus_actor,
timer::TimeEvent,
};
use nautilus_live::node::LiveNode;
use nautilus_model::{
data::option_chain::{OptionChainSlice, StrikeRange},
identifiers::{ClientId, InstrumentId, OptionSeriesId, TraderId},
instruments::{Instrument, any::InstrumentAny},
stubs::TestDefault,
};
use ustr::Ustr;
#[derive(Debug)]
struct OptionChainTester {
core: DataActorCore,
client_id: ClientId,
series_id: Option<OptionSeriesId>,
}
nautilus_actor!(OptionChainTester);
impl OptionChainTester {
fn new(client_id: ClientId) -> Self {
Self {
core: DataActorCore::new(DataActorConfig {
actor_id: Some("OPTION_CHAIN_TESTER-001".into()),
..Default::default()
}),
client_id,
series_id: None,
}
}
}
impl DataActor for OptionChainTester {
fn on_start(&mut self) -> anyhow::Result<()> {
let venue = *BYBIT_VENUE;
let underlying_filter = Ustr::from("BTC");
let now_ns = self.clock().timestamp_ns().as_u64();
let options: Vec<(InstrumentId, Ustr, Ustr, u64)> = {
let cache = self.cache();
let instruments = cache.instruments(&venue, Some(&underlying_filter));
instruments
.iter()
.filter_map(|inst| {
if let InstrumentAny::CryptoOption(opt) = inst {
let expiry = inst.expiration_ns()?.as_u64();
if expiry <= now_ns {
return None;
}
Some((
inst.id(),
underlying_filter,
opt.settlement_currency.code,
expiry,
))
} else {
None
}
})
.collect()
};
if options.is_empty() {
log::warn!("No BTC options found in cache");
return Ok(());
}
let nearest_expiry = options.iter().map(|(_, _, _, exp)| *exp).min().unwrap();
let settlement_currency = options
.iter()
.find(|(_, _, settlement, exp)| *exp == nearest_expiry && settlement.as_str() == "USDT")
.map_or_else(
|| {
options
.iter()
.find(|(_, _, _, exp)| *exp == nearest_expiry)
.unwrap()
.2
},
|(_, _, s, _)| *s,
);
let count = options
.iter()
.filter(|(_, _, s, exp)| *exp == nearest_expiry && *s == settlement_currency)
.count();
log::info!(
"Found {count} BTC options at nearest expiry (ts={nearest_expiry}, settlement={settlement_currency})"
);
let series_id = OptionSeriesId::new(
venue,
underlying_filter,
settlement_currency,
nautilus_core::UnixNanos::from(nearest_expiry),
);
log::info!("Subscribing to option chain: {series_id}");
let strike_range = StrikeRange::AtmRelative {
strikes_above: 3,
strikes_below: 3,
};
let snapshot_interval_ms = Some(5_000);
let client_id = self.client_id;
self.subscribe_option_chain(
series_id,
strike_range,
snapshot_interval_ms,
Some(client_id),
None,
);
self.series_id = Some(series_id);
Ok(())
}
fn on_option_chain(&mut self, slice: &OptionChainSlice) -> anyhow::Result<()> {
log::info!(
"OPTION_CHAIN | {} | atm={} | calls={} puts={} | strikes={}",
slice.series_id,
slice.atm_strike.map_or("-".to_string(), |p| format!("{p}")),
slice.call_count(),
slice.put_count(),
slice.strike_count(),
);
for strike in slice.strikes() {
let call_info = slice.get_call(&strike).map(|d| {
let greeks_str = d.greeks.as_ref().map_or("-".to_string(), |g| {
format!(
"d={:.3} g={:.5} v={:.2} iv={:.1}%",
g.delta,
g.gamma,
g.vega,
g.mark_iv.unwrap_or(0.0) * 100.0
)
});
format!(
"bid={} ask={} [{}]",
d.quote.bid_price, d.quote.ask_price, greeks_str
)
});
let put_info = slice.get_put(&strike).map(|d| {
let greeks_str = d.greeks.as_ref().map_or("-".to_string(), |g| {
format!(
"d={:.3} g={:.5} v={:.2} iv={:.1}%",
g.delta,
g.gamma,
g.vega,
g.mark_iv.unwrap_or(0.0) * 100.0
)
});
format!(
"bid={} ask={} [{}]",
d.quote.bid_price, d.quote.ask_price, greeks_str
)
});
log::info!(
" K={} | CALL: {} | PUT: {}",
strike,
call_info.unwrap_or_else(|| "-".to_string()),
put_info.unwrap_or_else(|| "-".to_string()),
);
}
Ok(())
}
fn on_stop(&mut self) -> anyhow::Result<()> {
if let Some(series_id) = self.series_id.take() {
let client_id = self.client_id;
self.unsubscribe_option_chain(series_id, Some(client_id));
log::info!("Unsubscribed from option chain {series_id}");
}
Ok(())
}
fn on_time_event(&mut self, _event: &TimeEvent) -> anyhow::Result<()> {
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let environment = Environment::Live;
let trader_id = TraderId::test_default();
let client_id = *BYBIT_CLIENT_ID;
let bybit_config = BybitDataClientConfig {
api_key: None, api_secret: None, product_types: vec![BybitProductType::Option],
..Default::default()
};
let client_factory = BybitDataClientFactory::new();
let mut node = LiveNode::builder(trader_id, environment)?
.with_name("BYBIT-OPTION-CHAIN-TESTER-001".to_string())
.add_data_client(None, Box::new(client_factory), Box::new(bybit_config))?
.with_delay_post_stop_secs(5)
.build()?;
let tester = OptionChainTester::new(client_id);
node.add_actor(tester)?;
node.run().await?;
Ok(())
}