use crossterm::event::KeyEvent;
use optionstratlib::OptionStyle;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::Positive;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::symbols::Marker;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph};
use crate::app::atm_index_of;
use crate::app::keymap::{KeyChord, PayoffAction, ReplayAction, resolve_payoff, resolve_replay};
use crate::app::{
BuilderLeg, BundleLoad, CurveMode, LegFocus, LiveState, PayoffBuilder, ReplayPayoffHead,
ReplayState, Side,
};
use crate::event::{AppEvent, ReplayControl, SeekTo};
use crate::ui::graph::{EmptyReason, GraphProjection, ProjectedSeries};
use crate::ui::theme::{StrikeRelation, Theme, sanitize};
pub fn draw(
state: &LiveState,
payoff: &GraphProjection,
frame: &mut Frame,
area: Rect,
theme: Theme,
) {
let builder = &state.payoff_builder;
let chain = state.store.chain();
let title = Line::from(vec![
Span::styled("Payoff", theme.accent()),
Span::styled(format!(" {}", builder.curve().label()), theme.dim()),
]);
let block = Block::bordered().title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if builder.is_empty() && builder.committed().is_none() {
draw_empty(frame, inner, theme);
return;
}
if builder.committed().is_some() {
match payoff.ready() {
Some(series) => draw_committed_chart(frame, inner, theme, builder, chain, series),
None => {
draw_curve_unavailable(frame, inner, theme, builder, chain, payoff.empty_reason())
}
}
return;
}
draw_builder(frame, inner, theme, builder, chain);
}
fn draw_builder(
frame: &mut Frame,
inner: Rect,
theme: Theme,
builder: &PayoffBuilder,
chain: &OptionChain,
) {
let mut lines: Vec<Line<'static>> = Vec::with_capacity(builder.legs().len() + 3);
let cursor = builder.cursor();
for (idx, leg) in builder.legs().iter().enumerate() {
lines.push(leg_line(leg, idx == cursor, leg.mark_in(chain), theme));
}
if !builder.errors().is_empty() {
lines.push(Line::from(""));
for err in builder.errors() {
lines.push(Line::from(vec![
Span::styled("! ", theme.warning()),
Span::styled(err.to_string(), theme.warning()),
]));
}
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn draw_committed_chart(
frame: &mut Frame,
inner: Rect,
theme: Theme,
builder: &PayoffBuilder,
chain: &OptionChain,
series: &ProjectedSeries,
) {
let [header, body] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
frame.render_widget(
Paragraph::new(committed_header(theme, builder, chain)),
header,
);
let [x_min, x_max] = series.x_bounds();
let zero_line = [(x_min, 0.0), (x_max, 0.0)];
let breakevens: Vec<(f64, f64)> = builder
.break_even_points()
.iter()
.map(Positive::to_f64)
.filter(|x| x.is_finite() && *x >= x_min && *x <= x_max)
.map(|x| (x, 0.0))
.collect();
let spot = chain.underlying_price.to_f64();
let spot_pts: Vec<(f64, f64)> = if spot.is_finite() && spot >= x_min && spot <= x_max {
vec![(spot, 0.0)]
} else {
Vec::new()
};
let mut datasets = vec![
Dataset::default()
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.dim())
.data(&zero_line),
Dataset::default()
.name(series.name().to_owned())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.accent())
.data(series.points()),
];
if !breakevens.is_empty() {
datasets.push(
Dataset::default()
.name("break-even")
.marker(Marker::Dot)
.graph_type(GraphType::Scatter)
.style(theme.warning())
.data(&breakevens),
);
}
if !spot_pts.is_empty() {
datasets.push(
Dataset::default()
.name("spot")
.marker(Marker::Block)
.graph_type(GraphType::Scatter)
.style(theme.strike_relation_style(StrikeRelation::AtSpot))
.data(&spot_pts),
);
}
let y_bounds = y_bounds_including_zero(series.y_bounds());
let y_labels = payoff_y_labels(y_bounds);
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.title("underlying")
.bounds(series.x_bounds())
.labels(axis_labels(series.x_labels()))
.style(theme.dim()),
)
.y_axis(
Axis::default()
.title("P&L")
.bounds(y_bounds)
.labels(y_labels)
.style(theme.dim()),
);
frame.render_widget(chart, body);
}
#[must_use]
fn y_bounds_including_zero(bounds: [f64; 2]) -> [f64; 2] {
let [lo, hi] = bounds;
[lo.min(0.0), hi.max(0.0)]
}
#[must_use]
fn payoff_y_labels(bounds: [f64; 2]) -> Vec<Span<'static>> {
let [min, max] = bounds;
let mid = min + (max - min) / 2.0;
vec![
Span::raw(format!("{min:.2}")),
Span::raw(format!("{mid:.2}")),
Span::raw(format!("{max:.2}")),
]
}
fn draw_curve_unavailable(
frame: &mut Frame,
inner: Rect,
theme: Theme,
builder: &PayoffBuilder,
chain: &OptionChain,
reason: Option<EmptyReason>,
) {
let mut lines = committed_header(theme, builder, chain);
lines.push(Line::from(vec![
Span::styled("! ", theme.warning()),
Span::styled(
unavailable_reason(
builder.curve(),
builder.has_expiration_curve(),
builder.curve_compute_failed(),
reason,
),
theme.warning(),
),
]));
lines.push(Line::from(""));
let cursor = builder.cursor();
for (idx, leg) in builder.legs().iter().enumerate() {
lines.push(leg_line(leg, idx == cursor, leg.mark_in(chain), theme));
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
#[must_use]
fn committed_header(
theme: Theme,
builder: &PayoffBuilder,
chain: &OptionChain,
) -> Vec<Line<'static>> {
let n = builder.legs().len();
let summary = Line::from(vec![
Span::styled("✓ ", theme.accent()),
Span::styled(format!("committed {n} {}", leg_word(n)), theme.accent()),
Span::styled(format!(" curve {}", builder.curve().label()), theme.dim()),
]);
let marks = Line::from(vec![
Span::styled("spot ", theme.dim()),
Span::raw(fmt_price(Some(chain.underlying_price))),
Span::styled(" break-even ", theme.dim()),
Span::raw(fmt_break_evens(builder.break_even_points())),
]);
vec![summary, marks]
}
#[must_use]
fn unavailable_reason(
curve: CurveMode,
expiration_available: bool,
compute_failed: bool,
reason: Option<EmptyReason>,
) -> &'static str {
if compute_failed {
return COMPUTE_FAILED_REASON;
}
if curve == CurveMode::TPlus0 && expiration_available {
return "t+0 unavailable — no reliable IV";
}
match reason {
Some(EmptyReason::NoData) => "payoff curve unavailable — needs marks and a future expiry",
Some(EmptyReason::Degenerate) => "payoff curve unavailable — degenerate geometry",
Some(EmptyReason::Unsupported) => "payoff curve unavailable — unsupported geometry",
None => "payoff curve unavailable",
}
}
const COMPUTE_FAILED_REASON: &str =
"payoff curve unavailable — the pricing model failed on these legs";
const REPLAY_COMPUTE_FAILED_HEADLINE: &str = "payoff curve unavailable";
const REPLAY_COMPUTE_FAILED_NOTE: &str = "the pricing model failed on these legs";
#[must_use]
fn fmt_break_evens(points: &[Positive]) -> String {
if points.is_empty() {
return EM_DASH.to_owned();
}
points
.iter()
.map(|p| fmt_price(Some(*p)))
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
fn axis_labels(labels: &[String]) -> Vec<Span<'static>> {
labels
.iter()
.map(|label| Span::raw(label.clone()))
.collect()
}
fn draw_empty(frame: &mut Frame, inner: Rect, theme: Theme) {
let text = Text::from(vec![
Line::from(Span::styled("add a leg with `a`", theme.accent())),
Line::from(Span::styled(
"on the chain: focus c / p, then a",
theme.dim(),
)),
Line::from(Span::styled(
"here: a adds the focused (or ATM) strike",
theme.dim(),
)),
]);
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);
}
#[must_use]
fn leg_line(
leg: &BuilderLeg,
is_cursor: bool,
mark: Option<Positive>,
theme: Theme,
) -> Line<'static> {
let cursor_marker = if is_cursor { "▸ " } else { " " };
let spans = vec![
Span::raw(cursor_marker.to_owned()),
Span::styled(
format!("{:<4}", leg.side.label()),
theme.pnl_style(leg.side == Side::Sell),
),
Span::raw(format!(
" {}× {} {} mark {}",
leg.qty,
fmt_strike(leg.strike),
style_glyph(leg.style),
fmt_price(mark),
)),
];
let line = Line::from(spans);
if is_cursor {
line.style(Style::new().add_modifier(Modifier::BOLD))
} else {
line
}
}
#[must_use]
fn leg_word(count: usize) -> &'static str {
if count == 1 { "leg" } else { "legs" }
}
#[must_use]
fn style_glyph(style: OptionStyle) -> &'static str {
match style {
OptionStyle::Call => "C",
OptionStyle::Put => "P",
}
}
#[must_use]
fn fmt_strike(strike: Positive) -> String {
if strike == Positive::MAX {
return EM_DASH.to_owned();
}
format!("{}", strike.round_to(2))
}
#[must_use]
fn fmt_price(value: Option<Positive>) -> String {
match value {
Some(price) if price != Positive::MAX => format!("{price:.2}"),
_ => EM_DASH.to_owned(),
}
}
const EM_DASH: &str = "—";
#[must_use]
pub fn handle_key(state: &mut LiveState, key: KeyEvent) -> Option<AppEvent> {
let chord = KeyChord::from_event(key)?;
match resolve_payoff(chord)? {
PayoffAction::AddLeg => {
append_focused_leg(state);
None
}
PayoffAction::RemoveLeg => {
state.payoff_builder.remove_cursor();
None
}
PayoffAction::Quantity => {
adjust_qty(state, chord);
None
}
PayoffAction::ToggleSide => {
state.payoff_builder.toggle_cursor_side();
None
}
PayoffAction::Commit => {
commit(state);
None
}
PayoffAction::Cancel => {
state.payoff_builder.discard();
None
}
PayoffAction::ToggleCurve => {
state.payoff_builder.toggle_curve();
None
}
}
}
pub(crate) fn append_focused_leg(state: &mut LiveState) {
let chain = state.store.chain();
let len = chain.options.len();
if len == 0 {
return;
}
let row = match state.selection.focused_row {
Some(row) if row < len => Some(row),
_ => atm_index_of(chain),
};
let Some(row) = row else {
return;
};
let Some(od) = chain.options.iter().nth(row) else {
return;
};
let strike = od.strike_price;
let style = style_of(state.selection.focused_leg);
state.payoff_builder.append(strike, style);
}
fn adjust_qty(state: &mut LiveState, chord: KeyChord) {
match chord {
KeyChord::Char('+') => state.payoff_builder.increment_qty(),
_ => state.payoff_builder.decrement_qty(),
}
}
fn commit(state: &mut LiveState) {
let LiveState {
store,
payoff_builder,
..
} = state;
let _ = payoff_builder.commit(store);
}
#[must_use]
fn style_of(leg: LegFocus) -> OptionStyle {
match leg {
LegFocus::Call => OptionStyle::Call,
LegFocus::Put => OptionStyle::Put,
}
}
pub fn draw_replay(
state: &ReplayState,
payoff: &GraphProjection,
frame: &mut Frame,
area: Rect,
theme: Theme,
) {
let title = Line::from(vec![
Span::styled("Payoff", theme.accent()),
Span::styled(" at head", theme.dim()),
]);
let block = Block::bordered().title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let loaded = match &state.bundle {
BundleLoad::Loading => {
draw_replay_center(
frame,
inner,
theme,
theme.accent(),
"loading bundle…",
"reading the bundle tables",
);
return;
}
BundleLoad::Error { message } => {
draw_replay_center(
frame,
inner,
theme,
theme.warning(),
&format!("! {}", sanitize(message)),
"press R to retry",
);
return;
}
BundleLoad::Ready(loaded) => loaded.as_ref(),
};
let head = loaded.payoff_head();
if head.open_legs() == 0 {
draw_replay_center(
frame,
inner,
theme,
theme.accent(),
"flat at this step",
"no open position at the head — scrub to an open step",
);
return;
}
let Some(series) = payoff.ready() else {
let (headline, note) = if head.curve_compute_failed() {
(REPLAY_COMPUTE_FAILED_HEADLINE, REPLAY_COMPUTE_FAILED_NOTE)
} else {
(
"payoff unavailable at this step",
"the open position could not be priced",
)
};
draw_replay_center(frame, inner, theme, theme.warning(), headline, note);
return;
};
draw_replay_chart(frame, inner, theme, head, series);
}
fn draw_replay_chart(
frame: &mut Frame,
inner: Rect,
theme: Theme,
head: &ReplayPayoffHead,
series: &ProjectedSeries,
) {
let [header, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(inner);
frame.render_widget(Paragraph::new(replay_payoff_header(theme, head)), header);
let [x_min, x_max] = series.x_bounds();
let zero_line = [(x_min, 0.0), (x_max, 0.0)];
let breakevens: Vec<(f64, f64)> = head
.break_even_points()
.iter()
.map(Positive::to_f64)
.filter(|x| x.is_finite() && *x >= x_min && *x <= x_max)
.map(|x| (x, 0.0))
.collect();
let mut datasets = vec![
Dataset::default()
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.dim())
.data(&zero_line),
Dataset::default()
.name(series.name().to_owned())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.accent())
.data(series.points()),
];
if !breakevens.is_empty() {
datasets.push(
Dataset::default()
.name("break-even")
.marker(Marker::Dot)
.graph_type(GraphType::Scatter)
.style(theme.warning())
.data(&breakevens),
);
}
let y_bounds = y_bounds_including_zero(series.y_bounds());
let y_labels = payoff_y_labels(y_bounds);
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.title("underlying")
.bounds(series.x_bounds())
.labels(axis_labels(series.x_labels()))
.style(theme.dim()),
)
.y_axis(
Axis::default()
.title("P&L")
.bounds(y_bounds)
.labels(y_labels)
.style(theme.dim()),
);
frame.render_widget(chart, body);
}
#[must_use]
fn replay_payoff_header(theme: Theme, head: &ReplayPayoffHead) -> Vec<Line<'static>> {
let n = head.open_legs();
let summary = Line::from(vec![
Span::styled("✓ ", theme.accent()),
Span::styled(format!("open {n} {}", leg_word(n)), theme.accent()),
Span::styled(" mark ", theme.dim()),
match head.mark_pnl_cents() {
Some(cents) => Span::styled(fmt_signed_cents(cents), theme.pnl_style(cents < 0)),
None => Span::raw(EM_DASH.to_owned()),
},
]);
let marks = Line::from(vec![
Span::styled("break-even ", theme.dim()),
Span::raw(fmt_break_evens(head.break_even_points())),
]);
let caveat = Line::from(Span::styled(
"expiration payoff (per contract) · mark = current MTM (not a bit-exact reprice)",
theme.dim(),
));
vec![summary, marks, caveat]
}
#[must_use]
fn fmt_signed_cents(cents: i64) -> String {
let sign = if cents < 0 { "−" } else { "+" };
let magnitude = cents.unsigned_abs();
let dollars = magnitude / 100;
let rem = magnitude % 100;
format!("{sign}${dollars}.{rem:02}")
}
fn draw_replay_center(
frame: &mut Frame,
inner: Rect,
theme: Theme,
headline_style: Style,
headline: &str,
sub: &str,
) {
let mut lines = vec![Line::from(Span::styled(
headline.to_owned(),
headline_style,
))];
if !sub.is_empty() {
lines.push(Line::from(Span::styled(sub.to_owned(), theme.dim())));
}
let text = Text::from(lines);
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);
}
#[must_use]
pub fn handle_key_replay(state: &mut ReplayState, key: KeyEvent) -> Option<AppEvent> {
let chord = KeyChord::from_event(key)?;
match resolve_replay(chord, state.screen)? {
ReplayAction::StepBack => Some(AppEvent::ReplaySeek(SeekTo::StepBy(-1))),
ReplayAction::StepForward => Some(AppEvent::ReplaySeek(SeekTo::StepBy(1))),
ReplayAction::JumpStart => Some(AppEvent::ReplaySeek(SeekTo::Step(0))),
ReplayAction::JumpEnd => Some(AppEvent::ReplaySeek(SeekTo::Step(u32::MAX))),
ReplayAction::PlayPause => Some(AppEvent::ReplayControl(ReplayControl::PlayPause)),
ReplayAction::SpeedSlower => Some(AppEvent::ReplayControl(ReplayControl::SpeedSlower)),
ReplayAction::SpeedFaster => Some(AppEvent::ReplayControl(ReplayControl::SpeedFaster)),
ReplayAction::PrevFill | ReplayAction::NextFill => None,
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use optionstratlib::OptionStyle;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Positive, ToPrimitive};
use optionstratlib::visualization::GraphData;
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use super::{draw, handle_key, payoff_y_labels, y_bounds_including_zero};
use crate::app::{BuilderLeg, CurveMode, LegError, LegFocus, LiveState, Side, SourceBinding};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ContractSpecFingerprint, ExerciseStyle,
ExpirySource, GreeksOrigin, GreeksRow, Instrument, InstrumentKey, ProviderId,
SettlementStyle, StreamHealth,
};
use crate::config::ThemeChoice;
use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
use crate::ui::graph::{GraphProjection, project};
use crate::ui::theme::Theme;
#[must_use]
fn projection(state: &LiveState) -> GraphProjection {
project(state.payoff_builder.active_graph_data())
}
const EXP: i64 = 1_700_000_000;
const FULL_A: f64 = 60_000.0;
const FULL_B: f64 = 62_000.0;
const BARE: f64 = 64_000.0;
#[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}"),
}
}
fn full_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),
..Default::default()
};
od.set_mid_prices();
od
}
fn subfloor_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: Positive::ZERO,
..Default::default()
};
od.set_mid_prices();
od
}
fn bare_row(strike: f64) -> OptionData {
OptionData {
strike_price: pos(strike),
..Default::default()
}
}
fn chain() -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(FULL_A), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(full_row(FULL_A));
let _ = chain.options.insert(full_row(FULL_B));
let _ = chain.options.insert(bare_row(BARE));
chain
}
fn store() -> 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)
.greeks(GreeksCapability::Provided)
.build()
}
fn live_state() -> LiveState {
LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store(),
)
}
fn past_chain() -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(FULL_A), "2020-01-01".to_owned(), None, None);
let _ = chain.options.insert(full_row(FULL_A));
let _ = chain.options.insert(full_row(FULL_B));
chain
}
fn past_live_state() -> LiveState {
let store = ChainStore::seed(
ChainFetch::new(
past_chain(),
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
);
LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store,
)
}
#[track_caller]
fn compute_failed_live_state() -> LiveState {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_some(),
"the leg validates (its mark is present)",
);
state.payoff_builder.force_curve_compute_failed();
assert!(
state.payoff_builder.curve_compute_failed(),
"the contained upstream panic is recorded on the committed strategy",
);
state
}
fn subfloor_live_state() -> LiveState {
let mut chain = OptionChain::new("BTC", pos(FULL_A), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(subfloor_row(FULL_A));
let _ = chain.options.insert(subfloor_row(FULL_B));
let store = ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
);
LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store,
)
}
fn higher_mark_store() -> ChainStore {
let mut a = full_row(FULL_A);
a.call_bid = Some(pos(3_500.0));
a.call_ask = Some(pos(3_600.0));
a.set_mid_prices();
let mut chain = OptionChain::new("BTC", pos(FULL_A), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(a);
let _ = chain.options.insert(full_row(FULL_B));
let _ = chain.options.insert(bare_row(BARE));
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
)
}
#[track_caller]
fn resolved_expiry() -> DateTime<Utc> {
let chain = OptionChain::new("BTC", pos(FULL_A), "2025-06-27".to_owned(), None, None);
match chain.get_expiration() {
Some(optionstratlib::ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute-UTC chain expiry, got {other:?}"),
}
}
fn instrument(strike: f64, style: OptionStyle) -> Instrument {
Instrument {
key: InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: resolved_expiry(),
strike: pos(strike),
style,
},
provider: pid("deribit"),
native_symbol: format!("BTC-{strike}-{style:?}"),
stream_symbol: None,
spec: ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
},
}
}
fn venue_iv_row(strike: f64, style: OptionStyle, iv: f64) -> GreeksRow {
GreeksRow {
instrument: instrument(strike, style),
iv: Some(pos(iv)),
delta: None,
gamma: None,
theta: None,
vega: None,
rho: None,
origin: GreeksOrigin::Provider,
event_time: None,
received_time: utc(EXP),
}
}
#[track_caller]
fn series_y_near(graph: &GraphData, target: f64) -> f64 {
match graph {
GraphData::Series(series) => {
let mut best: Option<(f64, f64)> = None;
for (x, y) in series.x.iter().zip(series.y.iter()) {
let xf = x.to_f64().unwrap_or(f64::MAX);
let yf = y.to_f64().unwrap_or(0.0);
let dist = (xf - target).abs();
if best.is_none_or(|(bd, _)| dist < bd) {
best = Some((dist, yf));
}
}
match best {
Some((_, y)) => y,
None => panic!("expected a non-empty series"),
}
}
other => panic!("expected a Series, got {other:?}"),
}
}
fn press(state: &mut LiveState, code: KeyCode) {
let _ = handle_key(state, KeyEvent::new(code, KeyModifiers::NONE));
}
#[track_caller]
fn nth_leg(state: &LiveState, idx: usize) -> BuilderLeg {
match state.payoff_builder.legs().get(idx) {
Some(leg) => *leg,
None => panic!("expected a leg at index {idx}"),
}
}
fn focus(state: &mut LiveState, row: usize) {
state.selection.focused_row = Some(row);
}
#[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}"),
}
}
#[track_caller]
fn render(state: &LiveState, width: u16, height: u16) {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection(state);
let mut term = terminal(width, height);
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => panic!("payoff draw failed: {e}"),
}
}
#[track_caller]
fn render_to_text(state: &LiveState, width: u16, height: u16) -> String {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection(state);
let mut term = terminal(width, height);
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => panic!("payoff draw failed at {width}x{height}: {e}"),
}
crate::ui::golden::buffer_to_text(term.backend().buffer())
}
#[test]
fn test_add_leg_appends_focused_strike_and_style_in_order() {
let mut state = live_state();
focus(&mut state, 0);
state.selection.focused_leg = LegFocus::Call;
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
state.selection.focused_leg = LegFocus::Put;
press(&mut state, KeyCode::Char('a'));
assert_eq!(state.payoff_builder.legs().len(), 2, "two legs appended");
assert_eq!(
nth_leg(&state, 0).strike,
pos(FULL_A),
"first leg is the first focus"
);
assert_eq!(nth_leg(&state, 0).style, OptionStyle::Call);
assert_eq!(
nth_leg(&state, 1).strike,
pos(FULL_B),
"second leg is the second focus"
);
assert_eq!(nth_leg(&state, 1).style, OptionStyle::Put);
assert_eq!(state.payoff_builder.cursor(), 1);
assert_eq!(nth_leg(&state, 1).side, Side::Buy);
assert_eq!(nth_leg(&state, 1).qty, 1);
}
#[test]
fn test_add_leg_with_no_focus_falls_back_to_atm() {
let mut state = live_state();
assert!(state.selection.focused_row.is_none());
press(&mut state, KeyCode::Char('a'));
assert_eq!(state.payoff_builder.legs().len(), 1);
assert_eq!(nth_leg(&state, 0).strike, pos(FULL_A));
}
#[test]
fn test_edits_touch_only_the_cursor_leg() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Char('+'));
press(&mut state, KeyCode::Char('+'));
press(&mut state, KeyCode::Char('s'));
assert_eq!(nth_leg(&state, 0).qty, 1, "leg 0 quantity is untouched");
assert_eq!(
nth_leg(&state, 0).side,
Side::Buy,
"leg 0 side is untouched"
);
assert_eq!(
nth_leg(&state, 1).qty,
3,
"only the cursor leg's quantity changed"
);
assert_eq!(
nth_leg(&state, 1).side,
Side::Sell,
"only the cursor leg's side toggled"
);
press(&mut state, KeyCode::Char('-'));
assert_eq!(nth_leg(&state, 1).qty, 2);
}
#[test]
fn test_remove_cursor_leg_keeps_cursor_in_bounds() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); focus(&mut state, 1);
press(&mut state, KeyCode::Char('a')); assert_eq!(state.payoff_builder.cursor(), 1);
press(&mut state, KeyCode::Char('x'));
assert_eq!(state.payoff_builder.legs().len(), 1);
assert_eq!(
state.payoff_builder.cursor(),
0,
"cursor clamps into bounds"
);
assert_eq!(nth_leg(&state, 0).strike, pos(FULL_A));
press(&mut state, KeyCode::Char('x'));
assert!(state.payoff_builder.is_empty());
assert_eq!(state.payoff_builder.cursor(), 0);
press(&mut state, KeyCode::Char('x'));
assert!(state.payoff_builder.is_empty());
}
#[test]
fn test_enter_commits_a_valid_strategy() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); focus(&mut state, 1);
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Enter);
match state.payoff_builder.committed() {
Some(committed) => assert_eq!(committed.legs().len(), 2),
None => panic!("a valid strategy must commit"),
}
assert!(
state.payoff_builder.errors().is_empty(),
"no errors on commit"
);
}
#[test]
fn test_validate_rejects_a_stale_stream_mark_with_stale_mark() {
use crate::chain::{InstrumentKey, QuoteClocks};
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); let chain = state.store.chain();
let (strike, style) = match state.payoff_builder.legs().first() {
Some(leg) => (leg.strike, leg.style),
None => panic!("a leg was appended"),
};
let as_of = utc(EXP);
let mut clocks = QuoteClocks::new();
clocks.insert(
InstrumentKey {
underlying: chain.symbol.clone(),
expiration_utc: match chain.get_expiration() {
Some(optionstratlib::ExpirationDate::DateTime(dt)) => dt,
other => panic!("fixture chain must carry an absolute expiry, got {other:?}"),
},
strike,
style,
},
as_of - chrono::Duration::seconds(120),
);
match state.payoff_builder.validate(chain, &clocks, as_of) {
Err(errors) => assert!(
errors
.iter()
.any(|e| matches!(e, LegError::StaleMark { idx: 0 })),
"the stale leg reports StaleMark, got {errors:?}"
),
Ok(_) => panic!("a stale-marked leg must not validate"),
}
let empty = QuoteClocks::new();
assert!(
state.payoff_builder.validate(chain, &empty, as_of).is_ok(),
"a leg with no stream clock is not gated"
);
}
#[test]
fn test_enter_on_empty_reports_error_and_does_not_commit() {
let mut state = live_state();
press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_none(),
"empty never commits"
);
assert_eq!(state.payoff_builder.errors(), &[LegError::Empty]);
}
#[test]
fn test_enter_on_zero_qty_reports_error_and_does_not_commit() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Char('-')); assert_eq!(nth_leg(&state, 0).qty, 0);
press(&mut state, KeyCode::Enter);
assert!(state.payoff_builder.committed().is_none());
assert_eq!(
state.payoff_builder.errors(),
&[LegError::ZeroQty { idx: 0 }]
);
}
#[test]
fn test_enter_on_missing_mark_reports_error_and_does_not_commit() {
let mut state = live_state();
focus(&mut state, 2);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
assert!(state.payoff_builder.committed().is_none());
assert_eq!(
state.payoff_builder.errors(),
&[LegError::NoMark { idx: 0 }]
);
}
#[test]
fn test_edit_after_failed_commit_clears_stale_errors() {
let mut state = live_state();
focus(&mut state, 2);
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Enter); assert!(!state.payoff_builder.errors().is_empty());
press(&mut state, KeyCode::Char('s'));
assert!(state.payoff_builder.errors().is_empty());
}
#[test]
fn test_edit_after_commit_clears_the_committed_strategy() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
assert!(state.payoff_builder.committed().is_some());
press(&mut state, KeyCode::Char('+'));
assert!(state.payoff_builder.committed().is_none());
}
#[test]
fn test_esc_discards_to_the_empty_state() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Char('a'));
assert_eq!(state.payoff_builder.legs().len(), 2);
press(&mut state, KeyCode::Esc);
assert!(state.payoff_builder.is_empty(), "Esc clears the strategy");
assert!(state.payoff_builder.committed().is_none());
assert!(state.payoff_builder.errors().is_empty());
assert_eq!(state.payoff_builder.cursor(), 0);
}
#[test]
fn test_toggle_curve_flips_the_mode() {
let mut state = live_state();
let before = state.payoff_builder.curve();
press(&mut state, KeyCode::Char('t'));
assert_ne!(
state.payoff_builder.curve(),
before,
"`t` toggles the curve mode"
);
press(&mut state, KeyCode::Char('t'));
assert_eq!(state.payoff_builder.curve(), before, "`t` toggles back");
}
#[test]
fn test_render_empty_partial_invalid_committed_states() {
let mut state = live_state();
render(&state, 80, 24);
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
render(&state, 80, 24);
focus(&mut state, 2);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
render(&state, 80, 24);
press(&mut state, KeyCode::Char('x')); press(&mut state, KeyCode::Enter);
assert!(state.payoff_builder.committed().is_some());
render(&state, 120, 40);
press(&mut state, KeyCode::Char('t'));
render(&state, 120, 40);
}
#[test]
fn test_commit_builds_a_nonempty_payoff_series_and_ready_projection() {
let mut state = live_state();
focus(&mut state, 0);
state.selection.focused_leg = LegFocus::Call;
press(&mut state, KeyCode::Char('a')); focus(&mut state, 1);
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Char('s')); press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_some(),
"a valid spread commits"
);
match state.payoff_builder.active_graph_data() {
GraphData::Series(series) => {
assert!(!series.x.is_empty(), "the payoff curve is sampled");
assert_eq!(series.x.len(), series.y.len(), "x and y are paired");
}
other => panic!("expected a single Series, got {other:?}"),
}
assert!(
projection(&state).ready().is_some(),
"the committed curve projects Ready",
);
}
#[test]
fn test_toggle_curve_switches_active_series_and_bumps_graph_revision() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
let expiration = state.payoff_builder.active_graph_data().clone();
let rev = state.payoff_builder.graph_revision();
press(&mut state, KeyCode::Char('t')); assert_ne!(
state.payoff_builder.graph_revision(),
rev,
"the toggle reprojects the other curve",
);
let tplus0 = state.payoff_builder.active_graph_data().clone();
assert_ne!(
expiration, tplus0,
"the active series switches expiration → t+0",
);
}
#[test]
fn test_cursor_edit_does_not_reproject_but_commit_and_clear_do() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
let graph_rev = state.payoff_builder.graph_revision();
press(&mut state, KeyCode::Char('+'));
assert_eq!(
state.payoff_builder.graph_revision(),
graph_rev,
"a cursor edit never reprojects the (empty) curve",
);
press(&mut state, KeyCode::Enter);
let committed_rev = state.payoff_builder.graph_revision();
assert_ne!(committed_rev, graph_rev, "a commit reprojects");
press(&mut state, KeyCode::Char('+'));
assert_ne!(
state.payoff_builder.graph_revision(),
committed_rev,
"clearing the committed curve reprojects",
);
match state.payoff_builder.active_graph_data() {
GraphData::Series(series) => assert!(series.x.is_empty(), "back to the empty series"),
other => panic!("expected the empty Series, got {other:?}"),
}
}
#[test]
fn test_refresh_tplus0_on_unchanged_snapshot_is_a_stable_noop() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
press(&mut state, KeyCode::Char('t')); let rev = state.payoff_builder.graph_revision();
let LiveState {
store,
payoff_builder,
..
} = &mut state;
payoff_builder.refresh_tplus0(store);
assert_eq!(
payoff_builder.graph_revision(),
rev,
"an unchanged snapshot reprices to the same series (no reprojection)",
);
}
#[test]
fn test_committed_curve_unavailable_on_expired_chain_is_honest_not_fabricated() {
let mut state = past_live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_some(),
"the legs validate (marks present), even though the curve can't price",
);
assert!(
projection(&state).ready().is_none(),
"no curve is fabricated for an unpriceable strategy",
);
render(&state, 80, 24); }
#[test]
fn test_committed_compute_failed_renders_the_pricing_model_message() {
let state = compute_failed_live_state();
assert!(
projection(&state).ready().is_none(),
"no curve is fabricated after a contained pricing panic",
);
let text = render_to_text(&state, 100, 24);
assert!(
text.contains("the pricing model failed on these legs"),
"the compute-failed message renders: {text:?}",
);
assert!(
!text.contains("needs marks and a future expiry"),
"a contained panic never claims the marks are missing: {text:?}",
);
}
#[test]
fn test_unpriceable_and_compute_failed_render_different_messages() {
let mut expired = past_live_state();
focus(&mut expired, 0);
press(&mut expired, KeyCode::Char('a'));
press(&mut expired, KeyCode::Enter);
let expired_text = render_to_text(&expired, 100, 24);
assert!(
expired_text.contains("needs marks and a future expiry"),
"the unpriceable state keeps its own message: {expired_text:?}",
);
assert!(
!expired_text.contains("the pricing model failed"),
"an unpriceable strategy never claims the model failed: {expired_text:?}",
);
assert!(
!expired.payoff_builder.curve_compute_failed(),
"nothing panicked; the legs simply cannot be priced",
);
}
#[test]
fn test_compute_failed_message_wins_over_the_tplus0_no_iv_message() {
let mut state = compute_failed_live_state();
state.payoff_builder.toggle_curve();
assert_eq!(state.payoff_builder.curve(), CurveMode::TPlus0);
let text = render_to_text(&state, 100, 24);
assert!(
text.contains("the pricing model failed on these legs"),
"the compute-failed message wins on the t+0 curve too: {text:?}",
);
assert!(
!text.contains("no reliable IV"),
"a contained panic is never reported as a missing IV: {text:?}",
);
}
#[test]
fn test_draw_live_payoff_states_render_at_the_shrunk_shapes() {
let committed = ic_committed_state();
let mut t0 = ic_committed_state();
t0.payoff_builder.toggle_curve();
let empty = live_state();
let mut partial = live_state();
focus(&mut partial, 0);
press(&mut partial, KeyCode::Char('a'));
let mut invalid = live_state();
focus(&mut invalid, 2); press(&mut invalid, KeyCode::Char('a'));
press(&mut invalid, KeyCode::Enter);
let mut unpriceable = past_live_state();
focus(&mut unpriceable, 0);
press(&mut unpriceable, KeyCode::Char('a'));
press(&mut unpriceable, KeyCode::Enter);
let compute_failed = compute_failed_live_state();
for (w, h) in [(1u16, 1u16), (40, 8)] {
for state in [
&committed,
&t0,
&empty,
&partial,
&invalid,
&unpriceable,
&compute_failed,
] {
render(state, w, h);
}
}
}
#[track_caller]
fn ic_committed_state() -> LiveState {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_some(),
"both legs have marks, so the strategy commits",
);
state
}
#[test]
fn test_commit_geometry_is_deterministic_across_identical_stores() {
let build = || {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Char('s'));
press(&mut state, KeyCode::Enter);
let expiration = state.payoff_builder.active_graph_data().clone();
press(&mut state, KeyCode::Char('t'));
let tplus0 = state.payoff_builder.active_graph_data().clone();
(expiration, tplus0)
};
assert_eq!(build(), build(), "identical inputs yield identical curves");
}
#[test]
fn test_tplus0_freezes_entry_premium_and_reflects_unrealized_pnl() {
let mut state = live_state(); focus(&mut state, 0);
state.selection.focused_leg = LegFocus::Call;
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Enter);
press(&mut state, KeyCode::Char('t')); assert_eq!(state.payoff_builder.curve(), CurveMode::TPlus0);
let before = series_y_near(state.payoff_builder.active_graph_data(), FULL_A);
assert!(
before.abs() < 20.0,
"at commit the t+0 P&L at spot is ~0 (mark == entry), got {before}",
);
state.store = higher_mark_store();
{
let LiveState {
store,
payoff_builder,
..
} = &mut state;
payoff_builder.refresh_tplus0(store);
}
let after = series_y_near(state.payoff_builder.active_graph_data(), FULL_A);
let accrued = after - before;
assert!(
(accrued - 500.0).abs() < 60.0,
"t+0 accrued ≈ the mark move of 500 (before {before}, after {after}, accrued {accrued})",
);
let wing = FULL_A * 0.7;
let t0_wing = series_y_near(state.payoff_builder.active_graph_data(), wing);
press(&mut state, KeyCode::Char('t')); let exp_wing = series_y_near(state.payoff_builder.active_graph_data(), wing);
assert!(
(t0_wing - exp_wing).abs() < 100.0,
"t+0 converges to the frozen expiration at the wing (t0 {t0_wing}, exp {exp_wing})",
);
}
#[test]
fn test_subfloor_local_iv_renders_expiration_but_marks_tplus0_unavailable() {
let mut state = subfloor_live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Enter);
assert!(
state.payoff_builder.committed().is_some(),
"marks present → the legs validate",
);
assert!(
state.payoff_builder.has_expiration_curve(),
"the expiration curve renders without any IV",
);
assert!(
projection(&state).ready().is_some(),
"the expiration curve projects Ready",
);
press(&mut state, KeyCode::Char('t'));
assert_eq!(state.payoff_builder.curve(), CurveMode::TPlus0);
assert!(
projection(&state).ready().is_none(),
"t+0 is unavailable for a sub-floor local IV, never fabricated",
);
match state.payoff_builder.active_graph_data() {
GraphData::Series(series) => {
assert!(series.x.is_empty(), "the t+0 series is empty (unavailable)");
}
other => panic!("expected an empty Series, got {other:?}"),
}
render(&state, 80, 24); }
#[test]
fn test_venue_iv_below_floor_is_not_gated_and_tplus0_renders() {
let mut state = subfloor_live_state();
{
let store = &mut state.store;
let _ = store.apply_greeks(&venue_iv_row(FULL_A, OptionStyle::Call, 0.003));
let _ = store.apply_greeks(&venue_iv_row(FULL_B, OptionStyle::Call, 0.003));
}
focus(&mut state, 0);
state.selection.focused_leg = LegFocus::Call;
press(&mut state, KeyCode::Char('a')); press(&mut state, KeyCode::Enter);
press(&mut state, KeyCode::Char('t')); assert_eq!(state.payoff_builder.curve(), CurveMode::TPlus0);
assert!(
projection(&state).ready().is_some(),
"a sub-floor VENUE IV still prices the t+0 curve (never gated)",
);
}
#[test]
fn test_y_bounds_always_include_zero_and_labels_track_the_widening() {
assert_eq!(y_bounds_including_zero([10.0, 50.0]), [0.0, 50.0]);
assert_eq!(y_bounds_including_zero([-50.0, -10.0]), [-50.0, 0.0]);
assert_eq!(y_bounds_including_zero([-5.0, 5.0]), [-5.0, 5.0]);
let labels: Vec<String> = payoff_y_labels([0.0, 50.0])
.into_iter()
.map(|s| s.content.into_owned())
.collect();
assert_eq!(labels, ["0.00", "25.00", "50.00"]);
}
#[test]
fn test_draw_committed_is_pure_and_builds_no_graphdata() {
let mut state = live_state();
focus(&mut state, 0);
press(&mut state, KeyCode::Char('a'));
focus(&mut state, 1);
press(&mut state, KeyCode::Char('a'));
press(&mut state, KeyCode::Enter);
let before = state.payoff_builder.active_graph_data().clone();
let before_rev = state.payoff_builder.graph_revision();
render(&state, 120, 40);
render(&state, 40, 12); assert_eq!(
state.payoff_builder.active_graph_data(),
&before,
"draw builds or mutates no GraphData",
);
assert_eq!(
state.payoff_builder.graph_revision(),
before_rev,
"draw reprojects nothing",
);
}
mod milestone {
use optionstratlib::Options;
use optionstratlib::model::Position;
use optionstratlib::prelude::Decimal;
use optionstratlib::strategies::base::{BreakEvenable, Strategies};
use optionstratlib::strategies::custom::CustomStrategy;
use optionstratlib::{ExpirationDate, OptionType, Side as OptionSide};
use super::*;
const IC_SPOT: f64 = 100.0;
const IC_AS_OF: i64 = 1_748_457_000;
const IC_QTY: f64 = 1.0;
const BREAK_EVEN_TOLERANCE: f64 = 0.02;
const MAX_PNL_TOLERANCE: f64 = 0.01;
struct IcLeg {
strike: f64,
style: OptionStyle,
side: Side,
premium: f64,
}
fn ic_legs() -> [IcLeg; 4] {
[
IcLeg {
strike: 90.0,
style: OptionStyle::Put,
side: Side::Buy,
premium: 1.0,
},
IcLeg {
strike: 95.0,
style: OptionStyle::Put,
side: Side::Sell,
premium: 2.5,
},
IcLeg {
strike: 105.0,
style: OptionStyle::Call,
side: Side::Sell,
premium: 2.5,
},
IcLeg {
strike: 110.0,
style: OptionStyle::Call,
side: Side::Buy,
premium: 1.0,
},
]
}
fn ic_row(leg: &IcLeg) -> OptionData {
let bid = leg.premium - 0.1;
let ask = leg.premium + 0.1;
let mut od = OptionData {
strike_price: pos(leg.strike),
implied_volatility: pos(0.3),
..Default::default()
};
match leg.style {
OptionStyle::Call => {
od.call_bid = Some(pos(bid));
od.call_ask = Some(pos(ask));
}
OptionStyle::Put => {
od.put_bid = Some(pos(bid));
od.put_ask = Some(pos(ask));
}
}
od.set_mid_prices();
od
}
fn ic_chain() -> OptionChain {
let mut chain =
OptionChain::new("IC", pos(IC_SPOT), "2025-06-27".to_owned(), None, None);
for leg in &ic_legs() {
let _ = chain.options.insert(ic_row(leg));
}
chain
}
fn ic_state() -> LiveState {
let store = ChainStore::seed(
ChainFetch::new(
ic_chain(),
ExpirySource::new("IC", resolved_expiry(), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(IC_AS_OF),
);
let mut state = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store,
);
for leg in &ic_legs() {
state.payoff_builder.append(pos(leg.strike), leg.style);
if leg.side == Side::Sell {
state.payoff_builder.toggle_cursor_side();
}
}
let LiveState {
store,
payoff_builder,
..
} = &mut state;
assert!(
payoff_builder.commit(store),
"the iron condor commits (every leg has a mark)",
);
state
}
fn ic_custom_strategy(state: &LiveState) -> CustomStrategy {
let chain = state.store.chain();
let legs = match state.payoff_builder.committed() {
Some(committed) => committed.legs(),
None => panic!("expected a committed iron condor"),
};
let open_date = match chrono::DateTime::<chrono::Utc>::from_timestamp(IC_AS_OF, 0) {
Some(dt) => dt,
None => panic!("bad fixed test instant"),
};
let mut positions = Vec::with_capacity(legs.len());
for leg in legs {
let premium = match leg.mark_in(chain) {
Some(p) => p,
None => panic!("a committed leg must have a mark"),
};
let side = match leg.side {
Side::Buy => OptionSide::Long,
Side::Sell => OptionSide::Short,
};
let option = Options::new(
OptionType::European,
side,
"IC".to_owned(),
leg.strike,
ExpirationDate::Days(pos(30.0)),
pos(0.3),
pos(IC_QTY),
pos(IC_SPOT),
Decimal::ZERO,
leg.style,
Positive::ZERO,
None,
);
positions.push(Position::new(
option,
premium,
open_date,
Positive::ZERO,
Positive::ZERO,
None,
None,
));
}
match CustomStrategy::new(
"ic".to_owned(),
"IC".to_owned(),
"iron condor".to_owned(),
pos(IC_SPOT),
positions,
pos(0.01),
1_000,
Positive::ONE,
) {
Ok(strategy) => strategy,
Err(e) => panic!("CustomStrategy::new failed: {e}"),
}
}
fn sorted_dedup(points: &[Positive], tol: f64) -> Vec<f64> {
let mut xs: Vec<f64> = points
.iter()
.map(Positive::to_f64)
.filter(|x| x.is_finite())
.collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut out: Vec<f64> = Vec::new();
for x in xs {
if out.last().is_none_or(|last| (x - *last).abs() > tol) {
out.push(x);
}
}
out
}
#[track_caller]
fn expiration_extent(graph: &GraphData) -> (f64, f64) {
match graph {
GraphData::Series(series) => {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for y in &series.y {
let v = y.to_f64().unwrap_or(0.0);
min = min.min(v);
max = max.max(v);
}
(min, max)
}
other => panic!("expected a Series, got {other:?}"),
}
}
#[track_caller]
fn render_payoff_golden(state: &LiveState) -> String {
use crate::ui::golden::{GOLDEN_HEIGHT, GOLDEN_WIDTH, buffer_to_text};
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection(state);
let mut term = terminal(GOLDEN_WIDTH, GOLDEN_HEIGHT);
let area = Rect::new(0, 0, GOLDEN_WIDTH, GOLDEN_HEIGHT);
match term.draw(|frame| draw(state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => panic!("payoff golden draw failed: {e}"),
}
buffer_to_text(term.backend().buffer())
}
#[test]
fn test_payoff_iron_condor_expiration_render_golden() {
let state = ic_state();
assert_eq!(
state.payoff_builder.curve(),
CurveMode::Expiration,
"the committed strategy defaults to the expiration curve",
);
let text = render_payoff_golden(&state);
crate::ui::golden::assert_golden("payoff", "iron_condor_expiration.txt", &text);
}
#[test]
fn test_payoff_iron_condor_t0_render_golden() {
let mut state = ic_state();
state.payoff_builder.toggle_curve();
assert_eq!(state.payoff_builder.curve(), CurveMode::TPlus0);
assert!(
projection(&state).ready().is_some(),
"the iron condor t+0 curve is Ready (a plausible IV per leg)",
);
let text = render_payoff_golden(&state);
crate::ui::golden::assert_golden("payoff", "iron_condor_t0.txt", &text);
}
#[test]
fn test_payoff_empty_render_golden() {
let state = live_state();
assert!(state.payoff_builder.is_empty() && state.payoff_builder.committed().is_none());
let text = render_payoff_golden(&state);
crate::ui::golden::assert_golden("payoff", "payoff_empty.txt", &text);
}
#[test]
fn test_payoff_invalid_combo_render_golden() {
let mut state = ic_state();
state.payoff_builder.discard();
state.payoff_builder.append(pos(90.0), OptionStyle::Put); state.payoff_builder.append(pos(IC_SPOT), OptionStyle::Call); {
let LiveState {
store,
payoff_builder,
..
} = &mut state;
assert!(
!payoff_builder.commit(store),
"a no-mark leg fails validation (the invalid state)",
);
}
assert_eq!(
state.payoff_builder.errors(),
&[LegError::NoMark { idx: 1 }],
"the invalid combo reports the un-marked leg",
);
let text = render_payoff_golden(&state);
crate::ui::golden::assert_golden("payoff", "payoff_invalid.txt", &text);
}
#[test]
fn test_iron_condor_break_even_and_max_pnl_match_optionstratlib() {
let state = ic_state();
let cv_bes = sorted_dedup(
state.payoff_builder.break_even_points(),
BREAK_EVEN_TOLERANCE,
);
let (cv_min, cv_max) = expiration_extent(state.payoff_builder.active_graph_data());
let strategy = ic_custom_strategy(&state);
let osl_bes = match strategy.get_break_even_points() {
Ok(bes) => sorted_dedup(bes, BREAK_EVEN_TOLERANCE),
Err(e) => panic!("get_break_even_points failed: {e}"),
};
let osl_max = match strategy.get_max_profit() {
Ok(p) => p.to_f64(),
Err(e) => panic!("get_max_profit failed: {e}"),
};
let osl_loss = match strategy.get_max_loss() {
Ok(p) => p.to_f64(),
Err(e) => panic!("get_max_loss failed: {e}"),
};
assert_eq!(
cv_bes.len(),
osl_bes.len(),
"same break-even count (ChainView {cv_bes:?} vs optionstratlib {osl_bes:?})",
);
for (a, b) in cv_bes.iter().zip(osl_bes.iter()) {
assert!(
(a - b).abs() <= BREAK_EVEN_TOLERANCE,
"break-even {a} vs optionstratlib {b} (tol {BREAK_EVEN_TOLERANCE})",
);
}
assert!(
(cv_max - osl_max).abs() <= MAX_PNL_TOLERANCE,
"max profit {cv_max} vs optionstratlib {osl_max} (tol {MAX_PNL_TOLERANCE})",
);
assert!(
((-cv_min) - osl_loss).abs() <= MAX_PNL_TOLERANCE,
"max loss {} vs optionstratlib {osl_loss} (tol {MAX_PNL_TOLERANCE})",
-cv_min,
);
}
#[test]
fn test_draw_payoff_and_matrix_recomputes_nothing_and_mutates_no_state() {
let state = ic_state();
let geometry_before = state.payoff_builder.active_graph_data().clone();
let break_evens_before = state.payoff_builder.break_even_points().to_vec();
let graph_rev_before = state.payoff_builder.graph_revision();
let revision_before = state.payoff_builder.revision();
let sidecar_before = representative_leg_greeks(&state);
let _ = render_payoff_golden(&state);
render_matrix(&state, GOLDEN_WIDTH_MATRIX, GOLDEN_HEIGHT_MATRIX);
render_matrix(&state, 40, 12);
assert_eq!(
state.payoff_builder.active_graph_data(),
&geometry_before,
"draw built or mutated no payoff GraphData",
);
assert_eq!(
state.payoff_builder.break_even_points(),
break_evens_before.as_slice(),
"draw recomputed no break-evens",
);
assert_eq!(
state.payoff_builder.graph_revision(),
graph_rev_before,
"draw reprojected nothing",
);
assert_eq!(
state.payoff_builder.revision(),
revision_before,
"draw bumped no builder revision",
);
assert_eq!(
representative_leg_greeks(&state),
sidecar_before,
"draw ran no compute_leg_greeks (the sidecar is byte-identical)",
);
}
const GOLDEN_WIDTH_MATRIX: u16 = 120;
const GOLDEN_HEIGHT_MATRIX: u16 = 40;
#[track_caller]
fn render_matrix(state: &LiveState, width: u16, height: u16) {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let mut term = terminal(width, height);
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| crate::ui::chain::draw(state, frame, area, theme, 0, utc(EXP)))
{
Ok(_) => {}
Err(e) => panic!("matrix draw failed at {width}x{height}: {e}"),
}
}
fn representative_leg_greeks(state: &LiveState) -> Option<crate::chain::LegGreeks> {
let key = InstrumentKey {
underlying: "IC".to_owned(),
expiration_utc: resolved_expiry(),
strike: pos(90.0),
style: OptionStyle::Put,
};
state.store.leg_greeks(&key).copied()
}
#[test]
fn test_every_payoff_and_matrix_state_renders_without_panic() {
let committed = ic_state();
let mut t0 = ic_state();
t0.payoff_builder.toggle_curve();
let empty = live_state();
let unavailable = past_live_state(); for (w, h) in [(40u16, 8u16), (80, 24), (120, 40), (200, 60)] {
for state in [&committed, &t0, &empty, &unavailable] {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection(state);
let mut term = terminal(w, h);
let area = Rect::new(0, 0, w, h);
match term.draw(|frame| draw(state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => panic!("payoff draw failed at {w}x{h}: {e}"),
}
}
render_matrix(&committed, w, h);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, ..ProptestConfig::default() })]
#[test]
fn prop_payoff_builder_any_key_sequence_renders(
keys in proptest::collection::vec(0u8..8, 0..24),
focus_row in 0usize..4,
width in 40u16..120,
height in 8u16..40,
) {
let mut state = live_state();
state.selection.focused_row = Some(focus_row);
for k in keys {
let code = match k {
0 => KeyCode::Char('a'),
1 => KeyCode::Char('x'),
2 => KeyCode::Char('+'),
3 => KeyCode::Char('-'),
4 => KeyCode::Char('s'),
5 => KeyCode::Enter,
6 => KeyCode::Esc,
_ => KeyCode::Char('t'),
};
press(&mut state, code);
}
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection(&state);
let mut term = terminal(width, height);
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(&state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => prop_assert!(false, "payoff draw failed at {width}x{height}: {e}"),
}
}
}
mod replay_head {
use std::collections::BTreeMap;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use super::super::{draw_replay, handle_key_replay};
use crate::app::{ReplayScreen, ReplayState};
use crate::config::ThemeChoice;
use crate::event::{AppEvent, BundleLoadResult, ReplayControl, SeekTo};
use crate::replay::{
BundleManifest, EquityPoint, LoadedBundle, PositionRow, PositionSide, SUPPORTED_SCHEMA,
};
use crate::ui::graph::{GraphProjection, project};
use crate::ui::theme::Theme;
const BASE_TS: i64 = 1_700_000_000_000_000_000;
const EXP_NS: i64 = 1_735_286_400_000_000_000;
fn manifest() -> BundleManifest {
let mut row_counts = BTreeMap::new();
let _ = row_counts.insert("fills".to_owned(), 0u64);
let _ = row_counts.insert("equity_curve".to_owned(), 0u64);
let _ = row_counts.insert("positions".to_owned(), 0u64);
let _ = row_counts.insert("greeks_attribution".to_owned(), 0u64);
BundleManifest {
schema: SUPPORTED_SCHEMA.to_owned(),
run_id: "run-test".to_owned(),
created_utc: "2026-07-18T00:00:00Z".to_owned(),
code_version: "0.5.0".to_owned(),
lockfile_sha256: "deadbeef".to_owned(),
seed: 1,
config: serde_json::json!({ "capital_cents": 1_000_000 }),
strategy: serde_json::json!({}),
data_source: serde_json::json!({}),
metrics: serde_json::json!({}),
row_counts,
}
}
fn equity(step: u32) -> EquityPoint {
EquityPoint {
step,
ts_ns: BASE_TS + i64::from(step),
cash_cents: 1_000,
position_value_cents: 0,
equity_cents: 1_000,
drawdown: 0.0,
}
}
fn position(step: u32, strike_cents: u64, style: char, side: PositionSide) -> PositionRow {
PositionRow {
step,
ts_ns: BASE_TS + i64::from(step),
position_id: 1,
trade_id: 7,
contract_id: format!("v1:BTC:{EXP_NS}:{strike_cents}:{style}"),
side,
quantity: 1,
avg_price_cents: 12_500,
mark_cents: 11_800,
unrealized_cents: -700,
stale_mark: false,
exit_reason: None,
open_at_end: step == 2,
}
}
fn ready_state(positions: Vec<PositionRow>) -> ReplayState {
let bundle = LoadedBundle {
manifest: manifest(),
fills: Vec::new(),
equity: (0..3).map(equity).collect(),
positions,
greeks: Vec::new(),
};
let mut state = ReplayState::new(PathBuf::from("/bundle/valid"));
state.apply_load_result(BundleLoadResult::Loaded(Box::new(bundle)));
state.screen = ReplayScreen::Payoff;
let _ = state.seek(SeekTo::Step(u32::MAX));
state
}
fn projection_of(state: &ReplayState) -> GraphProjection {
match state.loaded() {
Some(loaded) => project(loaded.payoff_graph()),
None => project(&optionstratlib::visualization::GraphData::Series(
optionstratlib::visualization::Series2D::default(),
)),
}
}
#[track_caller]
fn render_to_text(state: &ReplayState, w: u16, h: u16) -> String {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection_of(state);
let mut term = match Terminal::new(TestBackend::new(w, h)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
};
let area = Rect::new(0, 0, w, h);
match term.draw(|frame| draw_replay(state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => panic!("draw_replay failed at {w}x{h}: {e}"),
}
crate::ui::golden::buffer_to_text(term.backend().buffer())
}
#[test]
fn test_loading_bundle_shows_connecting_note() {
let state = ReplayState::new(PathBuf::from("/bundle/valid"));
let text = render_to_text(&state, 80, 24);
assert!(
text.contains("loading bundle"),
"a loading bundle shows the connecting note: {text:?}",
);
}
#[test]
fn test_bundle_error_shows_message_and_replay_retry_key() {
let mut state = ReplayState::new(PathBuf::from("/bundle/broken"));
state.apply_load_result(BundleLoadResult::Failed(
"manifest.json is malformed".to_owned(),
));
let text = render_to_text(&state, 80, 24);
assert!(
text.contains("manifest.json is malformed"),
"the bundle error message renders: {text:?}",
);
assert!(
text.contains("press R to retry"),
"a bundle error offers the Replay `R` retry key: {text:?}",
);
assert!(
!text.contains("loading bundle"),
"a failed bundle no longer shows the loading note: {text:?}",
);
assert!(
!text.to_lowercase().contains("reconnect"),
"a bundle error never surfaces the Live provider-reconnect key: {text:?}",
);
}
#[test]
fn test_flat_state_when_no_open_position_at_head() {
let state = ready_state(Vec::new());
let text = render_to_text(&state, 80, 24);
assert!(
text.contains("flat at this step"),
"the empty state renders before any curve: {text:?}",
);
assert!(
text.contains("scrub to an open step"),
"the recovery is stated: {text:?}",
);
}
#[test]
fn test_open_position_renders_curve_header_and_honest_caveat() {
let state = ready_state(vec![
position(0, 6_000_000, 'C', PositionSide::Long),
position(1, 6_000_000, 'C', PositionSide::Long),
position(2, 6_000_000, 'C', PositionSide::Long),
]);
let text = render_to_text(&state, 120, 40);
assert!(
text.contains("open 1 leg"),
"the leg count renders: {text:?}"
);
assert!(
text.contains("−$7.00"),
"the mark P&L renders from integer cents: {text:?}",
);
assert!(
text.contains("not a bit-exact reprice"),
"the honesty caveat is visible: {text:?}",
);
}
#[test]
fn test_mark_is_header_only_never_a_line_on_the_per_contract_chart() {
let state = ready_state(vec![
position(0, 6_000_000, 'C', PositionSide::Long),
position(1, 6_000_000, 'C', PositionSide::Long),
position(2, 6_000_000, 'C', PositionSide::Long),
]);
let text = render_to_text(&state, 120, 40);
assert!(
text.contains("mark −$7.00"),
"the header keeps the portfolio mark figure: {text:?}",
);
assert!(
text.contains("payoff @ expiration"),
"the chart legend renders its series name: {text:?}",
);
assert_eq!(
text.matches("mark").count(),
2,
"no `mark` legend entry: only the header figure + the caveat: {text:?}",
);
}
#[test]
fn test_draw_replay_is_pure_builds_no_graphdata() {
let state = ready_state(vec![
position(0, 6_000_000, 'C', PositionSide::Long),
position(1, 6_000_000, 'C', PositionSide::Long),
position(2, 6_000_000, 'C', PositionSide::Long),
]);
let (graph_before, rev_before) = match state.loaded() {
Some(loaded) => (loaded.payoff_graph().clone(), loaded.payoff_revision()),
None => panic!("expected a Ready state"),
};
let _ = render_to_text(&state, 120, 40);
let _ = render_to_text(&state, 40, 12); match state.loaded() {
Some(loaded) => {
assert_eq!(
loaded.payoff_graph(),
&graph_before,
"draw_replay built or mutated no GraphData",
);
assert_eq!(
loaded.payoff_revision(),
rev_before,
"draw_replay reprojected nothing (no revision bump)",
);
}
None => panic!("state stayed Ready"),
}
}
#[test]
fn test_degenerate_open_set_routes_to_unavailable_not_a_fabricated_curve() {
let state = ready_state(vec![
position(0, 0, 'C', PositionSide::Long),
position(1, 0, 'C', PositionSide::Long),
position(2, 0, 'C', PositionSide::Long),
]);
assert!(
projection_of(&state).ready().is_none(),
"the degenerate open set projects Empty, never a fabricated curve",
);
let text = render_to_text(&state, 80, 24);
assert!(
text.contains("payoff unavailable at this step"),
"an unpriceable open set renders the honest unavailable state: {text:?}",
);
}
#[test]
fn test_loading_state_renders_a_note() {
let mut state = ReplayState::new(PathBuf::from("/bundle/valid"));
state.screen = ReplayScreen::Payoff; let text = render_to_text(&state, 80, 24);
assert!(
text.contains("loading bundle"),
"the loading note renders while the bundle is not Ready: {text:?}",
);
}
#[test]
fn test_scrub_key_on_payoff_panel_emits_a_seek() {
let mut state = ready_state(vec![position(0, 6_000_000, 'C', PositionSide::Long)]);
let back =
handle_key_replay(&mut state, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert!(
matches!(back, Some(AppEvent::ReplaySeek(SeekTo::StepBy(-1)))),
"`←` on the payoff panel scrubs back (the flat-state recovery)",
);
let play = handle_key_replay(
&mut state,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
);
assert!(
matches!(
play,
Some(AppEvent::ReplayControl(ReplayControl::PlayPause))
),
"`Space` on the payoff panel toggles playback",
);
let none = handle_key_replay(
&mut state,
KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE),
);
assert!(
none.is_none(),
"the fill drill-down key is inert on the payoff panel"
);
}
#[track_caller]
fn compute_failed_state() -> ReplayState {
let mut state = ready_state(vec![
position(0, 6_000_000, 'C', PositionSide::Long),
position(1, 6_000_000, 'C', PositionSide::Long),
position(2, 6_000_000, 'C', PositionSide::Long),
]);
match &mut state.bundle {
crate::app::BundleLoad::Ready(loaded) => loaded.force_payoff_compute_failed(),
crate::app::BundleLoad::Loading | crate::app::BundleLoad::Error { .. } => {
panic!("expected a Ready bundle")
}
}
state
}
#[test]
fn test_payoff_at_head_compute_failed_renders_the_pricing_model_message() {
let state = compute_failed_state();
let text = render_to_text(&state, 80, 24);
assert!(
text.contains("payoff curve unavailable"),
"the compute-failed headline renders: {text:?}",
);
assert!(
text.contains("the pricing model failed on these legs"),
"the compute-failed note renders: {text:?}",
);
assert!(
!text.contains("could not be priced"),
"a contained panic is never reported as unpriceable legs: {text:?}",
);
}
#[test]
fn test_payoff_at_head_unpriceable_and_compute_failed_differ() {
let unpriceable = ready_state(vec![position(2, 0, 'P', PositionSide::Short)]);
let text = render_to_text(&unpriceable, 80, 24);
assert!(
text.contains("payoff unavailable at this step"),
"the unpriceable state keeps its own headline: {text:?}",
);
assert!(
!text.contains("the pricing model failed"),
"an unpriceable open set never claims the model failed: {text:?}",
);
}
#[test]
fn test_draw_replay_states_render_at_the_shrunk_shapes() {
for idx in 0..4u8 {
let state = replay_state_for(idx);
for (w, h) in [(1u16, 1u16), (40, 8)] {
let _ = render_to_text(&state, w, h);
}
}
let failed = compute_failed_state();
for (w, h) in [(1u16, 1u16), (40, 8)] {
let _ = render_to_text(&failed, w, h);
}
}
fn replay_state_for(state_idx: u8) -> ReplayState {
match state_idx {
0 => ready_state(Vec::new()), 1 => ready_state(vec![
position(0, 6_000_000, 'C', PositionSide::Long),
position(1, 6_000_000, 'C', PositionSide::Long),
position(2, 6_000_000, 'C', PositionSide::Long),
]), 2 => ready_state(vec![position(2, 0, 'P', PositionSide::Short)]), _ => {
let mut s = ReplayState::new(PathBuf::from("/bundle/valid"));
s.screen = ReplayScreen::Payoff;
s }
}
}
#[test]
#[ignore = "stress lever for the #131 flake: cargo test --all-features -- --ignored"]
fn test_payoff_projections_stress_over_every_state() {
const ITERATIONS: usize = 250;
for _ in 0..ITERATIONS {
for idx in 0..4u8 {
let state = replay_state_for(idx);
if let Some(loaded) = state.loaded() {
assert!(
!loaded.payoff_head().curve_compute_failed(),
"the replay payoff-at-head build panicked (the #131 flake)",
);
}
let _ = render_to_text(&state, 80, 24);
}
let live = [
super::ic_committed_state(),
super::live_state(),
super::subfloor_live_state(),
];
for state in &live {
assert!(
!state.payoff_builder.curve_compute_failed(),
"the live payoff geometry build panicked (the #131 flake)",
);
super::render(state, 80, 24);
}
let mut ticked = super::ic_committed_state();
let crate::app::LiveState {
store,
payoff_builder,
..
} = &mut ticked;
payoff_builder.refresh_tplus0(store);
assert!(
!ticked.payoff_builder.curve_compute_failed(),
"the t+0 reprice panicked (the #131 flake)",
);
super::render(&ticked, 80, 24);
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 96, ..ProptestConfig::default() })]
#[test]
fn prop_draw_replay_never_panics(
state_idx in 0u8..4,
width in 1u16..160,
height in 1u16..60,
) {
let state = replay_state_for(state_idx);
let theme = Theme::resolve(ThemeChoice::Auto, false);
let payoff = projection_of(&state);
let mut term = match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => { prop_assert!(false, "TestBackend failed: {e}"); return Ok(()); }
};
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw_replay(&state, &payoff, frame, area, theme)) {
Ok(_) => {}
Err(e) => prop_assert!(false, "draw_replay failed at {width}x{height}: {e}"),
}
}
}
}
}