use chrono::{DateTime, Datelike, Utc};
use crossterm::event::KeyEvent;
use optionstratlib::OptionStyle;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Decimal, Positive};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Cell, Paragraph, Row, Table};
use crate::app::keymap::{ChainAction, KeyChord, resolve_chain};
use crate::app::{LegFocus, LiveState, ScreenLoad};
use crate::chain::{
ChainStore, GreeksOrigin, InstrumentKey, LegGreeks, MIN_PLAUSIBLE_LOCAL_IV, StreamHealth,
TickDir,
};
use crate::event::AppEvent;
use crate::ui::theme::{
GreekColumn, GreekColumns, StrikeRelation, Theme, greek_columns_for_slots, health_span,
sanitize, spinner_frame, strike_relation_marker_span, tick_dir_span,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LegView {
pub bid: Option<Positive>,
pub ask: Option<Positive>,
pub mark: Option<Positive>,
pub iv: Option<Positive>,
pub iv_origin: GreeksOrigin,
pub delta: Option<Decimal>,
pub delta_origin: GreeksOrigin,
pub gamma: Option<Decimal>,
pub gamma_origin: GreeksOrigin,
pub theta: Option<Decimal>,
pub vega: Option<Decimal>,
pub greeks_origin: GreeksOrigin,
pub bid_dir: TickDir,
pub ask_dir: TickDir,
}
impl LegView {
#[must_use]
fn greek_is_local(&self, greek: GreekColumn) -> bool {
match greek {
GreekColumn::Delta => {
self.delta.is_some() && matches!(self.delta_origin, GreeksOrigin::ComputedLocally)
}
GreekColumn::Gamma => {
self.gamma.is_some() && matches!(self.gamma_origin, GreeksOrigin::ComputedLocally)
}
GreekColumn::Theta => self.theta.is_some(),
GreekColumn::Vega => self.vega.is_some(),
}
}
#[must_use]
fn iv_is_local(&self) -> bool {
self.iv.is_some() && matches!(self.iv_origin, GreeksOrigin::ComputedLocally)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChainRow {
pub strike: Positive,
pub call: LegView,
pub put: LegView,
pub strike_relation: StrikeRelation,
}
#[must_use]
fn project_call(
od: &OptionData,
leg: Option<&LegGreeks>,
bid_dir: TickDir,
ask_dir: TickDir,
) -> LegView {
resolve_leg(
od.call_bid,
od.call_ask,
od.call_middle,
od.delta_call,
leg,
(bid_dir, ask_dir),
)
}
#[must_use]
fn project_put(
od: &OptionData,
leg: Option<&LegGreeks>,
bid_dir: TickDir,
ask_dir: TickDir,
) -> LegView {
resolve_leg(
od.put_bid,
od.put_ask,
od.put_middle,
od.delta_put,
leg,
(bid_dir, ask_dir),
)
}
#[must_use]
fn resolve_leg(
bid: Option<Positive>,
ask: Option<Positive>,
mark: Option<Positive>,
venue_delta: Option<Decimal>,
leg: Option<&LegGreeks>,
dirs: (TickDir, TickDir),
) -> LegView {
let (bid_dir, ask_dir) = dirs;
let (delta, delta_origin) = match venue_delta {
Some(value) => (Some(value), GreeksOrigin::Provider),
None => match leg.and_then(|g| g.delta) {
Some(value) => (Some(value), GreeksOrigin::ComputedLocally),
None => (None, GreeksOrigin::Provider),
},
};
let (iv, iv_origin) = resolve_iv(leg);
let (gamma, gamma_origin) = match leg.and_then(|g| g.gamma.map(|value| (value, g.gamma_origin)))
{
Some((value, origin)) => (Some(value), origin),
None => (None, GreeksOrigin::Provider),
};
let theta = leg.and_then(|g| g.theta);
let vega = leg.and_then(|g| g.vega);
let delta_local = delta.is_some() && matches!(delta_origin, GreeksOrigin::ComputedLocally);
let iv_local = iv.is_some() && matches!(iv_origin, GreeksOrigin::ComputedLocally);
let gamma_local = gamma.is_some() && matches!(gamma_origin, GreeksOrigin::ComputedLocally);
let any_local = delta_local || iv_local || gamma_local || theta.is_some() || vega.is_some();
let greeks_origin = if any_local {
GreeksOrigin::ComputedLocally
} else {
GreeksOrigin::Provider
};
LegView {
bid,
ask,
mark,
iv,
iv_origin,
delta,
delta_origin,
gamma,
gamma_origin,
theta,
vega,
greeks_origin,
bid_dir,
ask_dir,
}
}
#[must_use]
fn resolve_iv(leg: Option<&LegGreeks>) -> (Option<Positive>, GreeksOrigin) {
if let Some((value, origin)) = leg.and_then(|g| g.iv.map(|v| (v, g.iv_origin))) {
match origin {
GreeksOrigin::Provider => {
if let Some(iv) = project_iv(value) {
return (Some(iv), GreeksOrigin::Provider);
}
}
GreeksOrigin::ComputedLocally => {
return (project_local_iv(value), GreeksOrigin::ComputedLocally);
}
}
}
(None, GreeksOrigin::Provider)
}
#[must_use]
fn project_iv(iv: Positive) -> Option<Positive> {
if iv.is_zero() { None } else { Some(iv) }
}
#[must_use]
fn project_local_iv(iv: Positive) -> Option<Positive> {
match project_iv(iv) {
Some(value) if value.to_dec() >= MIN_PLAUSIBLE_LOCAL_IV => Some(value),
_ => None,
}
}
#[must_use]
fn project_row(
od: &OptionData,
spot: Positive,
store: &ChainStore,
underlying: &str,
expiration: DateTime<Utc>,
as_of: Option<DateTime<Utc>>,
) -> ChainRow {
let strike = od.strike_price;
let call_key = leg_key(underlying, expiration, strike, OptionStyle::Call);
let put_key = leg_key(underlying, expiration, strike, OptionStyle::Put);
let (call_bid_dir, call_ask_dir) = leg_dirs(store, &call_key, as_of);
let (put_bid_dir, put_ask_dir) = leg_dirs(store, &put_key, as_of);
ChainRow {
strike,
call: project_call(od, store.leg_greeks(&call_key), call_bid_dir, call_ask_dir),
put: project_put(od, store.leg_greeks(&put_key), put_bid_dir, put_ask_dir),
strike_relation: StrikeRelation::classify(strike, spot),
}
}
#[must_use]
fn leg_key(
underlying: &str,
expiration: DateTime<Utc>,
strike: Positive,
style: OptionStyle,
) -> InstrumentKey {
InstrumentKey {
underlying: underlying.to_owned(),
expiration_utc: expiration,
strike,
style,
}
}
#[must_use]
fn leg_dirs(
store: &ChainStore,
key: &InstrumentKey,
as_of: Option<DateTime<Utc>>,
) -> (TickDir, TickDir) {
let Some(now) = as_of else {
return (TickDir::Flat, TickDir::Flat);
};
(store.bid_dir(key, now), store.ask_dir(key, now))
}
pub fn draw(
state: &LiveState,
frame: &mut Frame,
area: Rect,
theme: Theme,
tick: u64,
now: DateTime<Utc>,
) {
let chain = state.store.chain();
match &state.load {
ScreenLoad::Loading => {
draw_state_body(
frame,
area,
theme,
Text::from(vec![
Line::from(Span::styled(
format!(
"{} connecting to {}…",
spinner_frame(tick),
sanitize(state.source.provider.as_str())
),
theme.accent(),
)),
Line::from(Span::styled("waiting for the first chain", theme.dim())),
]),
);
}
ScreenLoad::Error { message } => {
draw_state_body(
frame,
area,
theme,
Text::from(vec![
Line::from(Span::styled(
format!("! {}", sanitize(message)),
theme.warning(),
)),
Line::from(Span::styled("press r to reconnect", theme.dim())),
]),
);
}
ScreenLoad::Ready => {
if chain.options.is_empty() {
draw_state_body(
frame,
area,
theme,
Text::from(vec![
Line::from(Span::styled(
format!(
"no data for {} {}",
sanitize(&chain.symbol),
sanitize(&chain.get_expiration_date())
),
theme.dim(),
)),
Line::from(Span::styled(
"no strikes yet - press r to reconnect",
theme.dim(),
)),
]),
);
} else {
draw_matrix(state, frame, area, theme, now);
}
}
}
}
fn draw_state_body(frame: &mut Frame, area: Rect, theme: Theme, text: Text<'static>) {
let block = Block::bordered().title(Span::styled("Chain", theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
let height = u16::try_from(text.height())
.unwrap_or(u16::MAX)
.min(inner.height);
let [centered] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(inner);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), centered);
}
const NUM_W: u16 = 8;
const STRIKE_W: u16 = 10;
const BASE_W: u16 = 100;
const SLOT_W: u16 = 18;
fn draw_matrix(state: &LiveState, frame: &mut Frame, area: Rect, theme: Theme, now: DateTime<Utc>) {
let store = &state.store;
let chain = store.chain();
let spot = chain.underlying_price;
let health = store.health();
let stale = !matches!(health, StreamHealth::Live);
let as_of = Some(now);
let key = store.chain_key();
let underlying = key.1.clone();
let expiration = key.2;
let block = Block::bordered().title(matrix_title(chain, expiration, health, theme));
let inner = block.inner(area);
if inner.width < BASE_W {
draw_state_body(
frame,
area,
theme,
Text::from(vec![
Line::from(Span::styled("chain needs a wider terminal", theme.dim())),
Line::from(""),
Line::from(Span::styled(
format!("widen to at least {} cols", BASE_W + 2),
theme.dim(),
)),
]),
);
return;
}
let greek_cols = greek_columns_for_slots(greek_slots_for_width(inner.width));
let plan = columns(greek_cols);
let widths: Vec<Constraint> = plan.iter().map(|col| col_width(*col)).collect();
let visible = floor_sub(usize::from(inner.height), 2);
let strikes: Vec<&OptionData> = chain.options.iter().collect();
let len = strikes.len();
let atm = state.atm_index();
let anchor = clamp_anchor(state.selection.focused_row, atm, len);
let selected = state
.selection
.focused_row
.and_then(|row| (row < len).then_some(row));
let start = window_start(anchor.unwrap_or(0), visible, len);
let header = header_row(&plan, theme);
let mut rows: Vec<Row> = Vec::with_capacity(visible.min(len));
for (idx, od) in strikes.iter().enumerate().skip(start).take(visible) {
let od: &OptionData = od;
let row = project_row(od, spot, store, &underlying, expiration, as_of);
let is_atm = atm == Some(idx);
let is_selected = selected == Some(idx);
let cells: Vec<Cell> = plan
.iter()
.map(|col| {
col_cell(
*col,
&row,
theme,
is_selected,
state.selection.focused_leg,
is_atm,
)
})
.collect();
let mut table_row = Row::new(cells);
if is_selected {
table_row = table_row.style(Style::new().add_modifier(Modifier::BOLD));
}
rows.push(table_row);
}
let mut table = Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(1);
if stale {
table = table.style(theme.dim());
}
frame.render_widget(table, area);
}
#[must_use]
fn matrix_title(
chain: &OptionChain,
expiration: DateTime<Utc>,
health: &StreamHealth,
theme: Theme,
) -> Line<'static> {
let mut spans = vec![
Span::styled(sanitize(&chain.symbol), theme.accent()),
Span::raw(format!(" exp {}", fmt_expiry_date(expiration))),
Span::raw(format!(" spot {}", fmt_strike(chain.underlying_price))),
];
if !matches!(health, StreamHealth::Live) {
spans.push(Span::raw(" "));
spans.push(health_span(health, theme));
}
Line::from(spans)
}
#[must_use]
fn greek_slots_for_width(width: u16) -> usize {
let over = width.max(BASE_W) - BASE_W;
usize::from(over / SLOT_W).min(3)
}
#[must_use]
fn floor_sub(a: usize, b: usize) -> usize {
a.max(b) - b
}
#[must_use]
fn clamp_anchor(focused: Option<usize>, atm: Option<usize>, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
match focused {
Some(row) if row < len => Some(row),
Some(_) => Some(floor_sub(len, 1)),
None => atm.or(Some(0)),
}
}
#[must_use]
fn window_start(anchor: usize, visible: usize, len: usize) -> usize {
if visible == 0 || len <= visible {
return 0;
}
let half = visible / 2;
let ideal = floor_sub(anchor, half);
let max_start = floor_sub(len, visible);
ideal.min(max_start)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChainCol {
CallGreek(GreekColumn),
CallIv,
CallBid,
CallAsk,
CallMark,
Strike,
PutBid,
PutAsk,
PutMark,
PutIv,
PutGreek(GreekColumn),
}
#[must_use]
fn columns(greeks: GreekColumns) -> Vec<ChainCol> {
let mut plan = Vec::new();
plan.push(ChainCol::CallGreek(GreekColumn::Delta));
if greeks.gamma {
plan.push(ChainCol::CallGreek(GreekColumn::Gamma));
}
if greeks.vega {
plan.push(ChainCol::CallGreek(GreekColumn::Vega));
}
if greeks.theta {
plan.push(ChainCol::CallGreek(GreekColumn::Theta));
}
plan.push(ChainCol::CallIv);
plan.push(ChainCol::CallBid);
plan.push(ChainCol::CallAsk);
plan.push(ChainCol::CallMark);
plan.push(ChainCol::Strike);
plan.push(ChainCol::PutBid);
plan.push(ChainCol::PutAsk);
plan.push(ChainCol::PutMark);
plan.push(ChainCol::PutIv);
if greeks.theta {
plan.push(ChainCol::PutGreek(GreekColumn::Theta));
}
if greeks.vega {
plan.push(ChainCol::PutGreek(GreekColumn::Vega));
}
if greeks.gamma {
plan.push(ChainCol::PutGreek(GreekColumn::Gamma));
}
plan.push(ChainCol::PutGreek(GreekColumn::Delta));
plan
}
#[must_use]
fn col_width(col: ChainCol) -> Constraint {
match col {
ChainCol::Strike => Constraint::Length(STRIKE_W),
_ => Constraint::Length(NUM_W),
}
}
#[must_use]
fn col_header(col: ChainCol) -> &'static str {
match col {
ChainCol::CallGreek(greek) | ChainCol::PutGreek(greek) => greek_label(greek),
ChainCol::CallIv | ChainCol::PutIv => "IV",
ChainCol::CallBid | ChainCol::PutBid => "Bid",
ChainCol::CallAsk | ChainCol::PutAsk => "Ask",
ChainCol::CallMark | ChainCol::PutMark => "Mark",
ChainCol::Strike => "Strike",
}
}
#[must_use]
fn col_side(col: ChainCol) -> Option<LegFocus> {
match col {
ChainCol::CallGreek(_)
| ChainCol::CallIv
| ChainCol::CallBid
| ChainCol::CallAsk
| ChainCol::CallMark => Some(LegFocus::Call),
ChainCol::PutGreek(_)
| ChainCol::PutIv
| ChainCol::PutBid
| ChainCol::PutAsk
| ChainCol::PutMark => Some(LegFocus::Put),
ChainCol::Strike => None,
}
}
#[must_use]
fn greek_label(greek: GreekColumn) -> &'static str {
match greek {
GreekColumn::Delta => "Δ",
GreekColumn::Gamma => "Γ",
GreekColumn::Vega => "ν",
GreekColumn::Theta => "Θ",
}
}
#[must_use]
fn group_label(col: ChainCol) -> &'static str {
match col {
ChainCol::CallMark => "Calls",
ChainCol::PutMark => "Puts",
_ => "",
}
}
#[must_use]
fn header_row(plan: &[ChainCol], theme: Theme) -> Row<'static> {
let cells: Vec<Cell> = plan
.iter()
.map(|col| {
let align = if matches!(col, ChainCol::Strike) {
Alignment::Center
} else {
Alignment::Right
};
let group = Line::from(group_label(*col)).alignment(Alignment::Center);
let label = Line::from(col_header(*col)).alignment(align);
Cell::from(Text::from(vec![group, label]))
})
.collect();
Row::new(cells).height(2).style(theme.accent())
}
#[must_use]
fn col_cell(
col: ChainCol,
row: &ChainRow,
theme: Theme,
is_selected: bool,
focused_leg: LegFocus,
is_atm: bool,
) -> Cell<'static> {
let cell = match col {
ChainCol::CallGreek(greek) => greek_cell(&row.call, greek, theme),
ChainCol::PutGreek(greek) => greek_cell(&row.put, greek, theme),
ChainCol::CallIv => origin_num_cell(fmt_iv(row.call.iv), row.call.iv_is_local(), theme),
ChainCol::PutIv => origin_num_cell(fmt_iv(row.put.iv), row.put.iv_is_local(), theme),
ChainCol::CallBid => dir_cell(row.call.bid, row.call.bid_dir, theme),
ChainCol::CallAsk => dir_cell(row.call.ask, row.call.ask_dir, theme),
ChainCol::CallMark => num_cell(fmt_price(row.call.mark)),
ChainCol::PutBid => dir_cell(row.put.bid, row.put.bid_dir, theme),
ChainCol::PutAsk => dir_cell(row.put.ask, row.put.ask_dir, theme),
ChainCol::PutMark => num_cell(fmt_price(row.put.mark)),
ChainCol::Strike => strike_cell(row, theme, is_atm),
};
match col_side(col) {
Some(side) if is_selected && side == focused_leg => {
cell.style(Style::new().add_modifier(Modifier::UNDERLINED))
}
_ => cell,
}
}
#[must_use]
fn greek_cell(leg: &LegView, greek: GreekColumn, theme: Theme) -> Cell<'static> {
let value = match greek {
GreekColumn::Delta => leg.delta,
GreekColumn::Gamma => leg.gamma,
GreekColumn::Vega => leg.vega,
GreekColumn::Theta => leg.theta,
};
origin_num_cell(fmt_greek(value), leg.greek_is_local(greek), theme)
}
#[must_use]
fn origin_num_cell(text: String, local: bool, theme: Theme) -> Cell<'static> {
if local {
let line = Line::from(vec![Span::raw(text), Span::styled("~", theme.warning())])
.alignment(Alignment::Right);
Cell::from(line)
} else {
num_cell(text)
}
}
#[must_use]
fn num_cell(text: String) -> Cell<'static> {
Cell::from(Line::from(text).alignment(Alignment::Right))
}
#[must_use]
fn dir_cell(price: Option<Positive>, dir: TickDir, theme: Theme) -> Cell<'static> {
if price.is_none() {
return num_cell(fmt_price(price));
}
let line = Line::from(vec![
Span::raw(fmt_price(price)),
Span::raw(" "),
tick_dir_span(dir, theme),
])
.alignment(Alignment::Right);
Cell::from(line)
}
const ATM_MARKER_W: usize = 4;
#[must_use]
fn strike_cell(row: &ChainRow, theme: Theme, is_atm: bool) -> Cell<'static> {
let mut spans = vec![
Span::styled(
fmt_strike(row.strike),
theme.strike_relation_style(row.strike_relation),
),
Span::raw(" "),
];
if is_atm {
spans.push(strike_relation_marker_span(StrikeRelation::AtSpot, theme));
} else {
spans.push(Span::raw(" ".repeat(ATM_MARKER_W)));
}
Cell::from(Line::from(spans).alignment(Alignment::Center))
}
const EM_DASH: &str = "—";
#[must_use]
fn fmt_price(value: Option<Positive>) -> String {
match value {
Some(price) if price != Positive::MAX => format!("{price:.2}"),
_ => EM_DASH.to_owned(),
}
}
#[must_use]
fn fmt_iv(value: Option<Positive>) -> String {
match value {
Some(iv) if iv != Positive::MAX => iv
.to_dec()
.checked_mul(Decimal::from(100))
.map_or_else(|| EM_DASH.to_owned(), |pct| format!("{pct:.2}%")),
_ => EM_DASH.to_owned(),
}
}
const GREEK_MAX_CHARS: usize = (NUM_W as usize) - 1;
#[must_use]
fn fmt_greek(value: Option<Decimal>) -> String {
let Some(greek) = value else {
return EM_DASH.to_owned();
};
for decimals in (0..=4usize).rev() {
let formatted = format!("{greek:.decimals$}");
if formatted.len() <= GREEK_MAX_CHARS {
return formatted;
}
}
format!("{greek:.0}")
}
#[must_use]
fn fmt_expiry_date(expiration: DateTime<Utc>) -> String {
format!(
"{:04}-{:02}-{:02}",
expiration.year(),
expiration.month(),
expiration.day(),
)
}
#[must_use]
fn fmt_strike(strike: Positive) -> String {
if strike == Positive::MAX {
return EM_DASH.to_owned();
}
format!("{}", strike.round_to(2))
}
#[must_use]
pub fn handle_key(state: &mut LiveState, key: KeyEvent) -> Option<AppEvent> {
let chord = KeyChord::from_event(key)?;
match resolve_chain(chord)? {
ChainAction::MoveStrike => {
move_strike(state, chord);
None
}
ChainAction::FocusLeg => {
focus_leg(state, chord);
None
}
ChainAction::AddLeg => {
crate::ui::payoff::append_focused_leg(state);
None
}
ChainAction::SwitchExpiry | ChainAction::SwitchUnderlying | ChainAction::Drill => None,
}
}
fn move_strike(state: &mut LiveState, chord: KeyChord) {
let len = state.store.chain().options.len();
if len == 0 {
return;
}
let down = matches!(chord, KeyChord::Down | KeyChord::Char('j'));
let current = clamp_anchor(state.selection.focused_row, None, len);
let next = match state.selection.focused_row {
None => state.atm_index().unwrap_or(0),
Some(_) => {
let row = current.unwrap_or(0);
if down {
(row + 1).min(floor_sub(len, 1))
} else {
floor_sub(row, 1)
}
}
};
state.selection.focused_row = Some(next);
}
fn focus_leg(state: &mut LiveState, chord: KeyChord) {
state.selection.focused_leg = match chord {
KeyChord::Char('p') => LegFocus::Put,
_ => LegFocus::Call,
};
if state.selection.focused_row.is_none() && !state.store.chain().options.is_empty() {
state.selection.focused_row = Some(state.atm_index().unwrap_or(0));
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::{ExpirationDate, OptionStyle};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::{
ChainRow, LegView, clamp_anchor, draw, greek_slots_for_width, handle_key, project_call,
project_iv, project_put, project_row, resolve_leg, window_start,
};
use crate::app::{LegFocus, LiveScreen, LiveState, Mode, ScreenLoad, Selection, SourceBinding};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ExpirySource, GreeksOrigin, GreeksRow,
Instrument, InstrumentKey, LegGreeks, ProviderId, QuoteUpdate, StreamHealth, TickDir,
};
use crate::chain::{ContractSpecFingerprint, ExerciseStyle, SettlementStyle};
use crate::config::ThemeChoice;
use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
use crate::ui::theme::{GreekColumn, StrikeRelation, Theme};
const EXP: i64 = 1_700_000_000;
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("invalid provider id `{id}`: {e}"),
}
}
#[track_caller]
fn utc(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
#[track_caller]
fn pos_dec(value: Decimal) -> Positive {
match Positive::new_decimal(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive decimal: {e}"),
}
}
fn dec(mantissa: i64, scale: u32) -> Decimal {
Decimal::new(mantissa, scale)
}
fn full_row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(1.0)),
call_ask: Some(pos(1.2)),
put_bid: Some(pos(2.0)),
put_ask: Some(pos(2.4)),
implied_volatility: pos(0.5),
delta_call: Some(dec(6, 1)),
delta_put: Some(dec(-4, 1)),
gamma: Some(dec(1, 2)),
..Default::default()
};
od.set_mid_prices();
od
}
fn chain_with(strikes: &[f64]) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
for strike in strikes {
let _ = chain.options.insert(full_row(*strike));
}
chain
}
fn store_with(chain: OptionChain) -> ChainStore {
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
)
}
fn caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.depth(true)
.greeks(GreeksCapability::Provided)
.build()
}
fn live_with(chain: OptionChain, load: ScreenLoad) -> LiveState {
let mut live = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store_with(chain),
);
live.load = load;
live
}
fn spec() -> ContractSpecFingerprint {
ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
}
}
fn instrument(strike: f64, style: OptionStyle) -> Instrument {
Instrument {
key: InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: utc(EXP),
strike: pos(strike),
style,
},
provider: pid("deribit"),
native_symbol: format!("BTC-{strike}-{}", style.as_str()),
stream_symbol: None,
spec: spec(),
}
}
fn quote(strike: f64, style: OptionStyle, bid: f64, ask: f64, received: i64) -> QuoteUpdate {
QuoteUpdate {
instrument: instrument(strike, style),
bid: Some(pos(bid)),
ask: Some(pos(ask)),
last: None,
bid_size: None,
ask_size: None,
event_time: Some(utc(received)),
received_time: utc(received),
}
}
fn mk_leg(
iv: Option<(Positive, GreeksOrigin)>,
delta: Option<Decimal>,
gamma: Option<(Decimal, GreeksOrigin)>,
theta: Option<Decimal>,
vega: Option<Decimal>,
) -> LegGreeks {
let mut leg = LegGreeks::default();
if let Some((value, origin)) = iv {
leg.iv = Some(value);
leg.iv_origin = origin;
}
leg.delta = delta; if let Some((value, origin)) = gamma {
leg.gamma = Some(value);
leg.gamma_origin = origin;
}
leg.theta = theta;
leg.vega = vega;
leg
}
#[track_caller]
fn resolved_expiry(chain: &OptionChain) -> DateTime<Utc> {
match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute-UTC chain expiry, got {other:?}"),
}
}
fn store_consistent(chain: OptionChain) -> ChainStore {
let exp = resolved_expiry(&chain);
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", exp, pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
)
}
fn greeks_at(
exp: DateTime<Utc>,
strike: f64,
style: OptionStyle,
iv: f64,
gamma: Decimal,
) -> GreeksRow {
GreeksRow {
instrument: Instrument {
key: InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: exp,
strike: pos(strike),
style,
},
provider: pid("deribit"),
native_symbol: format!("BTC-{strike}-{}", style.as_str()),
stream_symbol: None,
spec: spec(),
},
iv: Some(pos(iv)),
delta: None,
gamma: Some(gamma),
theta: Some(dec(-1, 1)),
vega: Some(dec(2, 1)),
rho: Some(dec(3, 1)),
origin: GreeksOrigin::Provider,
event_time: None,
received_time: utc(EXP + 10),
}
}
fn priced_row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(3_000.0)),
call_ask: Some(pos(3_100.0)),
put_bid: Some(pos(2_000.0)),
put_ask: Some(pos(2_100.0)),
implied_volatility: pos(0.5),
delta_call: Some(dec(6, 1)),
delta_put: Some(dec(-4, 1)),
gamma: Some(dec(1, 2)),
..Default::default()
};
od.set_mid_prices();
od
}
fn priced_chain(strikes: &[f64]) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
for strike in strikes {
let _ = chain.options.insert(priced_row(*strike));
}
chain
}
fn live_ready_from_store(store: ChainStore) -> LiveState {
let mut live = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store,
);
live.load = ScreenLoad::Ready;
live
}
#[track_caller]
fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
}
}
fn theme() -> Theme {
Theme::resolve(ThemeChoice::Auto, false)
}
#[track_caller]
fn rendered(live: &LiveState, width: u16, height: u16) -> String {
rendered_at(live, width, height, utc(EXP))
}
#[track_caller]
fn rendered_at(live: &LiveState, width: u16, height: u16, now: DateTime<Utc>) -> String {
let mut term = terminal(width, height);
match term.draw(|frame| draw(live, frame, frame.area(), theme(), 0, now)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
term.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_project_call_leg_none_iff_none_field() {
let mut od = OptionData {
strike_price: pos(60_000.0),
call_bid: Some(pos(1.0)),
call_ask: None,
put_bid: Some(pos(2.0)),
put_ask: Some(pos(2.4)),
implied_volatility: pos(0.5),
delta_call: Some(dec(5, 1)),
gamma: None,
..Default::default()
};
od.set_mid_prices();
let call = project_call(&od, None, TickDir::Flat, TickDir::Flat);
assert_eq!(call.bid, Some(pos(1.0)), "present bid projects Some");
assert_eq!(call.ask, None, "absent ask projects None (renders em dash)");
assert_eq!(call.mark, None, "no mid without both sides -> None");
assert_eq!(call.delta, Some(dec(5, 1)), "venue delta_call wins");
assert_eq!(call.gamma, None, "no sidecar entry -> gamma None");
assert_eq!(
call.iv, None,
"no sidecar IV -> `—` (the #25 shared od.implied_volatility fallback is gone)"
);
assert_eq!(call.theta, None, "no sidecar entry -> theta None");
assert_eq!(call.vega, None, "no sidecar entry -> vega None");
assert_eq!(call.greeks_origin, GreeksOrigin::Provider);
}
#[test]
fn test_project_put_leg_reads_put_side_fields_and_put_sidecar() {
let od = full_row(60_000.0);
let put_leg = mk_leg(
Some((pos(0.6), GreeksOrigin::Provider)),
None,
Some((dec(3, 2), GreeksOrigin::Provider)),
None,
None,
);
let put = project_put(&od, Some(&put_leg), TickDir::Up, TickDir::Down);
assert_eq!(put.bid, od.put_bid);
assert_eq!(put.ask, od.put_ask);
assert_eq!(put.mark, od.put_middle);
assert_eq!(
put.delta, od.delta_put,
"put reads the venue delta_put, not delta_call"
);
assert_eq!(
put.gamma,
Some(dec(3, 2)),
"put gamma is the put sidecar entry, not the shared od.gamma"
);
assert_eq!(put.iv, Some(pos(0.6)), "put iv is the put sidecar entry");
assert_eq!(put.bid_dir, TickDir::Up);
assert_eq!(put.ask_dir, TickDir::Down);
}
#[test]
fn test_project_iv_zero_projects_none_not_a_fabricated_quote() {
assert_eq!(project_iv(Positive::ZERO), None);
}
#[test]
fn test_project_iv_positive_projects_some() {
assert_eq!(project_iv(pos(0.5)), Some(pos(0.5)));
}
#[test]
fn test_project_call_leg_absent_iv_zero_sidecar_is_none() {
let od = OptionData {
strike_price: pos(60_000.0),
..Default::default()
};
let leg = mk_leg(
Some((Positive::ZERO, GreeksOrigin::Provider)),
None,
None,
None,
None,
);
let call = project_call(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
assert_eq!(
call.iv, None,
"a zero-sentinel sidecar IV projects None, not Some(0)"
);
assert_eq!(call.greeks_origin, GreeksOrigin::Provider);
}
#[test]
fn test_resolve_leg_delta_prefers_venue_over_local() {
let leg = mk_leg(None, Some(dec(-9, 1)), None, None, None);
let v = resolve_leg(
Some(pos(1.0)),
Some(pos(1.2)),
Some(pos(1.1)),
Some(dec(6, 1)),
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(v.delta, Some(dec(6, 1)), "venue delta wins over local");
assert_eq!(v.greeks_origin, GreeksOrigin::Provider);
}
#[test]
fn test_resolve_leg_delta_falls_back_to_local() {
let leg = mk_leg(None, Some(dec(-9, 1)), None, Some(dec(-5, 2)), None);
let v = resolve_leg(
None,
None,
None,
None,
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(
v.delta,
Some(dec(-9, 1)),
"no venue delta -> local fallback"
);
assert_eq!(v.greeks_origin, GreeksOrigin::ComputedLocally);
}
#[test]
fn test_resolve_leg_iv_gamma_carry_sidecar_origin() {
let venue = mk_leg(
Some((pos(0.55), GreeksOrigin::Provider)),
None,
Some((dec(1, 4), GreeksOrigin::Provider)),
None,
None,
);
let v = resolve_leg(
None,
None,
None,
Some(dec(5, 1)),
Some(&venue),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(v.iv, Some(pos(0.55)));
assert_eq!(v.gamma, Some(dec(1, 4)));
assert_eq!(
v.greeks_origin,
GreeksOrigin::Provider,
"venue iv/gamma + venue delta -> Provider"
);
let local = mk_leg(
Some((pos(0.6), GreeksOrigin::ComputedLocally)),
None,
Some((dec(2, 4), GreeksOrigin::ComputedLocally)),
None,
None,
);
let w = resolve_leg(
None,
None,
None,
Some(dec(5, 1)),
Some(&local),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(w.iv, Some(pos(0.6)));
assert_eq!(w.gamma, Some(dec(2, 4)));
assert_eq!(w.greeks_origin, GreeksOrigin::ComputedLocally);
}
#[test]
fn test_resolve_leg_theta_vega_are_always_local() {
let leg = mk_leg(None, None, None, Some(dec(-5, 2)), Some(dec(3, 2)));
let v = resolve_leg(
None,
None,
None,
Some(dec(6, 1)),
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(v.theta, Some(dec(-5, 2)));
assert_eq!(v.vega, Some(dec(3, 2)));
assert_eq!(
v.greeks_origin,
GreeksOrigin::ComputedLocally,
"venue delta + local vega is a mixed-origin, locally-badged row"
);
}
#[test]
fn test_resolve_leg_absent_fields_stay_none_render_em_dash() {
let v = resolve_leg(None, None, None, None, None, (TickDir::Flat, TickDir::Flat));
assert_eq!(v.delta, None);
assert_eq!(v.iv, None);
assert_eq!(v.gamma, None);
assert_eq!(v.theta, None);
assert_eq!(v.vega, None);
assert_eq!(super::fmt_greek(v.theta), super::EM_DASH);
assert_eq!(super::fmt_greek(v.delta), super::EM_DASH);
assert_eq!(v.greeks_origin, GreeksOrigin::Provider);
}
#[test]
fn test_resolve_iv_local_below_floor_clears_to_none() {
let leg = mk_leg(
Some((pos(0.0003), GreeksOrigin::ComputedLocally)),
None,
None,
None,
None,
);
let v = resolve_leg(
None,
None,
None,
None,
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(v.iv, None, "a sub-0.5% local IV is floored to None");
assert_eq!(super::fmt_iv(v.iv), super::EM_DASH);
}
#[test]
fn test_resolve_iv_local_above_floor_still_shows() {
let leg = mk_leg(
Some((pos(0.25), GreeksOrigin::ComputedLocally)),
None,
None,
None,
None,
);
let v = resolve_leg(
None,
None,
None,
None,
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(v.iv, Some(pos(0.25)), "an at/above-floor local IV survives");
assert_eq!(v.iv_origin, GreeksOrigin::ComputedLocally);
assert!(v.iv_is_local(), "a present local IV badges the IV cell");
assert_eq!(super::fmt_iv(v.iv), "25.00%");
}
#[test]
fn test_resolve_iv_venue_below_floor_is_never_cleared() {
let leg = mk_leg(
Some((pos(0.001), GreeksOrigin::Provider)),
None,
None,
None,
None,
);
let v = resolve_leg(
None,
None,
None,
None,
Some(&leg),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(
v.iv,
Some(pos(0.001)),
"a sub-floor venue IV is trusted, not floored"
);
assert_eq!(v.iv_origin, GreeksOrigin::Provider);
assert!(!v.iv_is_local(), "a venue IV is never badged local");
}
#[test]
fn test_resolve_iv_call_reads_per_style_venue_sidecar_not_shared_field() {
let od = OptionData {
strike_price: pos(60_000.0),
implied_volatility: pos(0.4922),
delta_call: Some(dec(6, 1)),
..Default::default()
};
let venue_leg = mk_leg(
Some((pos(0.55), GreeksOrigin::Provider)),
None,
None,
Some(dec(-1, 1)),
None,
);
let call = project_call(&od, Some(&venue_leg), TickDir::Flat, TickDir::Flat);
assert_eq!(
call.iv,
Some(pos(0.55)),
"the per-style venue sidecar IV wins (not the shared od field's 0.4922)"
);
assert_eq!(call.iv_origin, GreeksOrigin::Provider);
assert!(!call.iv_is_local(), "no local glyph on the venue IV");
}
#[test]
fn test_resolve_iv_call_no_longer_uses_shared_field_fallback() {
let od = OptionData {
strike_price: pos(60_000.0),
implied_volatility: pos(0.4922),
delta_call: Some(dec(6, 1)),
..Default::default()
};
let leg = mk_leg(
Some((pos(0.00003), GreeksOrigin::ComputedLocally)),
None,
None,
Some(dec(-1, 1)),
None,
);
let call = project_call(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
assert_eq!(
call.iv, None,
"the near-zero local is floored to `—` (the shared fallback is gone)"
);
assert_eq!(super::fmt_iv(call.iv), super::EM_DASH);
}
#[test]
fn test_resolve_iv_put_ignores_shared_field_no_collision() {
let od = OptionData {
strike_price: pos(60_000.0),
implied_volatility: pos(0.4922),
delta_put: Some(dec(-4, 1)),
..Default::default()
};
let leg = mk_leg(
Some((pos(0.002), GreeksOrigin::ComputedLocally)),
None,
None,
Some(dec(-1, 1)),
None,
);
let put = project_put(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
assert_eq!(put.iv, None, "the put never inherits the shared IV");
assert_eq!(super::fmt_iv(put.iv), super::EM_DASH);
}
#[test]
fn test_origin_glyph_badges_present_local_field_not_venue_delta() {
let mixed = mk_leg(None, None, None, Some(dec(-5, 2)), None);
let v = resolve_leg(
None,
None,
None,
Some(dec(6, 1)),
Some(&mixed),
(TickDir::Flat, TickDir::Flat),
);
assert!(
!v.greek_is_local(GreekColumn::Delta),
"the venue delta is not badged"
);
assert!(
v.greek_is_local(GreekColumn::Theta),
"the local theta is badged"
);
let no_delta = mk_leg(None, None, None, Some(dec(-5, 2)), None);
let w = resolve_leg(
None,
None,
None,
None,
Some(&no_delta),
(TickDir::Flat, TickDir::Flat),
);
assert_eq!(w.delta, None, "no delta at all");
assert!(
!w.greek_is_local(GreekColumn::Delta),
"an absent delta is never badged"
);
assert!(
w.greek_is_local(GreekColumn::Theta),
"the local theta still badges with a None delta"
);
assert_eq!(
w.greeks_origin,
GreeksOrigin::ComputedLocally,
"rollup preserved: any present local field -> row local"
);
let venue = mk_leg(
Some((pos(0.5), GreeksOrigin::Provider)),
None,
Some((dec(1, 4), GreeksOrigin::Provider)),
None,
None,
);
let f = resolve_leg(
None,
None,
None,
Some(dec(5, 1)),
Some(&venue),
(TickDir::Flat, TickDir::Flat),
);
assert!(!f.greek_is_local(GreekColumn::Delta));
assert!(!f.greek_is_local(GreekColumn::Gamma));
assert!(!f.iv_is_local());
assert_eq!(f.greeks_origin, GreeksOrigin::Provider);
}
#[test]
fn test_project_row_populates_local_theta_vega_from_sidecar() {
let chain = priced_chain(&[60_000.0]);
let exp = resolved_expiry(&chain);
let store = store_consistent(chain);
let od = match store.chain().options.iter().next() {
Some(od) => od.clone(),
None => panic!("expected one row"),
};
let row = project_row(
&od,
pos(60_000.0),
&store,
"BTC",
exp,
store.last_full_poll(),
);
assert!(row.call.theta.is_some(), "local theta populated");
assert!(row.call.vega.is_some(), "local vega populated");
assert!(row.call.gamma.is_some(), "local gamma populated");
assert!(row.call.iv.is_some(), "call iv resolves (local inversion)");
assert_eq!(row.call.delta, od.delta_call);
assert_eq!(row.call.greeks_origin, GreeksOrigin::ComputedLocally);
}
#[test]
fn test_unequal_call_put_iv_gamma_survive_projection_both_orders() {
let project_both = |call_first: bool| -> (LegView, LegView) {
let chain = priced_chain(&[60_000.0]);
let exp = resolved_expiry(&chain);
let mut store = store_consistent(chain);
let call = greeks_at(exp, 60_000.0, OptionStyle::Call, 0.40, dec(1, 2));
let put = greeks_at(exp, 60_000.0, OptionStyle::Put, 0.60, dec(2, 2));
if call_first {
let _ = store.apply_greeks(&call);
let _ = store.apply_greeks(&put);
} else {
let _ = store.apply_greeks(&put);
let _ = store.apply_greeks(&call);
}
let od = match store.chain().options.iter().next() {
Some(od) => od.clone(),
None => panic!("expected one row"),
};
let row = project_row(
&od,
pos(60_000.0),
&store,
"BTC",
exp,
store.last_full_poll(),
);
(row.call, row.put)
};
for call_first in [true, false] {
let (call, put) = project_both(call_first);
assert_eq!(
call.iv,
Some(pos(0.40)),
"call iv preserved (order={call_first})"
);
assert_eq!(
put.iv,
Some(pos(0.60)),
"put iv preserved (order={call_first})"
);
assert_eq!(call.gamma, Some(dec(1, 2)), "call gamma preserved");
assert_eq!(put.gamma, Some(dec(2, 2)), "put gamma preserved");
assert_ne!(call.iv, put.iv, "unequal call/put iv both survive");
assert_ne!(call.gamma, put.gamma, "unequal call/put gamma both survive");
}
}
#[test]
fn test_populated_matrix_shows_origin_glyph_for_local_greeks() {
let live = live_ready_from_store(store_consistent(priced_chain(&[
59_000.0, 60_000.0, 61_000.0,
])));
let text = rendered(&live, 160, 20);
assert!(
text.contains('~'),
"a locally-computed row shows the origin glyph"
);
}
#[test]
fn test_project_row_buckets_strike_relation_below_at_above() {
let chain = chain_with(&[50_000.0, 60_000.0, 70_000.0]);
let store = store_with(chain);
let spot = pos(60_000.0);
let expiration = utc(EXP);
let below = match store
.chain()
.options
.iter()
.find(|o| o.strike_price == pos(50_000.0))
{
Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
None => panic!("expected a 50000 strike"),
};
assert_eq!(below.strike_relation, StrikeRelation::BelowSpot);
let at = match store
.chain()
.options
.iter()
.find(|o| o.strike_price == pos(60_000.0))
{
Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
None => panic!("expected a 60000 strike"),
};
assert_eq!(at.strike_relation, StrikeRelation::AtSpot);
let above = match store
.chain()
.options
.iter()
.find(|o| o.strike_price == pos(70_000.0))
{
Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
None => panic!("expected a 70000 strike"),
};
assert_eq!(above.strike_relation, StrikeRelation::AboveSpot);
}
#[test]
fn test_project_row_projects_rising_bid_direction_up() {
let mut store = store_with(chain_with(&[60_000.0]));
let _ = store.apply_quote("e(60_000.0, OptionStyle::Call, 1.0, 1.2, EXP + 100));
let _ = store.apply_quote("e(60_000.0, OptionStyle::Call, 1.5, 1.7, EXP + 101));
let od = match store.chain().options.iter().next() {
Some(od) => od.clone(),
None => panic!("expected one row"),
};
let row = project_row(
&od,
pos(60_000.0),
&store,
"BTC",
utc(EXP),
store.last_full_poll(),
);
assert_eq!(row.call.bid_dir, TickDir::Up, "a rising bid projects Up");
assert_eq!(row.call.ask_dir, TickDir::Up, "a rising ask projects Up");
assert_eq!(row.put.bid_dir, TickDir::Flat);
}
#[test]
fn test_project_row_no_reference_instant_is_flat() {
let store = store_with(chain_with(&[60_000.0]));
let od = match store.chain().options.iter().next() {
Some(od) => od.clone(),
None => panic!("expected one row"),
};
let row = project_row(&od, pos(60_000.0), &store, "BTC", utc(EXP), None);
assert_eq!(row.call.bid_dir, TickDir::Flat);
assert_eq!(row.put.ask_dir, TickDir::Flat);
}
#[test]
fn test_clamp_anchor_falls_back_when_cursor_out_of_range() {
assert_eq!(
clamp_anchor(Some(2), Some(1), 5),
Some(2),
"in-range cursor kept"
);
assert_eq!(
clamp_anchor(Some(9), Some(1), 5),
Some(4),
"over-range -> last row"
);
assert_eq!(clamp_anchor(None, Some(3), 5), Some(3), "unset -> ATM");
assert_eq!(clamp_anchor(None, None, 0), None, "empty chain -> None");
}
#[test]
fn test_window_start_keeps_anchor_visible() {
assert_eq!(window_start(0, 5, 3), 0, "chain fits -> start 0");
assert_eq!(window_start(10, 5, 20), 8, "centers the anchor");
assert_eq!(window_start(19, 5, 20), 15, "clamps to the last window");
assert_eq!(window_start(5, 0, 20), 0, "zero visible -> start 0");
}
#[test]
fn test_greek_slots_for_width_is_bounded_and_grows() {
assert_eq!(greek_slots_for_width(20), 0, "too narrow -> Delta only");
assert!(greek_slots_for_width(200) <= 3, "at most three greek slots");
assert!(
greek_slots_for_width(200) >= greek_slots_for_width(120),
"wider fits at least as many",
);
}
#[test]
fn test_columns_full_greek_set_honors_drop_order() {
use crate::ui::theme::greek_columns_for_slots;
let call_greeks = |plan: &[super::ChainCol]| -> Vec<GreekColumn> {
plan.iter()
.filter_map(|col| match col {
super::ChainCol::CallGreek(greek) => Some(*greek),
_ => None,
})
.collect()
};
assert_eq!(
call_greeks(&super::columns(greek_columns_for_slots(0))),
vec![GreekColumn::Delta],
"0 slots: Delta only",
);
assert_eq!(
call_greeks(&super::columns(greek_columns_for_slots(1))),
vec![GreekColumn::Delta, GreekColumn::Theta],
"1 slot: Delta + Theta (Θ retained first)",
);
assert_eq!(
call_greeks(&super::columns(greek_columns_for_slots(2))),
vec![GreekColumn::Delta, GreekColumn::Vega, GreekColumn::Theta],
"2 slots: adds Vega",
);
assert_eq!(
call_greeks(&super::columns(greek_columns_for_slots(3))),
vec![
GreekColumn::Delta,
GreekColumn::Gamma,
GreekColumn::Vega,
GreekColumn::Theta,
],
"3 slots: adds Gamma (dropped first as width shrinks)",
);
let put_greeks: Vec<GreekColumn> = super::columns(greek_columns_for_slots(3))
.iter()
.filter_map(|col| match col {
super::ChainCol::PutGreek(greek) => Some(*greek),
_ => None,
})
.collect();
assert_eq!(
put_greeks,
vec![
GreekColumn::Theta,
GreekColumn::Vega,
GreekColumn::Gamma,
GreekColumn::Delta,
],
"put side mirrors the call side",
);
}
#[test]
fn test_draw_matrix_greek_columns_are_responsive() {
let live = live_ready_from_store(store_consistent(priced_chain(&[
59_000.0, 60_000.0, 61_000.0,
])));
let common = rendered(&live, 120, 20);
assert!(common.contains("Θ"), "theta column shows at 120 cols");
assert!(!common.contains("Γ"), "gamma needs a wider terminal");
let wide = rendered(&live, 200, 20);
assert!(wide.contains("Γ"), "gamma shows on a wide terminal");
assert!(wide.contains("ν"), "vega shows on a wide terminal");
assert!(wide.contains("Θ"), "theta shows on a wide terminal");
}
#[test]
fn test_fmt_iv_overflowing_magnitude_renders_em_dash_not_panic() {
let huge = pos_dec(Decimal::MAX);
assert_eq!(super::fmt_iv(Some(huge)), super::EM_DASH);
assert_eq!(super::fmt_iv(Some(pos(0.5))), "50.00%");
}
#[test]
fn test_draw_loading_state_shows_connecting_to_provider() {
let live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
let text = rendered(&live, 120, 20);
assert!(text.contains("connecting to deribit"), "names the provider");
}
#[test]
fn test_draw_empty_state_shows_no_data_hint() {
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let live = live_with(empty, ScreenLoad::Ready);
let text = rendered(&live, 120, 20);
assert!(text.contains("no data for BTC"), "names the underlying");
assert!(text.contains("2025-06-27"), "names the expiry");
}
#[test]
fn test_draw_error_state_shows_message_and_retry_key() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
live.load = ScreenLoad::Error {
message: "provider unreachable".to_owned(),
};
let text = rendered(&live, 120, 20);
assert!(text.contains("provider unreachable"), "shows the message");
assert!(text.contains("press r to reconnect"), "shows the retry key");
}
#[test]
fn test_draw_populated_matrix_shows_strike_and_atm_marker() {
let live = live_with(
chain_with(&[59_000.0, 60_000.0, 61_000.0]),
ScreenLoad::Ready,
);
let text = rendered(&live, 120, 20);
assert!(text.contains("60000"), "renders a strike");
assert!(text.contains("◀ATM"), "marks the ATM strike");
assert!(text.contains("Strike"), "renders the header");
}
#[test]
fn test_draw_stale_feed_shows_badge_not_blank() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
live.source.health = StreamHealth::Reconnecting { attempt: 2 };
live.store
.apply_health(StreamHealth::Reconnecting { attempt: 2 });
let text = rendered(&live, 120, 20);
assert!(text.contains("60000"), "the last chain still renders");
assert!(
text.contains("reconnecting"),
"shows the reconnecting badge"
);
}
#[test]
fn test_draw_absent_iv_row_renders_em_dash_not_zero_percent() {
let mut od = OptionData {
strike_price: pos(60_000.0),
call_bid: Some(pos(1.0)),
call_ask: Some(pos(1.2)),
implied_volatility: Positive::ZERO,
..Default::default()
};
od.set_mid_prices();
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(od);
let live = live_with(chain, ScreenLoad::Ready);
let text = rendered(&live, 120, 20);
assert!(
text.contains(super::EM_DASH),
"the absent IV renders an em dash"
);
assert!(!text.contains("0.00%"), "no fabricated 0.00% IV");
}
#[test]
fn test_draw_reachable_states_render_across_sizes_without_panic() {
let populated = chain_with(&[58_000.0, 59_000.0, 60_000.0, 61_000.0, 62_000.0]);
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let mut stale = live_with(populated.clone(), ScreenLoad::Ready);
stale
.store
.apply_health(StreamHealth::Stale { since: utc(EXP) });
stale.source.health = StreamHealth::Stale { since: utc(EXP) };
let states = [
live_with(populated.clone(), ScreenLoad::Ready),
live_with(empty, ScreenLoad::Ready),
live_with(populated.clone(), ScreenLoad::Loading),
{
let mut e = live_with(populated, ScreenLoad::Ready);
e.load = ScreenLoad::Error {
message: "boom".to_owned(),
};
e
},
stale,
];
for live in &states {
for (w, h) in [(40u16, 8u16), (80, 24), (120, 40), (160, 50)] {
let _ = rendered(live, w, h);
}
}
}
#[test]
fn test_draw_direction_marker_decays_on_wall_clock_not_last_poll() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
let _ = live
.store
.apply_quote("e(60_000.0, OptionStyle::Call, 1.0, 1.2, EXP + 100));
let _ = live
.store
.apply_quote("e(60_000.0, OptionStyle::Call, 1.5, 1.7, EXP + 101));
let fresh = rendered_at(&live, 120, 12, utc(EXP + 101));
assert!(
fresh.contains('▲'),
"a just-risen quote shows the up marker"
);
let decayed = rendered_at(&live, 120, 12, utc(EXP + 200));
assert!(
!decayed.contains('▲'),
"past the decay window the marker decays on wall-time, not the last poll",
);
}
#[test]
fn test_draw_narrow_terminal_shows_widen_hint_not_clipped_chain() {
let live = live_with(
chain_with(&[59_000.0, 60_000.0, 61_000.0]),
ScreenLoad::Ready,
);
for w in [40u16, 80u16] {
let text = rendered(&live, w, 12);
assert!(
text.contains("widen"),
"at {w} cols the chain shows a widen hint, not a clipped table",
);
assert!(
!text.contains("Strike"),
"at {w} cols no clipped chain header leaks",
);
}
let wide = rendered(&live, 120, 20);
assert!(wide.contains("Strike"), "the chain renders at 120 cols");
assert!(wide.contains("60000"), "a strike renders at 120 cols");
}
#[test]
fn test_draw_is_pure_leaves_state_unchanged() {
let live = live_with(
chain_with(&[59_000.0, 60_000.0, 61_000.0]),
ScreenLoad::Ready,
);
let before_len = live.store.chain().options.len();
let before_poll = live.store.last_full_poll();
let before_sel = live.selection;
let before_health = matches!(live.store.health(), StreamHealth::Live);
let _ = rendered(&live, 120, 40);
assert_eq!(
live.store.chain().options.len(),
before_len,
"no rows added/removed"
);
assert_eq!(live.store.last_full_poll(), before_poll, "no re-poll");
assert_eq!(live.selection, before_sel, "selection unchanged");
assert_eq!(
matches!(live.store.health(), StreamHealth::Live),
before_health,
"health unchanged",
);
}
#[test]
fn test_draw_leaves_sidecar_unchanged_no_pricing_in_draw() {
let chain = priced_chain(&[60_000.0]);
let exp = resolved_expiry(&chain);
let live = live_ready_from_store(store_consistent(chain));
let key = InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: exp,
strike: pos(60_000.0),
style: OptionStyle::Call,
};
let before = live.store.leg_greeks(&key).copied();
assert!(before.is_some(), "the seeded sidecar entry is present");
let _ = rendered(&live, 160, 40);
let after = live.store.leg_greeks(&key).copied();
assert_eq!(
before, after,
"draw reads the cached sidecar and never recomputes or mutates it",
);
}
#[test]
fn test_handle_key_move_strike_down_reveals_cursor_at_atm_then_steps() {
let mut live = live_with(
chain_with(&[58_000.0, 60_000.0, 62_000.0]),
ScreenLoad::Ready,
);
assert_eq!(live.selection.focused_row, None, "no cursor initially");
assert!(handle_key(&mut live, press(KeyCode::Char('j'))).is_none());
assert_eq!(live.selection.focused_row, Some(1), "cursor at ATM");
let _ = handle_key(&mut live, press(KeyCode::Char('j')));
assert_eq!(live.selection.focused_row, Some(2));
let _ = handle_key(&mut live, press(KeyCode::Down));
assert_eq!(
live.selection.focused_row,
Some(2),
"clamped at the last row"
);
let _ = handle_key(&mut live, press(KeyCode::Char('k')));
assert_eq!(live.selection.focused_row, Some(1));
}
#[test]
fn test_handle_key_focus_leg_sets_call_or_put() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
let _ = handle_key(&mut live, press(KeyCode::Char('p')));
assert_eq!(live.selection.focused_leg, LegFocus::Put);
assert!(
live.selection.focused_row.is_some(),
"focus reveals the cursor"
);
let _ = handle_key(&mut live, press(KeyCode::Char('c')));
assert_eq!(live.selection.focused_leg, LegFocus::Call);
}
#[test]
fn test_handle_key_unbound_key_returns_none_and_changes_nothing() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
let before = live.selection;
assert!(handle_key(&mut live, press(KeyCode::Char('z'))).is_none());
assert_eq!(live.selection, before, "an unbound key changes nothing");
}
#[test]
fn test_handle_key_deferred_actions_are_noops_no_io() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
let before = live.selection;
for code in [
KeyCode::Char('l'), KeyCode::Char(']'), KeyCode::Enter, ] {
assert!(handle_key(&mut live, press(code)).is_none());
}
assert_eq!(live.selection, before, "deferred actions change no state");
}
#[test]
fn test_handle_key_add_leg_appends_focused_leg_to_builder_and_marks_dirty() {
let mut live = live_with(
chain_with(&[58_000.0, 60_000.0, 62_000.0]),
ScreenLoad::Ready,
);
assert!(live.payoff_builder.is_empty(), "builder starts empty");
let rev0 = live.payoff_builder.revision();
let _ = handle_key(&mut live, press(KeyCode::Char('c')));
assert_eq!(
live.selection.focused_row,
Some(1),
"focus reveals the ATM cursor"
);
let _ = handle_key(&mut live, press(KeyCode::Char('a')));
assert_eq!(live.payoff_builder.legs().len(), 1, "one leg appended");
let leg0 = match live.payoff_builder.legs().first() {
Some(leg) => *leg,
None => panic!("expected a first leg"),
};
assert_eq!(leg0.strike, pos(60_000.0), "the focused strike is appended");
assert_eq!(leg0.style, OptionStyle::Call, "the focused call leg");
let rev1 = live.payoff_builder.revision();
assert!(
rev1 > rev0,
"a successful append bumps the builder revision (marks the frame dirty)"
);
let _ = handle_key(&mut live, press(KeyCode::Char('j')));
let _ = handle_key(&mut live, press(KeyCode::Char('p')));
let _ = handle_key(&mut live, press(KeyCode::Char('a')));
assert_eq!(
live.payoff_builder.legs().len(),
2,
"second leg appended in order"
);
let leg1 = match live.payoff_builder.legs().get(1) {
Some(leg) => *leg,
None => panic!("expected a second leg"),
};
assert_eq!(
leg1.strike,
pos(62_000.0),
"second leg is the newly focused strike"
);
assert_eq!(leg1.style, OptionStyle::Put, "second leg is a put");
assert!(
live.payoff_builder.revision() > rev1,
"the second append bumps the revision again"
);
}
#[test]
fn test_handle_key_add_leg_on_empty_chain_is_safe_noop() {
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let mut live = live_with(empty, ScreenLoad::Ready);
let rev0 = live.payoff_builder.revision();
let _ = handle_key(&mut live, press(KeyCode::Char('a')));
assert!(
live.payoff_builder.is_empty(),
"no leg appended on an empty chain"
);
assert_eq!(
live.payoff_builder.revision(),
rev0,
"a no-op append does not bump the revision"
);
}
#[test]
fn test_handle_key_move_strike_on_empty_chain_is_noop() {
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let mut live = live_with(empty, ScreenLoad::Ready);
let _ = handle_key(&mut live, press(KeyCode::Char('j')));
assert_eq!(
live.selection.focused_row, None,
"no cursor on an empty chain"
);
}
#[test]
fn test_chain_row_and_leg_view_are_constructible_copy_values() {
let od = full_row(60_000.0);
let row: ChainRow = ChainRow {
strike: od.strike_price,
call: project_call(&od, None, TickDir::Flat, TickDir::Flat),
put: project_put(&od, None, TickDir::Flat, TickDir::Flat),
strike_relation: StrikeRelation::AtSpot,
};
let copy: ChainRow = row;
let _first: LegView = row.call;
let _second: LegView = copy.call;
assert_eq!(row.strike, copy.strike);
}
#[test]
fn test_selection_default_focuses_call_leg() {
let selection = Selection::default();
assert_eq!(selection.focused_leg, LegFocus::Call);
assert_eq!(selection.focused_row, None);
}
#[test]
fn test_live_state_defaults_to_chain_screen() {
let live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
let app_mode = Mode::Live(live);
match app_mode {
Mode::Live(state) => assert_eq!(state.screen, LiveScreen::Chain),
Mode::Replay(_) => panic!("expected a live mode"),
}
}
const HOSTILE_SYMBOL: &str = "BTC\u{1b}]52;c;cHduZWQ=\u{7}\u{1b}[2J\nEVIL\t\u{9b}31m";
fn live_from_fetch(fetch: ChainFetch, load: ScreenLoad) -> LiveState {
let store = ChainStore::seed(fetch, ChainSource::Merged, Duration::from_secs(2), utc(EXP));
let mut live = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store,
);
live.load = load;
live
}
#[track_caller]
fn render_chain_golden(live: &LiveState) -> String {
use crate::ui::golden::{GOLDEN_HEIGHT, GOLDEN_WIDTH, buffer_to_text};
let mut term = terminal(GOLDEN_WIDTH, GOLDEN_HEIGHT);
match term.draw(|frame| draw(live, frame, frame.area(), theme(), 0, utc(EXP))) {
Ok(_) => {}
Err(e) => panic!("golden draw failed: {e}"),
}
buffer_to_text(term.backend().buffer())
}
#[test]
fn test_chain_deribit_btc_atm_render_golden() {
let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
let live = live_from_fetch(fetch, ScreenLoad::Ready);
let text = render_chain_golden(&live);
crate::ui::golden::assert_golden("chain", "deribit_btc_atm.txt", &text);
}
#[test]
fn test_populated_matrix_shows_greeks_row_and_computed_origin_glyph() {
let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
let live = live_from_fetch(fetch, ScreenLoad::Ready);
let text = render_chain_golden(&live);
assert!(
text.contains('Δ'),
"the delta greek column header is present"
);
assert!(
text.contains('Θ'),
"the theta greek column header is present"
);
assert!(text.contains("IV"), "the IV column header is present");
assert!(
text.contains('~'),
"the `~` origin glyph badges a ComputedLocally field (local theta)",
);
assert!(
text.contains("-12.132~"),
"a computed theta renders as a signed number followed by the origin glyph",
);
}
#[test]
fn test_chain_loading_render_golden() {
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let live = live_with(empty, ScreenLoad::Loading);
let text = render_chain_golden(&live);
crate::ui::golden::assert_golden("chain", "loading.txt", &text);
}
#[test]
fn test_chain_empty_render_golden() {
let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let live = live_with(empty, ScreenLoad::Ready);
let text = render_chain_golden(&live);
crate::ui::golden::assert_golden("chain", "empty.txt", &text);
}
#[test]
fn test_chain_provider_error_render_golden() {
let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
live.load = ScreenLoad::Error {
message: "provider unreachable".to_owned(),
};
let text = render_chain_golden(&live);
crate::ui::golden::assert_golden("chain", "provider_error.txt", &text);
}
#[test]
fn test_chain_stale_render_golden() {
let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
let mut live = live_from_fetch(fetch, ScreenLoad::Ready);
live.source.health = StreamHealth::Stale { since: utc(EXP) };
live.store
.apply_health(StreamHealth::Stale { since: utc(EXP) });
let text = render_chain_golden(&live);
crate::ui::golden::assert_golden("chain", "stale.txt", &text);
}
#[test]
fn test_chain_escape_hygiene_render_golden_renders_inert_text() {
let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named(HOSTILE_SYMBOL);
let live = live_from_fetch(fetch, ScreenLoad::Ready);
let text = render_chain_golden(&live);
assert!(
!text.contains('\u{1b}'),
"the rendered hostile symbol must carry no raw ESC byte",
);
assert!(
!text.contains('\u{9b}'),
"the rendered hostile symbol must carry no 8-bit CSI introducer",
);
assert!(
!text.contains('\u{7}'),
"the rendered hostile symbol must carry no BEL byte",
);
crate::ui::golden::assert_golden("chain", "escape_hygiene.txt", &text);
}
#[test]
fn test_draw_hostile_symbol_renders_inert_across_sizes_without_panic() {
let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named(HOSTILE_SYMBOL);
let live = live_from_fetch(fetch, ScreenLoad::Ready);
for (w, h) in [(40u16, 8u16), (80, 24), (120, 40), (200, 60)] {
let text = rendered(&live, w, h);
assert!(!text.contains('\u{1b}'), "no ESC byte at {w}x{h}");
assert!(!text.contains('\u{9b}'), "no 8-bit CSI at {w}x{h}");
}
}
}