use crossterm::event::KeyEvent;
use optionstratlib::OptionStyle;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::symbols::Marker;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Wrap};
use crate::app::keymap::{KeyChord, ReplayAction, resolve_replay};
use crate::app::{BundleLoad, LoadedReplay, ReplayState};
use crate::event::{AppEvent, ReplayControl, SeekTo};
use crate::replay::{ExecMode, Fill, GreeksAttribution, PositionSide};
use crate::ui::graph::{EmptyReason, GraphProjection, ProjectedSeries};
use crate::ui::theme::{Theme, pnl_sign_char, pnl_sign_span, run_label, sanitize, spinner_frame};
const EM_DASH: &str = "—";
const DRAWDOWN_PANEL_WIDTH: u16 = 18;
const FILL_DETAIL_HEIGHT: u16 = 3;
const ATTRIB_LABEL_WIDTH: usize = 12;
const ATTRIB_AMOUNT_WIDTH: usize = 11;
const ATTRIB_MAX_BAR: usize = 14;
pub fn draw(
state: &ReplayState,
equity: &GraphProjection,
frame: &mut Frame,
area: Rect,
theme: Theme,
tick: u64,
) {
match &state.bundle {
BundleLoad::Loading => draw_loading(frame, area, theme, &run_label(state), tick),
BundleLoad::Error { message } => draw_error(frame, area, theme, message),
BundleLoad::Ready(loaded) => {
draw_ready(frame, area, theme, &run_label(state), loaded, equity);
}
}
}
fn draw_loading(frame: &mut Frame, area: Rect, theme: Theme, run: &str, tick: u64) {
draw_state_body(
frame,
area,
theme,
Text::from(vec![
Line::from(Span::styled(
format!("{} loading bundle {}…", spinner_frame(tick), sanitize(run)),
theme.accent(),
)),
Line::from(Span::styled("reading the bundle tables", theme.dim())),
]),
);
}
fn draw_error(frame: &mut Frame, area: Rect, theme: Theme, message: &str) {
let block = Block::bordered().title(Span::styled("Replay", theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
let [msg_area, hint_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("! {}", sanitize(message)),
theme.warning(),
)))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
msg_area,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled("press R to retry", theme.dim())))
.alignment(Alignment::Center),
hint_area,
);
}
fn draw_state_body(frame: &mut Frame, area: Rect, theme: Theme, text: Text<'static>) {
let block = Block::bordered().title(Span::styled("Replay", 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);
}
fn draw_ready(
frame: &mut Frame,
area: Rect,
theme: Theme,
run: &str,
loaded: &LoadedReplay,
equity: &GraphProjection,
) {
let block = Block::bordered().title(Span::styled(replay_title(run, loaded), theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
let [equity_row, bottom_row] =
Layout::vertical([Constraint::Percentage(50), Constraint::Min(0)]).areas(inner);
let [chart_area, drawdown_area] = Layout::horizontal([
Constraint::Min(20),
Constraint::Length(DRAWDOWN_PANEL_WIDTH),
])
.areas(equity_row);
draw_equity(frame, chart_area, theme, equity);
draw_drawdown(frame, drawdown_area, theme, loaded);
let [attribution_area, fills_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(bottom_row);
draw_attribution(frame, attribution_area, theme, loaded);
draw_fills(frame, fills_area, theme, loaded);
}
fn replay_title(run: &str, loaded: &LoadedReplay) -> String {
let step = loaded.cursor.position();
let end = loaded.cursor.end_step();
match loaded.bundle.fills.first() {
Some(fill) => format!(
"Replay · {} · {} · step {step}/{end}",
sanitize(run),
exec_mode_label(fill.mode),
),
None => format!("Replay · {} · step {step}/{end}", sanitize(run)),
}
}
fn draw_equity(frame: &mut Frame, area: Rect, theme: Theme, equity: &GraphProjection) {
let block = Block::bordered().title(Span::styled("equity", theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
match equity.ready() {
Some(series) => draw_equity_chart(frame, inner, theme, series),
None => draw_centered_note(
frame,
inner,
theme,
equity_empty_message(equity.empty_reason()),
),
}
}
fn equity_empty_message(reason: Option<EmptyReason>) -> &'static str {
match reason {
Some(EmptyReason::NoData) | None => "no equity rows in this run",
Some(EmptyReason::Degenerate) => "equity curve unavailable — degenerate data",
Some(EmptyReason::Unsupported) => "equity curve unavailable",
}
}
fn draw_equity_chart(frame: &mut Frame, inner: Rect, theme: Theme, series: &ProjectedSeries) {
let x_bounds = series.x_bounds();
let y_bounds = series.y_bounds();
let head: Vec<(f64, f64)> = series.points().last().copied().into_iter().collect();
let mut datasets = vec![
Dataset::default()
.name(series.name().to_owned())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.accent())
.data(series.points()),
];
if !head.is_empty() {
datasets.push(
Dataset::default()
.name("head")
.marker(Marker::Block)
.graph_type(GraphType::Scatter)
.style(theme.warning())
.data(&head),
);
}
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.title("step")
.bounds(x_bounds)
.labels(step_axis_labels(x_bounds))
.style(theme.dim()),
)
.y_axis(
Axis::default()
.title("$")
.bounds(y_bounds)
.labels(equity_y_labels(y_bounds))
.style(theme.dim()),
);
frame.render_widget(chart, inner);
}
fn step_axis_labels(bounds: [f64; 2]) -> Vec<Span<'static>> {
let [min, max] = bounds;
vec![Span::raw(fmt_step(min)), Span::raw(fmt_step(max))]
}
fn equity_y_labels(bounds: [f64; 2]) -> Vec<Span<'static>> {
let [min, max] = bounds;
let mid = min + (max - min) / 2.0;
vec![
Span::raw(fmt_axis_cents(min)),
Span::raw(fmt_axis_cents(mid)),
Span::raw(fmt_axis_cents(max)),
]
}
fn draw_drawdown(frame: &mut Frame, area: Rect, theme: Theme, loaded: &LoadedReplay) {
let block = Block::bordered().title(Span::styled("drawdown", theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
let peak = loaded.peak_drawdown_cents();
let now = loaded
.cursor
.head_equity(&loaded.bundle)
.map(|point| point.drawdown);
let lines = vec![
Line::from(vec![
Span::styled("peak ", theme.dim()),
Span::styled(fmt_drawdown_cents(peak), theme.pnl_style(peak < 0)),
]),
Line::from(vec![
Span::styled("now ", theme.dim()),
Span::raw(fmt_drawdown_ratio(now)),
]),
];
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn draw_attribution(frame: &mut Frame, area: Rect, theme: Theme, loaded: &LoadedReplay) {
let step = loaded.cursor.position();
let block = Block::bordered().title(Span::styled(
format!("P&L attribution @ step {step}"),
theme.accent(),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = attribution_rows(loaded.cursor.head_greeks(&loaded.bundle));
let max_abs = rows
.iter()
.filter_map(|row| row.value.map(|(_, magnitude)| magnitude))
.max()
.unwrap_or(0);
let content_w = usize::from(inner.width);
let lines: Vec<Line<'static>> = rows
.iter()
.map(|row| attribution_line(row, max_abs, content_w, theme))
.collect();
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
struct AttribRow {
label: &'static str,
value: Option<(bool, u64)>,
}
impl AttribRow {
fn absent(label: &'static str) -> Self {
Self { label, value: None }
}
fn signed(label: &'static str, cents: i64) -> Self {
Self {
label,
value: Some((cents < 0, cents.unsigned_abs())),
}
}
fn fee(label: &'static str, fees_cents: u64) -> Self {
Self {
label,
value: Some((fees_cents > 0, fees_cents)),
}
}
}
fn attribution_rows(head: Option<&GreeksAttribution>) -> [AttribRow; 6] {
match head {
None => [
AttribRow::absent("Θ theta"),
AttribRow::absent("Δ delta"),
AttribRow::absent("ν vega"),
AttribRow::absent("spread cap."),
AttribRow::absent("fees"),
AttribRow::absent("residual"),
],
Some(row) => [
AttribRow::signed("Θ theta", row.theta_pnl_cents),
AttribRow::signed("Δ delta", row.delta_pnl_cents),
AttribRow::signed("ν vega", row.vega_pnl_cents),
AttribRow::signed("spread cap.", row.spread_capture_cents),
AttribRow::fee("fees", row.fees_cents),
AttribRow::signed("residual", row.residual_cents),
],
}
}
fn attribution_line(
row: &AttribRow,
max_abs: u64,
content_w: usize,
theme: Theme,
) -> Line<'static> {
let label = format!("{:<width$}", row.label, width = ATTRIB_LABEL_WIDTH);
match row.value {
None => Line::from(vec![
Span::raw(label),
Span::styled(EM_DASH.to_owned(), theme.dim()),
]),
Some((is_negative, magnitude)) => {
let after_sign = floor_sub(content_w, ATTRIB_LABEL_WIDTH + 1);
let amount = fmt_cents_abs(magnitude);
let amount_len = amount.chars().count();
let style = theme.pnl_style(is_negative);
let mut spans = vec![Span::raw(label), pnl_sign_span(is_negative, theme)];
if amount_len <= after_sign {
let pad = ATTRIB_AMOUNT_WIDTH.max(amount_len).min(after_sign);
spans.push(Span::styled(format!("{amount:<pad$}"), style));
let bar_room = floor_sub(after_sign, pad).min(ATTRIB_MAX_BAR);
if bar_room > 0 {
spans.push(Span::styled(
bar_string(magnitude, max_abs, bar_room),
style,
));
}
} else {
spans.push(Span::styled(elide(&amount, after_sign), style));
}
Line::from(spans)
}
}
}
fn elide(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if s.chars().count() <= max_width {
return s.to_owned();
}
let mut out: String = s.chars().take(max_width - 1).collect();
out.push('…');
out
}
#[must_use]
fn floor_sub(a: usize, b: usize) -> usize {
a.max(b) - b
}
fn bar_string(magnitude: u64, max_abs: u64, width: usize) -> String {
if max_abs == 0 || width == 0 {
return String::new();
}
let width_u = u128::try_from(width).unwrap_or(0);
let scaled = match u128::from(magnitude).checked_mul(width_u) {
Some(product) => product / u128::from(max_abs),
None => width_u,
};
let len = usize::try_from(scaled).unwrap_or(width).min(width);
"█".repeat(len)
}
fn draw_fills(frame: &mut Frame, area: Rect, theme: Theme, loaded: &LoadedReplay) {
let block = Block::bordered().title(Span::styled("fills", theme.accent()));
let inner = block.inner(area);
frame.render_widget(block, area);
let detail_height = if loaded.selection.is_some() {
FILL_DETAIL_HEIGHT
} else {
0
};
let [list_area, detail_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(detail_height)]).areas(inner);
draw_fills_list(frame, list_area, theme, loaded);
if loaded.selection.is_some() {
draw_fill_detail(frame, detail_area, theme, loaded);
}
}
fn draw_fills_list(frame: &mut Frame, area: Rect, theme: Theme, loaded: &LoadedReplay) {
let visible = loaded.cursor.visible_fills(&loaded.bundle);
if visible.is_empty() {
draw_centered_note(frame, area, theme, "no fills up to this step");
return;
}
let rows = usize::from(area.height);
if rows == 0 {
return;
}
let selection = loaded.selection.as_ref();
let selected_ix = loaded.selected_fill_index();
let start = fills_window_start(visible.len(), rows, selected_ix);
let lines: Vec<Line<'static>> = visible
.iter()
.skip(start)
.take(rows)
.map(|fill| fill_line(fill, is_selected(selection, fill), theme))
.collect();
frame.render_widget(Paragraph::new(Text::from(lines)), area);
}
#[must_use]
fn fills_window_start(total: usize, rows: usize, selected: Option<usize>) -> usize {
if rows == 0 || total <= rows {
return 0;
}
let default_start = total - rows;
match selected {
Some(sel) if sel < default_start => sel,
_ => default_start,
}
}
fn fill_line(fill: &Fill, selected: bool, theme: Theme) -> Line<'static> {
let marker = if selected { "▸ " } else { " " };
let text = format!(
"{marker}{:>5} {:<5} {}× {} {} @ {}",
fill.step,
side_label(fill.side),
fill.quantity,
sanitize(&fill.underlying),
style_glyph(fill.style),
fmt_cents_abs(fill.price_cents),
);
let line = Line::from(Span::raw(text));
if selected {
line.style(theme.accent().add_modifier(Modifier::BOLD))
} else {
line
}
}
fn draw_fill_detail(frame: &mut Frame, area: Rect, theme: Theme, loaded: &LoadedReplay) {
let Some(fill) = loaded.selection.as_ref() else {
return;
};
let lines = vec![
Line::from(vec![
Span::styled("▸ ", theme.accent()),
Span::raw(sanitize(&fill.contract_id)),
]),
Line::from(vec![
Span::raw(format!("{} {}× @ ", side_label(fill.side), fill.quantity)),
Span::raw(fmt_cents_abs(fill.price_cents)),
]),
Line::from(vec![
Span::styled("fees ", theme.dim()),
Span::raw(fmt_cents_abs(fill.fees_cents)),
Span::styled(" slip ", theme.dim()),
Span::raw(fmt_signed_cents(fill.slippage_cents)),
Span::styled(" ", theme.dim()),
Span::raw(exec_mode_label(fill.mode).to_owned()),
]),
];
frame.render_widget(Paragraph::new(Text::from(lines)), area);
}
fn draw_centered_note(frame: &mut Frame, area: Rect, theme: Theme, message: &str) {
let text = Text::from(Line::from(Span::styled(message.to_owned(), theme.dim())));
let height = 1u16.min(area.height);
let [centered] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(area);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), centered);
}
fn is_selected(selection: Option<&Fill>, fill: &Fill) -> bool {
selection.is_some_and(|selected| {
selected.step == fill.step
&& selected.order_id == fill.order_id
&& selected.fill_seq == fill.fill_seq
})
}
fn side_label(side: PositionSide) -> &'static str {
match side {
PositionSide::Long => "LONG",
PositionSide::Short => "SHORT",
}
}
fn style_glyph(style: OptionStyle) -> &'static str {
match style {
OptionStyle::Call => "C",
OptionStyle::Put => "P",
}
}
fn exec_mode_label(mode: ExecMode) -> &'static str {
match mode {
ExecMode::Naive => "naive",
ExecMode::Realistic => "realistic",
}
}
fn fmt_cents_abs(magnitude: u64) -> String {
let dollars = magnitude / 100;
let cents = magnitude % 100;
format!("${}.{cents:02}", group_thousands(dollars))
}
fn fmt_signed_cents(cents: i64) -> String {
let sign = if cents < 0 { '−' } else { '+' };
format!("{sign}{}", fmt_cents_abs(cents.unsigned_abs()))
}
fn fmt_drawdown_cents(cents: i64) -> String {
if cents == 0 {
return fmt_cents_abs(0);
}
format!("−{}", fmt_cents_abs(cents.unsigned_abs()))
}
fn fmt_drawdown_ratio(ratio: Option<f64>) -> String {
match ratio {
Some(value) if value.is_finite() => {
let pct = value * 100.0;
format!("{}{:.1}%", pnl_sign_char(pct < 0.0), pct.abs())
}
_ => EM_DASH.to_owned(),
}
}
fn fmt_axis_cents(cents: f64) -> String {
if !cents.is_finite() {
return EM_DASH.to_owned();
}
let dollars = cents / 100.0;
let sign = if dollars < 0.0 { "−" } else { "" };
let digits = format!("{:.0}", dollars.abs());
format!("{sign}${}", group_digits(&digits))
}
fn fmt_step(value: f64) -> String {
if !value.is_finite() {
return EM_DASH.to_owned();
}
format!("{:.0}", value.max(0.0))
}
fn group_thousands(value: u64) -> String {
group_digits(&value.to_string())
}
fn group_digits(digits: &str) -> String {
let bytes = digits.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (index, byte) in bytes.iter().enumerate() {
if index > 0 && (len - index).is_multiple_of(3) {
out.push(',');
}
out.push(char::from(*byte));
}
out
}
#[must_use]
pub fn handle_key(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 => {
let _ = state.step_fill(false);
None
}
ReplayAction::NextFill => {
let _ = state.step_fill(true);
None
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use optionstratlib::OptionStyle;
use optionstratlib::visualization::{GraphData, Series2D};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Line;
use super::{
ATTRIB_LABEL_WIDTH, AttribRow, attribution_line, bar_string, draw, fill_line,
fmt_axis_cents, fmt_cents_abs, fmt_drawdown_cents, fmt_drawdown_ratio, fmt_signed_cents,
group_thousands, handle_key,
};
use crate::app::tests_support::loaded_bundle;
use crate::app::{BundleLoad, ReplayState};
use crate::config::ThemeChoice;
use crate::event::{BundleLoadResult, SeekTo};
use crate::replay::{
BundleManifest, EquityPoint, ExecMode, Fill, GreeksAttribution, LoadedBundle, 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 CID: &str = "v1:BTC:1735286400000000000:6000000:C";
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-xyz".to_owned(),
created_utc: "2026-07-17T00:00:00Z".to_owned(),
code_version: "0.3.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, equity_cents: i64, drawdown: f64) -> EquityPoint {
EquityPoint {
step,
ts_ns: BASE_TS + i64::from(step),
cash_cents: equity_cents,
position_value_cents: 0,
equity_cents,
drawdown,
}
}
fn greeks(step: u32) -> GreeksAttribution {
GreeksAttribution {
step,
ts_ns: BASE_TS + i64::from(step),
theta_pnl_cents: 193_000,
delta_pnl_cents: -42_000,
vega_pnl_cents: 31_000,
spread_capture_cents: 18_000,
fees_cents: 500,
residual_cents: -6_000,
}
}
fn fill(step: u32, order_id: u64, side: PositionSide) -> Fill {
Fill {
step,
ts_ns: BASE_TS + i64::from(step),
strategy_run_id: "run-xyz".to_owned(),
trade_id: order_id,
position_id: order_id,
order_id,
fill_seq: 0,
underlying: "BTC".to_owned(),
expiration_ns: 1_735_286_400_000_000_000,
contract_id: CID.to_owned(),
strike_cents: 6_000_000,
style: OptionStyle::Call,
side,
quantity: 1,
price_cents: 235,
fees_cents: 30,
slippage_cents: -15,
mode: ExecMode::Realistic,
}
}
fn rich_bundle(n: u32) -> LoadedBundle {
let fills = vec![
fill(0, 10, PositionSide::Short),
fill(0, 11, PositionSide::Long),
fill(1, 20, PositionSide::Long),
];
LoadedBundle {
manifest: manifest(),
fills,
equity: (0..n)
.map(|s| equity(s, 1_000 + i64::from(s) * 10, -0.02))
.collect(),
positions: Vec::new(),
greeks: (0..n).map(greeks).collect(),
}
}
fn many_fills_bundle(n: u32) -> LoadedBundle {
LoadedBundle {
manifest: manifest(),
fills: (0..n)
.map(|s| {
let mut f = fill(s, u64::from(s) + 100, PositionSide::Long);
f.quantity = s.checked_add(1).unwrap_or(1);
f
})
.collect(),
equity: (0..n)
.map(|s| equity(s, 1_000 + i64::from(s) * 10, -0.02))
.collect(),
positions: Vec::new(),
greeks: (0..n).map(greeks).collect(),
}
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn theme() -> Theme {
Theme::resolve(ThemeChoice::Auto, false)
}
fn state_from(bundle: LoadedBundle) -> ReplayState {
let mut state = ReplayState::new(PathBuf::from("/tmp/run-xyz"));
state.apply_load_result(BundleLoadResult::Loaded(Box::new(bundle)));
state
}
fn equity_projection(state: &ReplayState) -> GraphProjection {
match state.loaded() {
Some(loaded) => project(loaded.equity_graph()),
None => project(&GraphData::Series(Series2D::default())),
}
}
#[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 rendered(state: &ReplayState, tick: u64, width: u16, height: u16) -> String {
let projection = equity_projection(state);
let mut term = terminal(width, height);
match term.draw(|frame| {
let area = frame.area();
draw(state, &projection, frame, area, theme(), tick);
}) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
term.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn loading_state() -> ReplayState {
ReplayState::new(PathBuf::from("/tmp/run-2025"))
}
fn error_state(message: &str) -> ReplayState {
let mut state = ReplayState::new(PathBuf::from("/tmp/run-2025"));
state.apply_load_result(BundleLoadResult::Failed(message.to_owned()));
state
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_draw_loading_shows_spinner_and_run_label() {
let state = loading_state();
assert!(matches!(state.bundle, BundleLoad::Loading));
let text = rendered(&state, 0, 80, 24);
assert!(text.contains("loading bundle"), "loading label present");
assert!(text.contains("run-2025"), "the run/dir label is shown");
assert!(text.contains('⠋'), "the loading spinner is shown");
}
#[test]
fn test_draw_error_shows_message_and_retry_hint() {
let state = error_state("manifest.json is malformed");
let text = rendered(&state, 0, 80, 24);
assert!(
text.contains("manifest.json is malformed"),
"the error message is shown"
);
assert!(
text.contains("press R to retry"),
"the R retry affordance is shown"
);
}
#[test]
fn test_draw_ready_shows_equity_attribution_and_fills() {
let mut state = state_from(rich_bundle(6));
let _ = state.seek(SeekTo::Step(u32::MAX));
let text = rendered(&state, 0, 120, 40);
assert!(text.contains("equity"), "the equity panel is titled");
assert!(text.contains("drawdown"), "the drawdown panel is shown");
assert!(
text.contains("P&L attribution"),
"the attribution panel is shown"
);
assert!(text.contains("theta"), "a by-Greek term label is shown");
assert!(text.contains("fills"), "the fills panel is shown");
assert!(
text.contains("$1,930.00"),
"theta cents render as $1,930.00: {text:?}"
);
assert!(text.contains('−'), "a negative term shows the − glyph");
}
#[test]
fn test_draw_ready_empty_run_renders_deliberate_empty_states() {
let state = state_from(loaded_bundle(0));
assert!(matches!(state.bundle, BundleLoad::Ready(_)));
let text = rendered(&state, 0, 120, 40);
assert!(
text.contains("no equity rows"),
"the equity empty state is shown"
);
assert!(text.contains("no fills"), "the fills empty state is shown");
assert!(text.contains('—'), "absent attribution renders an em dash");
}
#[test]
fn test_draw_never_panics_over_states_and_small_sizes() {
let states = [
loading_state(),
error_state(
"bundle failed: a long error message that must wrap across a narrow \
body without panicking or clipping the retry affordance off-screen",
),
state_from(loaded_bundle(0)),
state_from(rich_bundle(6)),
];
for state in &states {
for (w, h) in [(1u16, 1u16), (5, 3), (12, 4), (40, 8), (80, 24), (200, 60)] {
let _ = rendered(state, 7, w, h);
}
}
}
#[test]
fn test_next_fill_selects_and_steps_over_visible_fills() {
let mut state = state_from(rich_bundle(6));
let _ = state.seek(SeekTo::Step(u32::MAX)); let follow = handle_key(&mut state, press(KeyCode::Char('.')));
assert!(
follow.is_none(),
"a drill-down key emits no follow-on event"
);
match state.loaded().and_then(|l| l.selection.as_ref()) {
Some(fill) => assert_eq!(fill.step, 1, "`.` from none selects the most recent fill"),
None => panic!("`.` must select a fill"),
}
let _ = handle_key(&mut state, press(KeyCode::Char(',')));
match state.loaded().and_then(|l| l.selection.as_ref()) {
Some(fill) => {
assert_eq!(fill.step, 0);
assert_eq!(fill.order_id, 11, "`,` steps back one fill");
}
None => panic!("selection must persist"),
}
}
#[test]
fn test_prev_next_fill_clamp_at_the_ends() {
let mut state = state_from(rich_bundle(6));
let _ = state.seek(SeekTo::Step(u32::MAX));
for _ in 0..8 {
let _ = handle_key(&mut state, press(KeyCode::Char(',')));
}
match state.loaded().and_then(|l| l.selection.as_ref()) {
Some(fill) => assert_eq!(fill.order_id, 10, "`,` clamps at the oldest fill"),
None => panic!("selection must exist"),
}
for _ in 0..8 {
let _ = handle_key(&mut state, press(KeyCode::Char('.')));
}
match state.loaded().and_then(|l| l.selection.as_ref()) {
Some(fill) => assert_eq!(fill.step, 1, "`.` clamps at the newest fill"),
None => panic!("selection must exist"),
}
}
#[test]
fn test_drill_down_on_empty_fills_is_a_safe_noop() {
let mut state = state_from(loaded_bundle(4));
let _ = handle_key(&mut state, press(KeyCode::Char('.')));
assert!(
state.loaded().and_then(|l| l.selection.as_ref()).is_none(),
"no fills → no selection",
);
}
#[test]
fn test_selection_follows_the_head_when_scrubbing_back() {
let mut state = state_from(rich_bundle(6));
let _ = state.seek(SeekTo::Step(u32::MAX));
let _ = handle_key(&mut state, press(KeyCode::Char('.'))); assert!(state.loaded().and_then(|l| l.selection.as_ref()).is_some());
let _ = state.seek(SeekTo::Step(0)); assert!(
state.loaded().and_then(|l| l.selection.as_ref()).is_none(),
"the selection follows the visible fills at the cursor (clamped to None)",
);
}
#[test]
fn test_off_window_selection_scrolls_into_view() {
let mut state = state_from(many_fills_bundle(12));
let _ = state.seek(SeekTo::Step(u32::MAX));
for _ in 0..20 {
let _ = handle_key(&mut state, press(KeyCode::Char(',')));
}
match state.loaded().and_then(|l| l.selection.as_ref()) {
Some(fill) => assert_eq!(fill.step, 0, "`,` clamps at the oldest fill"),
None => panic!("a selection must exist"),
}
let selected = match state.loaded().and_then(|l| l.selection.clone()) {
Some(f) => f,
None => panic!("a selection must exist"),
};
let newest = match state.loaded().and_then(|l| l.bundle.fills.last().cloned()) {
Some(f) => f,
None => panic!("the bundle has fills"),
};
let text = rendered(&state, 0, 120, 16);
let selected_row = line_text(&fill_line(&selected, true, theme()));
let newest_row = line_text(&fill_line(&newest, false, theme()));
assert!(
!text.contains("selected fill ↑"),
"the earlier off-window indicator is gone — the list follows the selection: {text:?}",
);
assert!(
text.contains(&selected_row),
"the selected step-0 fill scrolled into view, highlighted ({selected_row:?}): {text:?}",
);
assert!(
!text.contains(&newest_row),
"the newest fill (step 11) scrolled off the top-anchored window: {text:?}",
);
assert!(
text.contains("v1:BTC"),
"the detail panel still shows the selected fill: {text:?}"
);
}
#[test]
fn test_in_window_selection_stays_bottom_anchored() {
let mut state = state_from(many_fills_bundle(12));
let _ = state.seek(SeekTo::Step(u32::MAX));
let _ = handle_key(&mut state, press(KeyCode::Char('.')));
let newest = match state.loaded().and_then(|l| l.bundle.fills.last().cloned()) {
Some(f) => f,
None => panic!("the bundle has fills"),
};
let oldest = match state.loaded().and_then(|l| l.bundle.fills.first().cloned()) {
Some(f) => f,
None => panic!("the bundle has fills"),
};
let text = rendered(&state, 0, 120, 16);
assert!(
!text.contains("selected fill ↑"),
"no off-window indicator in the follow model: {text:?}"
);
assert!(
text.contains(&line_text(&fill_line(&newest, true, theme()))),
"the newest fill stays highlighted at the bottom-anchored window: {text:?}",
);
assert!(
!text.contains(&line_text(&fill_line(&oldest, false, theme()))),
"the oldest fill is not pulled on-screen: {text:?}",
);
}
#[test]
fn test_fills_window_start_follows_selection() {
use super::fills_window_start;
assert_eq!(fills_window_start(3, 10, Some(0)), 0);
assert_eq!(fills_window_start(10, 10, None), 0);
assert_eq!(fills_window_start(20, 0, Some(5)), 0);
assert_eq!(fills_window_start(20, 5, None), 15);
assert_eq!(
fills_window_start(20, 5, Some(18)),
15,
"in-window keeps anchor"
);
assert_eq!(
fills_window_start(20, 5, Some(15)),
15,
"at the window top edge"
);
assert_eq!(
fills_window_start(20, 5, Some(3)),
3,
"scrolls to the selection"
);
assert_eq!(
fills_window_start(20, 5, Some(0)),
0,
"oldest → top of the list"
);
}
#[test]
fn test_selected_fill_index_is_cached_off_draw() {
let mut state = state_from(many_fills_bundle(12));
let _ = state.seek(SeekTo::Step(u32::MAX)); assert_eq!(
state
.loaded()
.and_then(crate::app::LoadedReplay::selected_fill_index),
None,
"no selection → no cached index",
);
let _ = handle_key(&mut state, press(KeyCode::Char('.')));
assert_eq!(
state
.loaded()
.and_then(crate::app::LoadedReplay::selected_fill_index),
Some(11),
"`.` caches the newest fill's tape index",
);
let _ = handle_key(&mut state, press(KeyCode::Char(',')));
assert_eq!(
state
.loaded()
.and_then(crate::app::LoadedReplay::selected_fill_index),
Some(10),
"`,` decrements the cached index",
);
match state.loaded() {
Some(loaded) => {
let visible = loaded.cursor.visible_fills(&loaded.bundle);
let scanned = loaded.selection_key().and_then(|key| {
visible
.iter()
.position(|f| (f.step, f.order_id, f.fill_seq) == key)
});
assert_eq!(
loaded.selected_fill_index(),
scanned,
"the cached index equals a fresh position scan",
);
}
None => panic!("the bundle is loaded"),
}
let _ = state.seek(SeekTo::Step(5));
assert_eq!(
state
.loaded()
.and_then(crate::app::LoadedReplay::selected_fill_index),
None,
"a selection scrubbed out of the as-of window clears its cached index",
);
assert!(
state.loaded().and_then(|l| l.selection.as_ref()).is_none(),
"the selection clears together with its index",
);
}
#[test]
fn test_seek_bumps_equity_revision_but_a_clamped_noop_does_not() {
let mut state = state_from(rich_bundle(6));
let rev0 = state
.loaded()
.map(crate::app::LoadedReplay::equity_revision)
.unwrap_or_default();
let moved = state.seek(SeekTo::Step(3));
assert!(moved, "a real seek moves the cursor");
let rev1 = state
.loaded()
.map(crate::app::LoadedReplay::equity_revision)
.unwrap_or_default();
assert_ne!(
rev0, rev1,
"a cursor move bumps the equity revision (re-project)"
);
let noop = state.seek(SeekTo::Step(3));
assert!(!noop, "seeking to the same step is a no-op");
let rev2 = state
.loaded()
.map(crate::app::LoadedReplay::equity_revision)
.unwrap_or_default();
assert_eq!(rev1, rev2, "a no-op seek does not re-project");
}
fn manifest_with_capital(initial_capital: u64) -> BundleManifest {
let mut m = manifest();
m.config = serde_json::json!({ "initial_capital": initial_capital });
m
}
fn capital_bundle(n: u32, initial_capital: u64) -> LoadedBundle {
LoadedBundle {
manifest: manifest_with_capital(initial_capital),
fills: Vec::new(),
equity: (0..n)
.map(|s| {
let wobble = (i64::from(s % 41) - 20) * 1_500;
equity(s, 1_000_000 + wobble, 0.0)
})
.collect(),
positions: Vec::new(),
greeks: Vec::new(),
}
}
#[test]
fn test_step0_loss_shows_drawdown_vs_opening_capital() {
let bundle = LoadedBundle {
manifest: manifest_with_capital(1_000_000),
fills: Vec::new(),
equity: vec![equity(0, 990_000, -0.01), equity(1, 1_050_000, 0.0)],
positions: Vec::new(),
greeks: Vec::new(),
};
let state = state_from(bundle);
assert_eq!(
state
.loaded()
.map(crate::app::LoadedReplay::peak_drawdown_cents),
Some(-10_000),
"a step-0 loss shows its true drawdown vs the opening capital, not $0",
);
}
#[test]
fn test_forward_steps_match_a_direct_seek_across_a_stride_boundary() {
let n = 600u32;
let cap = 1_000_000u64;
let mut stepper = state_from(capital_bundle(n, cap));
for _ in 0..n {
let _ = stepper.seek(SeekTo::StepBy(1));
}
let mut seeker = state_from(capital_bundle(n, cap));
let _ = seeker.seek(SeekTo::Step(u32::MAX));
let step_peak = stepper
.loaded()
.map(crate::app::LoadedReplay::peak_drawdown_cents);
let seek_peak = seeker
.loaded()
.map(crate::app::LoadedReplay::peak_drawdown_cents);
assert_eq!(
step_peak, seek_peak,
"the incremental forward peak equals the arbitrary-seek rebuild peak",
);
let step_series = match stepper.loaded().map(crate::app::LoadedReplay::equity_graph) {
Some(GraphData::Series(s)) => (s.x.clone(), s.y.clone()),
other => panic!("expected a Series, got {other:?}"),
};
let seek_series = match seeker.loaded().map(crate::app::LoadedReplay::equity_graph) {
Some(GraphData::Series(s)) => (s.x.clone(), s.y.clone()),
other => panic!("expected a Series, got {other:?}"),
};
assert_eq!(
step_series, seek_series,
"the incremental forward series equals the arbitrary-seek rebuild series",
);
}
#[test]
fn test_backward_seek_rebuilds_the_drawdown_from_the_seed() {
let n = 40u32;
let cap = 1_000_000u64;
let mut state = state_from(capital_bundle(n, cap));
let _ = state.seek(SeekTo::Step(u32::MAX)); let _ = state.seek(SeekTo::Step(9));
let mut reference = state_from(capital_bundle(n, cap));
let _ = reference.seek(SeekTo::Step(9));
assert_eq!(
state
.loaded()
.map(crate::app::LoadedReplay::peak_drawdown_cents),
reference
.loaded()
.map(crate::app::LoadedReplay::peak_drawdown_cents),
"the backward rebuild reproduces the drawdown at the earlier head",
);
}
#[test]
fn test_fmt_cents_abs_groups_thousands_with_two_decimals() {
assert_eq!(fmt_cents_abs(0), "$0.00");
assert_eq!(fmt_cents_abs(5), "$0.05");
assert_eq!(fmt_cents_abs(235), "$2.35");
assert_eq!(fmt_cents_abs(193_000), "$1,930.00");
assert_eq!(fmt_cents_abs(123_456_789), "$1,234,567.89");
}
#[test]
fn test_group_thousands() {
assert_eq!(group_thousands(0), "0");
assert_eq!(group_thousands(12), "12");
assert_eq!(group_thousands(1_000), "1,000");
assert_eq!(group_thousands(1_234_567), "1,234,567");
}
#[test]
fn test_fmt_signed_cents_carries_the_sign_glyph() {
assert_eq!(fmt_signed_cents(0), "+$0.00");
assert_eq!(fmt_signed_cents(42), "+$0.42");
assert_eq!(fmt_signed_cents(-15), "−$0.15");
let _ = fmt_signed_cents(i64::MIN);
}
#[test]
fn test_fmt_drawdown_cents_signs_only_the_negative() {
assert_eq!(
fmt_drawdown_cents(0),
"$0.00",
"a peak reads $0.00, no sign"
);
assert_eq!(fmt_drawdown_cents(-124_000), "−$1,240.00");
}
#[test]
fn test_fmt_drawdown_ratio_guards_nan_inf_and_absent() {
assert_eq!(fmt_drawdown_ratio(Some(-0.032)), "−3.2%");
assert!(fmt_drawdown_ratio(Some(-0.032)).starts_with('\u{2212}'));
assert_eq!(fmt_drawdown_ratio(Some(0.0)), "+0.0%");
assert_eq!(fmt_drawdown_ratio(None), "—", "absent renders an em dash");
assert_eq!(fmt_drawdown_ratio(Some(f64::NAN)), "—", "NaN is guarded");
assert_eq!(
fmt_drawdown_ratio(Some(f64::INFINITY)),
"—",
"Inf is guarded"
);
}
#[test]
fn test_fmt_axis_cents_groups_thousands_and_guards_non_finite() {
assert_eq!(fmt_axis_cents(100_000.0), "$1,000");
assert_eq!(fmt_axis_cents(100_000_000.0), "$1,000,000");
assert_eq!(fmt_axis_cents(0.0), "$0");
assert_eq!(fmt_axis_cents(-100_000.0), "−$1,000");
assert_eq!(fmt_axis_cents(f64::NAN), "—");
assert_eq!(fmt_axis_cents(f64::INFINITY), "—");
}
#[test]
fn test_attribution_line_elides_amount_at_narrow_width() {
let theme = theme();
let row = AttribRow::signed("Θ theta", 193_000); let wide = line_text(&attribution_line(&row, 193_000, 48, theme));
assert!(
wide.contains("$1,930.00"),
"a wide panel shows the complete amount: {wide:?}"
);
let medium = line_text(&attribution_line(&row, 193_000, 26, theme));
assert!(
medium.contains("$1,930.00") && !medium.contains('…'),
"the bar drops before the amount is elided: {medium:?}"
);
let narrow = line_text(&attribution_line(&row, 193_000, 16, theme));
assert!(
narrow.contains('…'),
"a narrow panel visibly elides the amount: {narrow:?}"
);
assert!(
!narrow.contains("$1,930.00"),
"the complete amount does not fit at a narrow width: {narrow:?}"
);
}
#[test]
fn test_attribution_line_width_floor_holds_at_the_boundary() {
let theme = theme();
let row = AttribRow::signed("Θ theta", 193_000); for w in [0usize, ATTRIB_LABEL_WIDTH + 1] {
let text = line_text(&attribution_line(&row, 193_000, w, theme));
assert!(
text.starts_with("Θ theta"),
"the label still renders at width {w}: {text:?}",
);
assert!(
!text.contains("$1,930.00"),
"no room for the amount at width {w}: {text:?}",
);
}
let tight = line_text(&attribution_line(
&row,
193_000,
ATTRIB_LABEL_WIDTH + 2,
theme,
));
assert!(
tight.contains('…'),
"one cell past the sign elides to a marker: {tight:?}",
);
}
#[test]
fn test_bar_string_scales_and_clamps() {
assert_eq!(bar_string(0, 0, 10), "", "no scale → empty");
assert_eq!(
bar_string(5, 10, 10).chars().count(),
5,
"half of ten cells"
);
assert_eq!(bar_string(10, 10, 10).chars().count(), 10, "full bar");
assert_eq!(
bar_string(20, 10, 10).chars().count(),
10,
"clamped at width"
);
let _ = bar_string(u64::MAX, u64::MAX, 12);
}
#[test]
fn test_draw_does_not_mutate_state_or_reproject() {
let mut state = state_from(rich_bundle(6));
let _ = state.seek(SeekTo::Step(u32::MAX));
let _ = handle_key(&mut state, press(KeyCode::Char('.')));
let rev_before = state
.loaded()
.map(crate::app::LoadedReplay::equity_revision)
.unwrap_or_default();
let sel_before = state.loaded().and_then(|l| l.selection_key());
let _ = rendered(&state, 3, 120, 40);
let rev_after = state
.loaded()
.map(crate::app::LoadedReplay::equity_revision)
.unwrap_or_default();
let sel_after = state.loaded().and_then(|l| l.selection_key());
assert_eq!(
rev_before, rev_after,
"draw must not re-project the equity series"
);
assert_eq!(sel_before, sel_after, "draw must not move the selection");
}
}