use std::path::PathBuf;
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::model::Position;
use optionstratlib::prelude::{BasicAxisTypes, Decimal, Positive};
use optionstratlib::visualization::GraphData;
use optionstratlib::{ExpirationDate, OptionStyle};
use tokio::sync::mpsc;
use crate::app::keymap::{GlobalCommand, KeyChord, resolve_global};
use crate::chain::quote_is_stale;
use crate::chain::{
ChainFetch, ChainSnapshot, ChainStore, DepthLadder, DepthStore, ExpirySource, InstrumentKey,
MarketUpdate, MergeOutcome, ProviderId, QuoteClocks, StreamHealth,
};
use crate::config::ThemeChoice;
use crate::event::{AppEvent, BundleLoadResult, Command, ReplayControl, SeekTo};
use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
use crate::replay::{Fill, LoadedBundle, Playback, PlaybackSpeed, TimelineCursor};
mod bridge;
pub(crate) mod keymap;
mod payoff_build;
mod registry;
mod replay_load;
mod replay_payoff_build;
mod replay_view;
mod supervisor;
mod surface_build;
use replay_view::EquityGeometry;
pub use bridge::{BridgeSenders, COMMAND_CHANNEL_CAPACITY, CONTROL_CHANNEL_CAPACITY, EventBridge};
pub use registry::{
ChainViewApp, ChainViewAppBuilder, ProviderSubscription, Resolved,
spawn_supervised_subscription,
};
pub use replay_load::spawn_bundle_load;
pub use supervisor::{
DEFAULT_JOIN_BUDGET, ExitCause, ExitReporter, FinalTeardown, GuardTeardown, SupervisedTask,
Supervisor, TaskExit, TokioTask,
};
pub const HINT_TICKS: u8 = 8;
const RELOAD_UNAVAILABLE_HINT: &str = "reload unavailable: data layer not responding";
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub theme: ThemeChoice,
pub no_color: bool,
pub status: StatusLine,
pub tick_count: u64,
pub now: DateTime<Utc>,
pub status_hint: Option<String>,
pub hint_ttl: u8,
pub help_open: bool,
pub should_quit: bool,
pub dirty: bool,
tx_command: mpsc::Sender<Command>,
pub commands_dropped: u64,
}
impl App {
#[must_use]
pub fn new(mode: Mode, theme: ThemeChoice, tx_command: mpsc::Sender<Command>) -> Self {
Self {
mode,
theme,
no_color: false,
status: StatusLine::default(),
tick_count: 0,
now: now_utc(),
status_hint: None,
hint_ttl: 0,
help_open: false,
should_quit: false,
dirty: true,
tx_command,
commands_dropped: 0,
}
}
#[must_use]
pub fn with_no_color(mut self, no_color: bool) -> Self {
self.no_color = no_color;
self
}
pub fn on_event(&mut self, event: AppEvent) {
match event {
AppEvent::Key(key) => self.on_key(key),
AppEvent::Resize(width, height) => self.on_resize(width, height),
AppEvent::Tick => self.on_tick(),
AppEvent::Market(update) => self.on_market(update),
AppEvent::ReplaySeek(seek) => self.on_replay_seek(seek),
AppEvent::ReplayControl(control) => self.on_replay_control(control),
AppEvent::BundleLoaded(result) => self.on_bundle_loaded(result),
}
}
pub fn mark_drawn(&mut self) {
self.dirty = false;
}
fn on_key(&mut self, key: KeyEvent) {
let _ = self.dispatch_key_global(key);
}
#[must_use = "the route decides whether the active screen also handles the key"]
pub(crate) fn dispatch_key_global(&mut self, key: KeyEvent) -> KeyRoute {
if key.kind != KeyEventKind::Press {
return KeyRoute::Consumed;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.request_quit();
return KeyRoute::Consumed;
}
if self.help_open {
if matches!(
KeyChord::from_event(key),
Some(KeyChord::Char('?') | KeyChord::Esc)
) {
self.toggle_help();
}
return KeyRoute::Consumed;
}
let Some(chord) = KeyChord::from_event(key) else {
return KeyRoute::ToScreen;
};
self.clear_status_hint();
match resolve_global(chord) {
Some(command) => {
self.apply_global_command(command);
KeyRoute::Consumed
}
None => KeyRoute::ToScreen,
}
}
fn apply_global_command(&mut self, command: GlobalCommand) {
match command {
GlobalCommand::Quit => self.request_quit(),
GlobalCommand::ToggleHelp => self.toggle_help(),
GlobalCommand::Reconnect => self.request_reconnect(),
GlobalCommand::Rediscover => self.request_rediscover(),
GlobalCommand::SwitchScreen(slot) => self.request_switch_screen(slot),
GlobalCommand::NextScreen => self.cycle_screen(true),
GlobalCommand::PrevScreen => self.cycle_screen(false),
}
}
fn on_resize(&mut self, _width: u16, _height: u16) {
self.dirty = true;
}
fn on_tick(&mut self) {
self.now = now_utc();
self.tick_count = self.tick_count.checked_add(1).unwrap_or(0);
let mut needs_redraw = false;
if self.tick_hint() {
needs_redraw = true;
}
if self.advance_replay_playback() {
needs_redraw = true;
}
if self.is_in_motion() {
needs_redraw = true;
}
if needs_redraw {
self.dirty = true;
}
}
fn tick_hint(&mut self) -> bool {
if self.status_hint.is_none() {
return false;
}
match self.hint_ttl.checked_sub(1) {
Some(remaining) if remaining > 0 => {
self.hint_ttl = remaining;
false
}
_ => {
self.status_hint = None;
self.hint_ttl = 0;
true
}
}
}
#[must_use]
fn is_in_motion(&self) -> bool {
match &self.mode {
Mode::Live(live) => {
matches!(live.load, ScreenLoad::Loading)
|| matches!(live.source.health, StreamHealth::Reconnecting { .. })
}
Mode::Replay(replay) => {
matches!(replay.bundle, BundleLoad::Loading)
|| matches!(replay.play, Playback::Playing { .. })
}
}
}
fn on_market(&mut self, update: MarketUpdate) {
let changed = match &mut self.mode {
Mode::Live(live) => live.apply_market(update),
Mode::Replay(_) => false,
};
if changed {
self.dirty = true;
}
}
fn on_replay_seek(&mut self, seek: SeekTo) {
match &mut self.mode {
Mode::Live(_) => {}
Mode::Replay(replay) => {
if replay.seek(seek) {
self.dirty = true;
}
}
}
}
fn on_replay_control(&mut self, control: ReplayControl) {
match &mut self.mode {
Mode::Live(_) => {}
Mode::Replay(replay) => {
if replay.apply_control(control) {
self.dirty = true;
}
}
}
}
fn on_bundle_loaded(&mut self, result: BundleLoadResult) {
match &mut self.mode {
Mode::Live(_) => {}
Mode::Replay(replay) => {
replay.apply_load_result(result);
self.dirty = true;
}
}
}
fn advance_replay_playback(&mut self) -> bool {
match &mut self.mode {
Mode::Live(_) => false,
Mode::Replay(replay) => replay.advance_playback(),
}
}
fn request_quit(&mut self) {
self.should_quit = true;
self.dirty = true;
}
fn toggle_help(&mut self) {
self.help_open = !self.help_open;
self.dirty = true;
}
fn request_reconnect(&mut self) {
match &self.mode {
Mode::Live(_) => {
let _ = self.send_command(Command::Reconnect);
}
Mode::Replay(_) => {}
}
}
fn request_rediscover(&mut self) {
match &mut self.mode {
Mode::Live(_) => {
let _ = self.send_command(Command::Rediscover);
}
Mode::Replay(replay) => {
let dir = replay.dir.clone();
if self.send_command(Command::ReloadBundle(dir)) {
if let Mode::Replay(replay) = &mut self.mode {
replay.begin_reload();
}
self.dirty = true;
} else {
self.set_status_hint(RELOAD_UNAVAILABLE_HINT.to_owned());
}
}
}
}
fn send_command(&mut self, command: Command) -> bool {
if self.tx_command.try_send(command).is_err() {
if let Some(next) = self.commands_dropped.checked_add(1) {
self.commands_dropped = next;
}
false
} else {
true
}
}
fn request_switch_screen(&mut self, slot: u8) {
let outcome = match &mut self.mode {
Mode::Live(live) => match live_screen_for_slot(slot) {
Some(screen) if live.screen == screen => SwitchOutcome::NoChange,
Some(screen) if live.screen_reachable(screen) => {
live.enter_screen(screen);
SwitchOutcome::Switched
}
Some(screen) => SwitchOutcome::Unavailable(format!(
"{} not available on {}",
live_screen_name(screen),
live.source.provider.as_str()
)),
None => SwitchOutcome::Unavailable(format!("no screen bound to {slot}")),
},
Mode::Replay(replay) => match replay_screen_for_slot(slot) {
Some(screen) if replay.screen == screen => SwitchOutcome::NoChange,
Some(screen) if is_replay_screen_reachable(screen) => {
replay.screen = screen;
SwitchOutcome::Switched
}
Some(screen) => SwitchOutcome::Unavailable(replay_unavailable_hint(screen)),
None => SwitchOutcome::Unavailable(format!("no screen bound to {slot}")),
},
};
match outcome {
SwitchOutcome::Switched => self.dirty = true,
SwitchOutcome::NoChange => {}
SwitchOutcome::Unavailable(hint) => self.set_status_hint(hint),
}
}
fn cycle_screen(&mut self, forward: bool) {
let changed = match &mut self.mode {
Mode::Live(live) => {
let reachable: Vec<LiveScreen> = LIVE_SCREEN_ORDER
.into_iter()
.filter(|screen| live.screen_reachable(*screen))
.collect();
match next_in_cycle(&reachable, live.screen, forward) {
Some(next) if next != live.screen => {
live.enter_screen(next);
true
}
_ => false,
}
}
Mode::Replay(replay) => {
let reachable: Vec<ReplayScreen> = REPLAY_SCREEN_ORDER
.into_iter()
.filter(|screen| is_replay_screen_reachable(*screen))
.collect();
match next_in_cycle(&reachable, replay.screen, forward) {
Some(next) if next != replay.screen => {
replay.screen = next;
true
}
_ => false,
}
}
};
if changed {
self.dirty = true;
}
}
fn set_status_hint(&mut self, hint: String) {
self.status_hint = Some(hint);
self.hint_ttl = HINT_TICKS;
self.dirty = true;
}
fn clear_status_hint(&mut self) {
if self.status_hint.take().is_some() {
self.hint_ttl = 0;
self.dirty = true;
}
}
}
enum SwitchOutcome {
Switched,
NoChange,
Unavailable(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum KeyRoute {
Consumed,
ToScreen,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum Mode {
Live(LiveState),
Replay(ReplayState),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum LiveScreen {
#[default]
Chain,
Depth,
Surface,
Payoff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum ReplayScreen {
#[default]
Replay,
Payoff,
}
#[derive(Debug)]
pub struct LiveState {
pub source: SourceBinding,
pub overlay: Option<OverlayBinding>,
pub screen: LiveScreen,
pub store: ChainStore,
pub selection: Selection,
pub payoff_builder: PayoffBuilder,
pub surface: SurfacePanel,
pub depth_store: DepthStore,
pub depth_scroll: Option<usize>,
pub depth_visible_rows: usize,
pub load: ScreenLoad,
atm_index: Option<usize>,
}
impl LiveState {
#[must_use]
pub fn new(source: SourceBinding, store: ChainStore) -> Self {
debug_assert!(
chain_present(source.capabilities.chain),
"a live source must assemble a chain so the default Chain screen is reachable",
);
let atm_index = atm_index_of(store.chain());
let surface = SurfacePanel::new(&store);
Self {
source,
overlay: None,
screen: LiveScreen::Chain,
store,
selection: Selection::default(),
payoff_builder: PayoffBuilder::new(),
surface,
depth_store: DepthStore::new(),
depth_scroll: None,
depth_visible_rows: 0,
load: ScreenLoad::Loading,
atm_index,
}
}
#[must_use]
pub fn atm_index(&self) -> Option<usize> {
self.atm_index
}
#[must_use]
pub fn with_overlay(mut self, overlay: OverlayBinding) -> Self {
self.overlay = Some(overlay);
self
}
#[must_use = "the returned bool reports whether the screen switch was applied"]
pub fn set_screen(&mut self, screen: LiveScreen) -> bool {
if self.screen_reachable(screen) {
self.enter_screen(screen);
true
} else {
false
}
}
pub(crate) fn enter_screen(&mut self, screen: LiveScreen) {
self.screen = screen;
if screen == LiveScreen::Surface {
let LiveState { store, surface, .. } = self;
surface.rebuild_if_dirty(store);
}
}
#[must_use]
pub fn screen_reachable(&self, screen: LiveScreen) -> bool {
is_screen_reachable(screen, &self.effective_capabilities())
}
#[must_use]
pub fn effective_capabilities(&self) -> ProviderCapabilities {
let source = self.source.capabilities;
let Some(overlay) = self.overlay.as_ref() else {
return source;
};
let overlay = overlay.capabilities;
ProviderCapabilities::builder()
.chain(source.chain)
.depth(source.depth || overlay.depth)
.greeks(max_greeks(source.greeks, overlay.greeks))
.option_stream(source.option_stream)
.underlying_stream(source.underlying_stream || overlay.underlying_stream)
.chain_poll(source.chain_poll)
.trades_tape(source.trades_tape || overlay.trades_tape)
.auth(source.auth)
.build()
}
#[must_use]
pub fn overlay_aware_health(&self) -> &StreamHealth {
if !matches!(self.source.health, StreamHealth::Live) {
return &self.source.health;
}
match self.overlay.as_ref() {
Some(overlay) if !matches!(overlay.health, StreamHealth::Live) => &overlay.health,
_ => &self.source.health,
}
}
fn apply_market(&mut self, update: MarketUpdate) -> bool {
match update {
MarketUpdate::Quote(quote) => {
let changed = merged(self.store.apply_quote("e));
self.fold_chain_update(changed)
}
MarketUpdate::Greeks(greeks) => {
let changed = merged(self.store.apply_greeks(&greeks));
self.fold_chain_update(changed)
}
MarketUpdate::Depth(ladder) => self.apply_depth(ladder),
MarketUpdate::Chain(snapshot) => {
let changed = self.apply_chain_snapshot(snapshot);
self.fold_chain_update(changed)
}
MarketUpdate::Health(provider, health) => {
let changed = self.apply_health(&provider, health);
self.fold_chain_update(changed)
}
}
}
fn fold_chain_update(&mut self, changed: bool) -> bool {
if changed {
self.refresh_committed_tplus0();
self.refresh_surface();
}
changed
}
fn apply_depth(&mut self, ladder: DepthLadder) -> bool {
let visible = self.screen == LiveScreen::Depth
&& self.selected_depth_key().as_ref() == Some(&ladder.instrument.key);
let applied = self.depth_store.apply(ladder);
applied && visible
}
#[must_use]
pub fn selected_depth_key(&self) -> Option<InstrumentKey> {
let row = self.selection.focused_row?;
let od = self.store.chain().options.iter().nth(row)?;
let (_, underlying, expiration) = self.store.chain_key();
let style = match self.selection.focused_leg {
LegFocus::Call => OptionStyle::Call,
LegFocus::Put => OptionStyle::Put,
};
Some(InstrumentKey {
underlying: underlying.clone(),
expiration_utc: *expiration,
strike: od.strike_price,
style,
})
}
fn refresh_surface(&mut self) {
if self.screen == LiveScreen::Surface {
let LiveState { store, surface, .. } = self;
surface.refresh(store);
} else {
self.surface.mark_dirty();
}
}
fn refresh_committed_tplus0(&mut self) {
if self.payoff_builder.curve() != CurveMode::TPlus0
|| self.payoff_builder.committed().is_none()
{
return;
}
let LiveState {
store,
payoff_builder,
..
} = self;
payoff_builder.refresh_tplus0(store);
}
fn apply_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> bool {
let ChainSnapshot {
chain_key,
chain,
aliases,
last_full_poll,
source: _,
health: _,
} = snapshot;
match last_full_poll {
Some(polled) => {
let (provider, underlying, expiration_utc) = chain_key;
let fetch = ChainFetch::new(
chain,
ExpirySource::new(underlying, expiration_utc, provider),
aliases,
);
self.store.apply_poll(fetch, polled);
self.atm_index = atm_index_of(self.store.chain());
true
}
None => false,
}
}
fn apply_health(&mut self, provider: &ProviderId, health: StreamHealth) -> bool {
let mut changed = false;
if *provider == self.source.provider {
self.source.health = health.clone();
self.store.apply_health(health.clone());
changed = true;
}
if let Some(overlay) = &mut self.overlay
&& *provider == overlay.provider
{
overlay.health = health;
changed = true;
}
changed
}
}
#[derive(Debug, Clone)]
pub struct SourceBinding {
pub provider: ProviderId,
pub capabilities: ProviderCapabilities,
pub health: StreamHealth,
}
impl SourceBinding {
#[must_use]
pub fn new(
provider: ProviderId,
capabilities: ProviderCapabilities,
health: StreamHealth,
) -> Self {
Self {
provider,
capabilities,
health,
}
}
}
#[derive(Debug, Clone)]
pub struct OverlayBinding {
pub provider: ProviderId,
pub capabilities: ProviderCapabilities,
pub health: StreamHealth,
}
impl OverlayBinding {
#[must_use]
pub fn new(
provider: ProviderId,
capabilities: ProviderCapabilities,
health: StreamHealth,
) -> Self {
Self {
provider,
capabilities,
health,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum LegFocus {
#[default]
Call,
Put,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Selection {
pub focused_row: Option<usize>,
pub focused_leg: LegFocus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum Side {
#[default]
Buy,
Sell,
}
impl Side {
#[must_use]
pub fn toggled(self) -> Self {
match self {
Self::Buy => Self::Sell,
Self::Sell => Self::Buy,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Buy => "BUY",
Self::Sell => "SELL",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuilderLeg {
pub strike: Positive,
pub style: OptionStyle,
pub side: Side,
pub qty: u32,
}
impl BuilderLeg {
#[must_use]
pub fn mark_in(&self, chain: &OptionChain) -> Option<Positive> {
let od = chain
.options
.iter()
.find(|o| o.strike_price == self.strike)?;
let mid = match self.style {
OptionStyle::Call => od.call_middle,
OptionStyle::Put => od.put_middle,
};
mid.filter(|m| *m != Positive::MAX)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum CurveMode {
#[default]
Expiration,
TPlus0,
}
impl CurveMode {
#[must_use]
pub fn toggled(self) -> Self {
match self {
Self::Expiration => Self::TPlus0,
Self::TPlus0 => Self::Expiration,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Expiration => "expiration",
Self::TPlus0 => "t+0",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegError {
Empty,
ZeroQty {
idx: usize,
},
NoMark {
idx: usize,
},
StaleMark {
idx: usize,
},
}
impl std::fmt::Display for LegError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "add a leg with `a`"),
Self::ZeroQty { idx } => write!(f, "leg {}: zero quantity", idx + 1),
Self::NoMark { idx } => write!(f, "leg {}: no mark", idx + 1),
Self::StaleMark { idx } => write!(f, "leg {}: mark is stale", idx + 1),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CommittedStrategy {
legs: Vec<BuilderLeg>,
grid: Vec<Positive>,
entry_positions: Vec<Position>,
expiration: GraphData,
tplus0: GraphData,
break_evens: Vec<Positive>,
compute_failed: bool,
}
impl CommittedStrategy {
#[must_use]
pub fn legs(&self) -> &[BuilderLeg] {
&self.legs
}
#[must_use]
pub fn break_even_points(&self) -> &[Positive] {
&self.break_evens
}
#[must_use]
fn has_expiration_curve(&self) -> bool {
match &self.expiration {
GraphData::Series(series) => !series.x.is_empty(),
GraphData::MultiSeries(_) | GraphData::GraphSurface(_) => false,
}
}
#[must_use]
fn active_graph(&self, curve: CurveMode) -> &GraphData {
match curve {
CurveMode::Expiration => &self.expiration,
CurveMode::TPlus0 => &self.tplus0,
}
}
fn populate_geometry(&mut self, store: &ChainStore) {
match payoff_build::build_geometry(&self.legs, store) {
payoff_build::GeometryBuild::Priced(geometry) => {
self.grid = geometry.grid;
self.entry_positions = geometry.entry_positions;
self.expiration = geometry.expiration;
self.tplus0 = geometry.tplus0;
self.break_evens = geometry.break_evens;
self.compute_failed = false;
}
payoff_build::GeometryBuild::NotPriceable => self.compute_failed = false,
payoff_build::GeometryBuild::ComputeFailed => self.compute_failed = true,
}
}
fn refresh_tplus0(&mut self, store: &ChainStore) -> bool {
if self.grid.is_empty() {
return false;
}
let (series, compute_failed) = match payoff_build::rebuild_tplus0(
&self.legs,
store,
&self.grid,
&self.entry_positions,
) {
payoff_build::TPlus0Build::Series(series) => (series, false),
payoff_build::TPlus0Build::ComputeFailed => (payoff_build::empty_series(), true),
};
let changed = series != self.tplus0 || compute_failed != self.compute_failed;
self.tplus0 = series;
self.compute_failed = compute_failed;
changed
}
#[must_use]
fn curve_compute_failed(&self) -> bool {
self.compute_failed
}
}
#[derive(Debug, Clone)]
pub struct PayoffBuilder {
legs: Vec<BuilderLeg>,
cursor: usize,
curve: CurveMode,
errors: Vec<LegError>,
committed: Option<CommittedStrategy>,
revision: u64,
graph_revision: u64,
empty_graph: GraphData,
}
impl Default for PayoffBuilder {
fn default() -> Self {
Self {
legs: Vec::new(),
cursor: 0,
curve: CurveMode::default(),
errors: Vec::new(),
committed: None,
revision: 0,
graph_revision: 0,
empty_graph: payoff_build::empty_series(),
}
}
}
const DEFAULT_LEG_QTY: u32 = 1;
const MAX_LEG_QTY: u32 = 9_999;
impl PayoffBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn legs(&self) -> &[BuilderLeg] {
&self.legs
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.legs.is_empty()
}
#[must_use]
pub fn cursor(&self) -> usize {
self.cursor
}
#[must_use]
pub fn curve(&self) -> CurveMode {
self.curve
}
#[must_use]
pub fn errors(&self) -> &[LegError] {
&self.errors
}
#[must_use]
pub fn committed(&self) -> Option<&CommittedStrategy> {
self.committed.as_ref()
}
#[must_use]
pub fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub fn graph_revision(&self) -> u64 {
self.graph_revision
}
#[must_use]
pub fn active_graph_data(&self) -> &GraphData {
match self.committed.as_ref() {
Some(committed) => committed.active_graph(self.curve),
None => &self.empty_graph,
}
}
#[must_use]
pub fn break_even_points(&self) -> &[Positive] {
match self.committed.as_ref() {
Some(committed) => committed.break_even_points(),
None => &[],
}
}
#[must_use]
pub fn has_expiration_curve(&self) -> bool {
match self.committed.as_ref() {
Some(committed) => committed.has_expiration_curve(),
None => false,
}
}
#[must_use]
pub fn curve_compute_failed(&self) -> bool {
match self.committed.as_ref() {
Some(committed) => committed.curve_compute_failed(),
None => false,
}
}
pub(crate) fn append(&mut self, strike: Positive, style: OptionStyle) {
self.legs.push(BuilderLeg {
strike,
style,
side: Side::Buy,
qty: DEFAULT_LEG_QTY,
});
self.cursor = self.legs.len().max(1) - 1;
self.on_edit();
}
pub(crate) fn remove_cursor(&mut self) {
if self.cursor >= self.legs.len() {
return;
}
let _ = self.legs.remove(self.cursor);
if self.cursor >= self.legs.len() {
self.cursor = self.legs.len().max(1) - 1;
}
self.on_edit();
}
pub(crate) fn increment_qty(&mut self) {
if let Some(leg) = self.legs.get_mut(self.cursor)
&& leg.qty < MAX_LEG_QTY
{
leg.qty += 1;
self.on_edit();
}
}
pub(crate) fn decrement_qty(&mut self) {
if let Some(leg) = self.legs.get_mut(self.cursor)
&& leg.qty > 0
{
leg.qty -= 1;
self.on_edit();
}
}
pub(crate) fn toggle_cursor_side(&mut self) {
if let Some(leg) = self.legs.get_mut(self.cursor) {
leg.side = leg.side.toggled();
self.on_edit();
}
}
pub(crate) fn toggle_curve(&mut self) {
self.curve = self.curve.toggled();
self.bump();
if self.committed.is_some() {
self.bump_graph();
}
}
pub(crate) fn refresh_tplus0(&mut self, store: &ChainStore) {
let Some(committed) = self.committed.as_mut() else {
return;
};
if committed.refresh_tplus0(store) {
self.bump_graph();
}
}
#[cfg(test)]
pub(crate) fn force_curve_compute_failed(&mut self) {
let Some(committed) = self.committed.as_mut() else {
return;
};
committed.grid = Vec::new();
committed.entry_positions = Vec::new();
committed.expiration = payoff_build::empty_series();
committed.tplus0 = payoff_build::empty_series();
committed.break_evens = Vec::new();
committed.compute_failed = true;
self.bump_graph();
}
pub(crate) fn discard(&mut self) {
if self.legs.is_empty() && self.errors.is_empty() && self.committed.is_none() {
return;
}
let had_committed = self.committed.is_some();
self.legs.clear();
self.cursor = 0;
self.errors.clear();
self.committed = None;
self.bump();
if had_committed {
self.bump_graph();
}
}
pub(crate) fn commit(&mut self, store: &ChainStore) -> bool {
let clocks = store.quote_clocks();
let as_of = store.analytics_as_of();
match self.validate(store.chain(), &clocks, as_of) {
Ok(mut strategy) => {
strategy.populate_geometry(store);
self.committed = Some(strategy);
self.errors.clear();
self.bump();
self.bump_graph();
true
}
Err(errors) => {
self.committed = None;
self.errors = errors;
self.bump();
false
}
}
}
pub fn validate(
&self,
chain: &OptionChain,
clocks: &QuoteClocks,
as_of: DateTime<Utc>,
) -> Result<CommittedStrategy, Vec<LegError>> {
if self.legs.is_empty() {
return Err(vec![LegError::Empty]);
}
let expiration_utc = match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => Some(dt),
_ => None,
};
let mut errors = Vec::new();
for (idx, leg) in self.legs.iter().enumerate() {
if leg.qty == 0 {
errors.push(LegError::ZeroQty { idx });
}
if leg.mark_in(chain).is_none() {
errors.push(LegError::NoMark { idx });
} else if let Some(expiration_utc) = expiration_utc {
let key = InstrumentKey {
underlying: chain.symbol.clone(),
expiration_utc,
strike: leg.strike,
style: leg.style,
};
if let Some(received) = clocks.received(&key)
&& quote_is_stale(received, as_of)
{
errors.push(LegError::StaleMark { idx });
}
}
}
if errors.is_empty() {
Ok(CommittedStrategy {
legs: self.legs.clone(),
grid: Vec::new(),
entry_positions: Vec::new(),
expiration: payoff_build::empty_series(),
tplus0: payoff_build::empty_series(),
break_evens: Vec::new(),
compute_failed: false,
})
} else {
Err(errors)
}
}
fn on_edit(&mut self) {
let had_committed = self.committed.is_some();
self.committed = None;
self.errors.clear();
self.bump();
if had_committed {
self.bump_graph();
}
}
fn bump(&mut self) {
self.revision = self.revision.checked_add(1).unwrap_or(0);
}
fn bump_graph(&mut self) {
self.graph_revision = self.graph_revision.checked_add(1).unwrap_or(0);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum SurfaceView {
#[default]
Smile,
Curve,
Surface,
}
impl SurfaceView {
#[must_use]
fn next(self) -> Self {
match self {
Self::Smile => Self::Curve,
Self::Curve => Self::Surface,
Self::Surface => Self::Smile,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Smile => "smile",
Self::Curve => "curve",
Self::Surface => "surface",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum SurfaceAxis {
#[default]
Delta,
Gamma,
Theta,
Vega,
Volatility,
Price,
}
impl SurfaceAxis {
const ORDER: [SurfaceAxis; 6] = [
SurfaceAxis::Delta,
SurfaceAxis::Gamma,
SurfaceAxis::Theta,
SurfaceAxis::Vega,
SurfaceAxis::Volatility,
SurfaceAxis::Price,
];
#[must_use]
fn cycled(self, forward: bool) -> Self {
next_in_cycle(&Self::ORDER, self, forward).unwrap_or(self)
}
#[must_use]
pub(crate) fn to_basic(self) -> BasicAxisTypes {
match self {
Self::Delta => BasicAxisTypes::Delta,
Self::Gamma => BasicAxisTypes::Gamma,
Self::Theta => BasicAxisTypes::Theta,
Self::Vega => BasicAxisTypes::Vega,
Self::Volatility => BasicAxisTypes::Volatility,
Self::Price => BasicAxisTypes::Price,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Delta => "Δ",
Self::Gamma => "Γ",
Self::Theta => "Θ",
Self::Vega => "ν",
Self::Volatility => "IV",
Self::Price => "price",
}
}
#[must_use]
pub(crate) fn metric_name(self) -> &'static str {
match self {
Self::Delta => "delta",
Self::Gamma => "gamma",
Self::Theta => "theta",
Self::Vega => "vega",
Self::Volatility => "IV",
Self::Price => "price",
}
}
}
#[derive(Debug, Clone)]
pub struct SurfacePanel {
view: SurfaceView,
axis: SurfaceAxis,
active: GraphData,
revision: u64,
graph_revision: u64,
dirty: bool,
}
impl SurfacePanel {
#[must_use]
fn new(store: &ChainStore) -> Self {
let view = SurfaceView::default();
let axis = SurfaceAxis::default();
let active = build_active(view, axis, store);
Self {
view,
axis,
active,
revision: 0,
graph_revision: 0,
dirty: false,
}
}
#[must_use]
pub fn view(&self) -> SurfaceView {
self.view
}
#[must_use]
pub fn axis(&self) -> SurfaceAxis {
self.axis
}
#[must_use]
pub fn active_graph_data(&self) -> &GraphData {
&self.active
}
#[must_use]
pub fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub fn graph_revision(&self) -> u64 {
self.graph_revision
}
pub(crate) fn cycle_axis(&mut self, forward: bool, store: &ChainStore) {
self.axis = self.axis.cycled(forward);
self.bump();
self.rebuild(store);
}
pub(crate) fn cycle_view(&mut self, store: &ChainStore) {
self.view = self.view.next();
self.bump();
self.rebuild(store);
}
pub(crate) fn refresh(&mut self, store: &ChainStore) {
self.rebuild(store);
}
pub(crate) fn mark_dirty(&mut self) {
self.dirty = true;
}
pub(crate) fn rebuild_if_dirty(&mut self, store: &ChainStore) {
if self.dirty {
self.rebuild(store);
}
}
fn rebuild(&mut self, store: &ChainStore) {
let next = build_active(self.view, self.axis, store);
if next != self.active {
self.active = next;
self.bump_graph();
}
self.dirty = false;
}
fn bump(&mut self) {
self.revision = self.revision.checked_add(1).unwrap_or(0);
}
fn bump_graph(&mut self) {
self.graph_revision = self.graph_revision.checked_add(1).unwrap_or(0);
}
}
#[must_use]
fn build_active(view: SurfaceView, axis: SurfaceAxis, store: &ChainStore) -> GraphData {
match view {
SurfaceView::Smile => surface_build::build_smile(store),
SurfaceView::Curve => surface_build::build_curve(store, axis),
SurfaceView::Surface => surface_build::build_surface(store, axis),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ScreenLoad {
#[default]
Loading,
Ready,
Error {
message: String,
},
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct StatusLine;
#[derive(Debug)]
pub struct ReplayState {
pub dir: PathBuf,
pub bundle: BundleLoad,
pub screen: ReplayScreen,
pub play: Playback,
}
impl ReplayState {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
bundle: BundleLoad::Loading,
screen: ReplayScreen::Replay,
play: Playback::Paused,
}
}
#[must_use]
pub fn loaded(&self) -> Option<&LoadedReplay> {
match &self.bundle {
BundleLoad::Ready(loaded) => Some(loaded),
BundleLoad::Loading | BundleLoad::Error { .. } => None,
}
}
#[must_use]
pub fn is_playing(&self) -> bool {
self.play.is_playing()
}
pub fn apply_load_result(&mut self, result: BundleLoadResult) {
self.bundle = match result {
BundleLoadResult::Loaded(bundle) => {
BundleLoad::Ready(Box::new(LoadedReplay::new(*bundle)))
}
BundleLoadResult::Failed(message) => BundleLoad::Error { message },
};
self.play = Playback::Paused;
}
pub fn begin_reload(&mut self) {
self.bundle = BundleLoad::Loading;
self.play = Playback::Paused;
}
pub fn seek(&mut self, seek: SeekTo) -> bool {
let BundleLoad::Ready(loaded) = &mut self.bundle else {
return false;
};
let loaded: &mut LoadedReplay = loaded;
let before = loaded.cursor;
loaded.cursor.seek(seek, &loaded.bundle);
if loaded.cursor == before {
return false;
}
loaded.on_cursor_moved();
true
}
pub fn step_fill(&mut self, forward: bool) -> bool {
let BundleLoad::Ready(loaded) = &mut self.bundle else {
return false;
};
let loaded: &mut LoadedReplay = loaded;
loaded.step_fill(forward)
}
pub fn apply_control(&mut self, control: ReplayControl) -> bool {
let next = match control {
ReplayControl::PlayPause => self.play.toggled(PlaybackSpeed::default()),
ReplayControl::SpeedFaster => match self.play {
Playback::Playing { speed } => Playback::playing(speed.faster()),
Playback::Paused => return false,
},
ReplayControl::SpeedSlower => match self.play {
Playback::Playing { speed } => Playback::playing(speed.slower()),
Playback::Paused => return false,
},
};
if next != self.play {
self.play = next;
true
} else {
false
}
}
pub fn advance_playback(&mut self) -> bool {
if !self.play.is_playing() {
return false;
}
let play = self.play;
let (moved, at_end) = match &mut self.bundle {
BundleLoad::Ready(loaded) => {
let loaded: &mut LoadedReplay = loaded;
let before = loaded.cursor;
loaded.cursor.advance_playback(play, &loaded.bundle);
let moved = loaded.cursor != before;
if moved {
loaded.on_cursor_moved();
}
(moved, loaded.cursor.is_at_end())
}
BundleLoad::Loading | BundleLoad::Error { .. } => return false,
};
if at_end {
self.play = Playback::Paused;
}
moved || at_end
}
}
#[derive(Debug)]
pub enum BundleLoad {
Loading,
Ready(Box<LoadedReplay>),
Error {
message: String,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub struct LoadedReplay {
pub bundle: LoadedBundle,
pub cursor: TimelineCursor,
pub selection: Option<Fill>,
selected_ix: Option<usize>,
geometry: EquityGeometry,
equity_revision: u64,
payoff_graph: GraphData,
payoff_head: ReplayPayoffHead,
payoff_revision: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReplayPayoffHead {
break_evens: Vec<Positive>,
mark_pnl_cents: Option<i64>,
open_legs: usize,
curve_compute_failed: bool,
}
impl ReplayPayoffHead {
#[must_use]
pub fn break_even_points(&self) -> &[Positive] {
&self.break_evens
}
#[must_use]
pub fn mark_pnl_cents(&self) -> Option<i64> {
self.mark_pnl_cents
}
#[must_use]
pub fn open_legs(&self) -> usize {
self.open_legs
}
#[must_use]
pub fn curve_compute_failed(&self) -> bool {
self.curve_compute_failed
}
}
impl LoadedReplay {
#[must_use]
pub fn new(bundle: LoadedBundle) -> Self {
let cursor = TimelineCursor::new(&bundle);
let visible = cursor.visible_equity(&bundle);
let seed_cents = opening_capital_cents(&bundle)
.or_else(|| bundle.equity.first().map(|point| point.equity_cents))
.unwrap_or(0);
let geometry = EquityGeometry::build(visible, seed_cents);
let (payoff_graph, payoff_head) = build_payoff_at_head(&cursor, &bundle);
Self {
bundle,
cursor,
selection: None,
selected_ix: None,
geometry,
equity_revision: 0,
payoff_graph,
payoff_head,
payoff_revision: 0,
}
}
#[must_use]
pub fn equity_graph(&self) -> &GraphData {
self.geometry.graph()
}
#[must_use]
pub fn equity_revision(&self) -> u64 {
self.equity_revision
}
#[must_use]
pub fn peak_drawdown_cents(&self) -> i64 {
self.geometry.peak_drawdown_cents()
}
#[must_use]
pub fn payoff_graph(&self) -> &GraphData {
&self.payoff_graph
}
#[must_use]
pub fn payoff_head(&self) -> &ReplayPayoffHead {
&self.payoff_head
}
#[must_use]
pub fn payoff_revision(&self) -> u64 {
self.payoff_revision
}
#[must_use]
pub fn selection_key(&self) -> Option<(u32, u64, u32)> {
self.selection.as_ref().map(fill_key)
}
#[must_use]
pub fn selected_fill_index(&self) -> Option<usize> {
self.selected_ix
}
fn on_cursor_moved(&mut self) {
self.rebuild_equity();
self.rebuild_payoff();
self.reclamp_selection();
}
fn rebuild_payoff(&mut self) {
let (graph, head) = build_payoff_at_head(&self.cursor, &self.bundle);
self.payoff_graph = graph;
self.payoff_head = head;
self.payoff_revision = self.payoff_revision.checked_add(1).unwrap_or(0);
}
#[cfg(test)]
pub(crate) fn force_payoff_compute_failed(&mut self) {
self.payoff_graph = payoff_build::empty_series();
self.payoff_head.break_evens = Vec::new();
self.payoff_head.curve_compute_failed = true;
self.payoff_revision = self.payoff_revision.checked_add(1).unwrap_or(0);
}
fn rebuild_equity(&mut self) {
let visible = self.cursor.visible_equity(&self.bundle);
if visible.len() >= self.geometry.raw_len() {
self.geometry.extend_forward(visible);
} else {
self.geometry.rebuild(visible);
}
self.equity_revision = self.equity_revision.checked_add(1).unwrap_or(0);
}
fn reclamp_selection(&mut self) {
let key = match &self.selection {
Some(fill) => fill_key(fill),
None => {
self.selected_ix = None;
return;
}
};
match self
.cursor
.visible_fills(&self.bundle)
.iter()
.position(|fill| fill_key(fill) == key)
{
Some(ix) => self.selected_ix = Some(ix),
None => {
self.selection = None;
self.selected_ix = None;
}
}
}
fn step_fill(&mut self, forward: bool) -> bool {
let (next, next_ix) = {
let visible = self.cursor.visible_fills(&self.bundle);
match visible.len().checked_sub(1) {
None => (None, None),
Some(last) => {
let current = self.selection.as_ref().and_then(|fill| {
let key = fill_key(fill);
visible.iter().position(|f| fill_key(f) == key)
});
let idx = match (current, forward) {
(None, _) => last,
(Some(i), true) => i.checked_add(1).map_or(last, |j| j.min(last)),
(Some(0), false) => 0,
(Some(i), false) => i - 1,
};
(visible.get(idx).cloned(), Some(idx))
}
}
};
if selection_key(self.selection.as_ref()) == selection_key(next.as_ref()) {
return false;
}
self.selected_ix = next.as_ref().and(next_ix);
self.selection = next;
true
}
}
#[must_use]
fn opening_capital_cents(bundle: &LoadedBundle) -> Option<i64> {
bundle.manifest.capital_config().ok()?.capital_cents().ok()
}
#[must_use]
fn fill_key(fill: &Fill) -> (u32, u64, u32) {
(fill.step, fill.order_id, fill.fill_seq)
}
#[must_use]
fn selection_key(selection: Option<&Fill>) -> Option<(u32, u64, u32)> {
selection.map(fill_key)
}
#[must_use]
fn build_payoff_at_head(
cursor: &TimelineCursor,
bundle: &LoadedBundle,
) -> (GraphData, ReplayPayoffHead) {
let open = cursor.open_positions(bundle);
let head_ts_ns = cursor.head_equity(bundle).map(|point| point.ts_ns);
let geometry = replay_payoff_build::build(&open, head_ts_ns);
let (graph, break_evens, curve_compute_failed) = match geometry.curve {
replay_payoff_build::ReplayCurve::Priced { graph, break_evens } => {
(graph, break_evens, false)
}
replay_payoff_build::ReplayCurve::ComputeFailed => {
(payoff_build::empty_series(), Vec::new(), true)
}
};
let head = ReplayPayoffHead {
break_evens,
mark_pnl_cents: geometry.mark_pnl_cents,
open_legs: geometry.open_legs,
curve_compute_failed,
};
(graph, head)
}
#[must_use]
pub fn is_screen_reachable(screen: LiveScreen, caps: &ProviderCapabilities) -> bool {
match screen {
LiveScreen::Chain | LiveScreen::Payoff => chain_present(caps.chain),
LiveScreen::Depth => caps.depth,
LiveScreen::Surface => greeks_available(caps.greeks),
}
}
fn chain_present(chain: ChainCapability) -> bool {
match chain {
ChainCapability::Native | ChainCapability::Assemble | ChainCapability::Partial => true,
ChainCapability::None => false,
}
}
fn greeks_available(greeks: GreeksCapability) -> bool {
match greeks {
GreeksCapability::Provided | GreeksCapability::ComputedLocally => true,
GreeksCapability::None => false,
}
}
fn max_greeks(a: GreeksCapability, b: GreeksCapability) -> GreeksCapability {
match (a, b) {
(GreeksCapability::Provided, _) | (_, GreeksCapability::Provided) => {
GreeksCapability::Provided
}
(GreeksCapability::ComputedLocally, _) | (_, GreeksCapability::ComputedLocally) => {
GreeksCapability::ComputedLocally
}
(GreeksCapability::None, GreeksCapability::None) => GreeksCapability::None,
}
}
fn merged(outcome: MergeOutcome) -> bool {
match outcome {
MergeOutcome::Applied | MergeOutcome::OverlayRefused => true,
MergeOutcome::Buffered
| MergeOutcome::DroppedOutOfOrder
| MergeOutcome::DroppedTombstoned
| MergeOutcome::DroppedCrossed => false,
}
}
const LIVE_SCREEN_ORDER: [LiveScreen; 4] = [
LiveScreen::Chain,
LiveScreen::Depth,
LiveScreen::Surface,
LiveScreen::Payoff,
];
const REPLAY_SCREEN_ORDER: [ReplayScreen; 2] = [ReplayScreen::Replay, ReplayScreen::Payoff];
#[must_use]
fn live_screen_for_slot(slot: u8) -> Option<LiveScreen> {
match slot {
1 => Some(LiveScreen::Chain),
2 => Some(LiveScreen::Depth),
3 => Some(LiveScreen::Surface),
4 => Some(LiveScreen::Payoff),
_ => None,
}
}
#[must_use]
fn replay_screen_for_slot(slot: u8) -> Option<ReplayScreen> {
match slot {
1 => Some(ReplayScreen::Replay),
2 => Some(ReplayScreen::Payoff),
_ => None,
}
}
#[must_use]
pub fn is_replay_screen_reachable(screen: ReplayScreen) -> bool {
match screen {
ReplayScreen::Replay | ReplayScreen::Payoff => true,
}
}
#[must_use]
pub(crate) fn live_screen_name(screen: LiveScreen) -> &'static str {
match screen {
LiveScreen::Chain => "Chain",
LiveScreen::Depth => "Depth",
LiveScreen::Surface => "Surface",
LiveScreen::Payoff => "Payoff",
}
}
#[must_use]
pub(crate) fn replay_screen_name(screen: ReplayScreen) -> &'static str {
match screen {
ReplayScreen::Replay => "Replay",
ReplayScreen::Payoff => "Payoff",
}
}
#[must_use]
fn replay_unavailable_hint(screen: ReplayScreen) -> String {
match screen {
ReplayScreen::Replay | ReplayScreen::Payoff => "screen not available".to_owned(),
}
}
#[must_use]
fn next_in_cycle<T: PartialEq + Copy>(items: &[T], current: T, forward: bool) -> Option<T> {
let idx = items.iter().position(|item| *item == current)?;
let len = items.len();
if len == 0 {
return None;
}
let next = if forward {
(idx + 1) % len
} else {
(idx + len - 1) % len
};
items.get(next).copied()
}
#[must_use]
fn now_utc() -> DateTime<Utc> {
let since = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO);
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
DateTime::<Utc>::from_timestamp(secs, since.subsec_nanos()).unwrap_or(DateTime::<Utc>::MIN_UTC)
}
#[must_use]
pub(crate) fn atm_index_of(chain: &OptionChain) -> Option<usize> {
let spot = chain.underlying_price.to_dec();
let mut best: Option<(usize, Decimal)> = None;
for (idx, od) in chain.options.iter().enumerate() {
let diff = (od.strike_price.to_dec() - spot).abs();
let better = match best {
Some((_, best_diff)) => diff < best_diff,
None => true,
};
if better {
best = Some((idx, diff));
}
}
best.map(|(idx, _)| idx)
}
#[cfg(test)]
pub(crate) mod tests_support {
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::Positive;
use tokio::sync::mpsc;
use super::{
App, LiveScreen, LiveState, Mode, ReplayScreen, ReplayState, ScreenLoad, SourceBinding,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ExpirySource, ProviderId, StreamHealth,
};
use crate::config::ThemeChoice;
use crate::event::{BundleLoadResult, Command};
use crate::providers::{
ChainCapability, ChainPollCapability, GreeksCapability, ProviderCapabilities,
};
use crate::replay::{
BundleManifest, EquityPoint, ExecMode, Fill, GreeksAttribution, LoadedBundle, PositionSide,
SUPPORTED_SCHEMA,
};
const EXP: i64 = 1_700_000_000;
fn replay_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-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_point(step: u32) -> EquityPoint {
EquityPoint {
step,
ts_ns: 1_700_000_000_000_000_000 + i64::from(step),
cash_cents: 1_000,
position_value_cents: 0,
equity_cents: 1_000,
drawdown: 0.0,
}
}
#[must_use]
pub(crate) fn loaded_bundle(n_steps: u32) -> LoadedBundle {
LoadedBundle {
manifest: replay_manifest(),
fills: Vec::new(),
equity: (0..n_steps).map(equity_point).collect(),
positions: Vec::new(),
greeks: Vec::new(),
}
}
#[must_use]
pub(crate) fn ready_replay_app(n_steps: u32) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let mut replay = ReplayState::new(PathBuf::from("/bundle"));
replay.apply_load_result(BundleLoadResult::Loaded(Box::new(loaded_bundle(n_steps))));
let mut app = App::new(Mode::Replay(replay), ThemeChoice::Auto, tx);
app.mark_drawn();
(app, rx)
}
fn replay_fill(step: u32, order_id: u64) -> Fill {
Fill {
step,
ts_ns: 1_700_000_000_000_000_000 + i64::from(step),
strategy_run_id: "run-test".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: "v1:BTC:1735286400000000000:6000000:C".to_owned(),
strike_cents: 6_000_000,
style: optionstratlib::OptionStyle::Call,
side: PositionSide::Long,
quantity: 1,
price_cents: 235,
fees_cents: 30,
slippage_cents: -15,
mode: ExecMode::Realistic,
}
}
fn greeks_row(step: u32) -> GreeksAttribution {
GreeksAttribution {
step,
ts_ns: 1_700_000_000_000_000_000 + 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,
}
}
#[must_use]
pub(crate) fn ready_replay_app_with_fills(n_steps: u32) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let bundle = LoadedBundle {
manifest: replay_manifest(),
fills: vec![replay_fill(0, 10), replay_fill(0, 11), replay_fill(1, 20)],
equity: (0..n_steps).map(equity_point).collect(),
positions: Vec::new(),
greeks: (0..n_steps).map(greeks_row).collect(),
};
let mut replay = ReplayState::new(PathBuf::from("/bundle"));
replay.apply_load_result(BundleLoadResult::Loaded(Box::new(bundle)));
let _ = replay.seek(crate::event::SeekTo::Step(u32::MAX));
let mut app = App::new(Mode::Replay(replay), ThemeChoice::Auto, tx);
app.mark_drawn();
(app, rx)
}
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`: {e}"),
}
}
#[track_caller]
fn utc(secs: i64) -> chrono::DateTime<chrono::Utc> {
match chrono::DateTime::<chrono::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 row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(1.0)),
call_ask: Some(pos(1.2)),
put_bid: Some(pos(2.0)),
put_ask: Some(pos(2.4)),
implied_volatility: pos(0.5),
..Default::default()
};
od.set_mid_prices();
od
}
fn chain_with(strikes: &[f64]) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
for strike in strikes {
let _ = chain.options.insert(row(*strike));
}
chain
}
fn store() -> ChainStore {
ChainStore::seed(
ChainFetch::new(
chain_with(&[60_000.0]),
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
)
}
fn full_caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.depth(true)
.greeks(GreeksCapability::Provided)
.chain_poll(ChainPollCapability::Poll {
interval_hint_secs: 2,
})
.build()
}
#[must_use]
pub(crate) fn live_app(
screen: LiveScreen,
load: ScreenLoad,
help: bool,
) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let mut live = LiveState::new(
SourceBinding::new(pid("deribit"), full_caps(), StreamHealth::Live),
store(),
);
live.screen = screen;
live.load = load;
let mut app = App::new(Mode::Live(live), ThemeChoice::Auto, tx);
app.help_open = help;
app.mark_drawn();
(app, rx)
}
#[must_use]
pub(crate) fn replay_app(screen: ReplayScreen, help: bool) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let mut replay = ReplayState::new(PathBuf::from("/bundle"));
replay.screen = screen;
let mut app = App::new(Mode::Replay(replay), ThemeChoice::Auto, tx);
app.help_open = help;
app.mark_drawn();
(app, rx)
}
#[must_use]
pub(crate) fn live_app_on(screen: LiveScreen, load: ScreenLoad, help: bool) -> App {
live_app(screen, load, help).0
}
#[must_use]
pub(crate) fn live_app_caps(capabilities: ProviderCapabilities) -> App {
let (tx, _rx) = mpsc::channel::<Command>(8);
let live = LiveState::new(
SourceBinding::new(pid("deribit"), capabilities, StreamHealth::Live),
store(),
);
let mut app = App::new(Mode::Live(live), ThemeChoice::Auto, tx);
app.mark_drawn();
app
}
#[must_use]
pub(crate) fn replay_app_on(screen: ReplayScreen, help: bool) -> App {
replay_app(screen, help).0
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use optionstratlib::OptionStyle;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::Positive;
use tokio::sync::mpsc;
use super::tests_support::{loaded_bundle, ready_replay_app};
use super::{
App, BundleLoad, HINT_TICKS, KeyRoute, LegFocus, LiveScreen, LiveState, Mode,
OverlayBinding, Playback, ReplayScreen, ReplayState, ScreenLoad, SourceBinding,
is_screen_reachable,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSnapshot, ChainSource, ChainStore, ExpirySource, Instrument,
MarketUpdate, ProviderId, QuoteUpdate, StreamHealth,
};
use crate::chain::{ContractSpecFingerprint, ExerciseStyle, InstrumentKey, SettlementStyle};
use crate::config::ThemeChoice;
use crate::event::{AppEvent, BundleLoadResult, Command, ReplayControl, SeekTo};
use crate::providers::{
ChainCapability, ChainPollCapability, GreeksCapability, ProviderCapabilities,
};
use crate::replay::PlaybackSpeed;
const EXP: i64 = 1_700_000_000;
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
#[track_caller]
fn utc(secs: i64) -> chrono::DateTime<chrono::Utc> {
match chrono::DateTime::<chrono::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 refresh() -> std::time::Duration {
std::time::Duration::from_secs(2)
}
fn spec() -> ContractSpecFingerprint {
ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
}
}
fn ikey(strike: f64, style: OptionStyle) -> InstrumentKey {
InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: utc(EXP),
strike: pos(strike),
style,
}
}
fn instrument(provider: &str, strike: f64, style: OptionStyle) -> Instrument {
Instrument {
key: ikey(strike, style),
provider: pid(provider),
native_symbol: format!("BTC-{strike}-{}", style.as_str()),
stream_symbol: None,
spec: spec(),
}
}
fn quote(
provider: &str,
strike: f64,
style: OptionStyle,
bid: Option<f64>,
ask: Option<f64>,
received: i64,
) -> QuoteUpdate {
QuoteUpdate {
instrument: instrument(provider, strike, style),
bid: bid.map(pos),
ask: ask.map(pos),
last: None,
bid_size: None,
ask_size: None,
event_time: None,
received_time: utc(received),
}
}
fn row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(1.0)),
call_ask: Some(pos(1.2)),
put_bid: Some(pos(2.0)),
put_ask: Some(pos(2.4)),
implied_volatility: pos(0.5),
..Default::default()
};
od.set_mid_prices();
od
}
fn chain_with(strikes: &[f64]) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
for strike in strikes {
let _ = chain.options.insert(row(*strike));
}
chain
}
fn fetch(strikes: &[f64], provider: &str) -> ChainFetch {
ChainFetch::new(
chain_with(strikes),
ExpirySource::new("BTC", utc(EXP), pid(provider)),
AliasCatalog::new(),
)
}
fn store(strikes: &[f64], provider: &str) -> ChainStore {
ChainStore::seed(
fetch(strikes, provider),
ChainSource::Merged,
refresh(),
utc(EXP),
)
}
fn full_caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.depth(true)
.greeks(GreeksCapability::Provided)
.chain_poll(ChainPollCapability::Poll {
interval_hint_secs: 2,
})
.build()
}
fn source_binding(provider: &str, caps: ProviderCapabilities) -> SourceBinding {
SourceBinding::new(pid(provider), caps, StreamHealth::Live)
}
fn live_app(caps: ProviderCapabilities) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let live = LiveState::new(
source_binding("deribit", caps),
store(&[60_000.0], "deribit"),
);
let mut app = App::new(Mode::Live(live), ThemeChoice::Auto, tx);
app.mark_drawn();
(app, rx)
}
fn replay_app(dir: &str) -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let mut app = App::new(
Mode::Replay(ReplayState::new(PathBuf::from(dir))),
ThemeChoice::Auto,
tx,
);
app.mark_drawn();
(app, rx)
}
#[track_caller]
fn live(app: &App) -> &LiveState {
match &app.mode {
Mode::Live(live) => live,
Mode::Replay(_) => panic!("expected a live app"),
}
}
#[track_caller]
fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn test_send_command_on_a_closed_channel_is_surfaced_not_silently_dropped() {
let (app, rx) = live_app(full_caps());
let mut app = app;
assert_eq!(app.commands_dropped, 0, "healthy steady state");
drop(rx);
app.send_command(Command::Reconnect);
assert_eq!(
app.commands_dropped, 1,
"a dropped recovery command must be counted, never silently swallowed"
);
app.send_command(Command::Rediscover);
assert_eq!(app.commands_dropped, 2);
}
#[test]
fn test_app_new_starts_dirty_so_first_frame_draws() {
let (tx, _rx) = mpsc::channel::<Command>(8);
let live = LiveState::new(
source_binding("deribit", full_caps()),
store(&[60_000.0], "deribit"),
);
let app = App::new(Mode::Live(live), ThemeChoice::Auto, tx);
assert!(app.dirty);
}
#[test]
fn test_on_event_tick_idle_does_not_set_dirty() {
let (mut app, _rx) = live_app(full_caps());
match &mut app.mode {
Mode::Live(live) => live.load = ScreenLoad::Ready,
Mode::Replay(_) => panic!("expected a live app"),
}
app.on_event(AppEvent::Tick);
assert!(
!app.dirty,
"an idle non-motion tick must not force a redraw"
);
}
#[test]
fn test_on_event_tick_in_motion_sets_dirty_to_animate_spinner() {
let (mut app, _rx) = live_app(full_caps());
assert!(matches!(
&app.mode,
Mode::Live(live) if matches!(live.load, ScreenLoad::Loading)
));
app.on_event(AppEvent::Tick);
assert!(app.dirty, "a motion-state tick must redraw the spinner");
}
#[test]
fn test_status_hint_decays_after_n_ticks_not_immediately() {
let (mut app, _rx) = live_app(full_caps());
match &mut app.mode {
Mode::Live(live) => live.load = ScreenLoad::Ready,
Mode::Replay(_) => panic!("expected a live app"),
}
app.set_status_hint("Depth not available on deribit".to_owned());
app.mark_drawn();
app.on_event(AppEvent::Tick);
assert!(
app.status_hint.is_some(),
"the hint must survive at least one tick, not flash near-zero"
);
assert!(!app.dirty, "an intermediate countdown tick does not redraw");
let mut cleared = false;
for _ in 0..u32::from(HINT_TICKS) + 2 {
app.mark_drawn();
app.on_event(AppEvent::Tick);
if app.status_hint.is_none() {
assert!(app.dirty, "the decaying tick that clears the hint redraws");
cleared = true;
break;
}
}
assert!(cleared, "the hint decays within its lifetime");
}
#[test]
fn test_on_event_resize_sets_dirty() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::Resize(100, 30));
assert!(app.dirty);
}
#[test]
fn test_mark_drawn_clears_dirty() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::Resize(100, 30));
assert!(app.dirty);
app.mark_drawn();
assert!(!app.dirty);
}
#[test]
fn test_on_event_market_quote_applied_folds_into_store_and_sets_dirty() {
let (mut app, _rx) = live_app(full_caps());
let update = MarketUpdate::Quote(quote(
"deribit",
60_000.0,
OptionStyle::Call,
Some(1.4),
Some(1.6),
EXP + 100,
));
app.on_event(AppEvent::Market(update));
assert!(app.dirty);
let patched = live(&app)
.store
.chain()
.options
.iter()
.find(|o| o.strike_price == pos(60_000.0))
.map(|o| o.call_bid);
assert_eq!(patched, Some(Some(pos(1.4))));
}
#[test]
fn test_on_event_market_quote_dropped_crossed_does_not_set_dirty() {
let (mut app, _rx) = live_app(full_caps());
let update = MarketUpdate::Quote(quote(
"deribit",
60_000.0,
OptionStyle::Call,
Some(2.0),
Some(1.5),
EXP + 100,
));
app.on_event(AppEvent::Market(update));
assert!(!app.dirty);
}
#[test]
fn test_on_event_market_quote_unknown_strike_buffered_does_not_set_dirty() {
let (mut app, _rx) = live_app(full_caps());
let update = MarketUpdate::Quote(quote(
"deribit",
99_000.0,
OptionStyle::Call,
Some(1.0),
Some(1.2),
EXP + 100,
));
app.on_event(AppEvent::Market(update));
assert!(!app.dirty);
}
#[test]
fn test_on_event_market_chain_snapshot_applies_poll_and_sets_dirty() {
let (mut app, _rx) = live_app(full_caps());
let snapshot = ChainSnapshot {
chain_key: (pid("deribit"), "BTC".to_owned(), utc(EXP)),
chain: chain_with(&[61_000.0]),
aliases: AliasCatalog::new(),
source: ChainSource::Merged,
health: StreamHealth::Live,
last_full_poll: Some(utc(EXP + 200)),
};
app.on_event(AppEvent::Market(MarketUpdate::Chain(snapshot)));
assert!(app.dirty);
assert!(live(&app).store.contains_strike(pos(61_000.0)));
assert!(!live(&app).store.contains_strike(pos(60_000.0)));
}
#[test]
fn test_on_event_market_chain_snapshot_without_poll_time_is_noop() {
let (mut app, _rx) = live_app(full_caps());
let snapshot = ChainSnapshot {
chain_key: (pid("deribit"), "BTC".to_owned(), utc(EXP)),
chain: chain_with(&[61_000.0]),
aliases: AliasCatalog::new(),
source: ChainSource::Merged,
health: StreamHealth::Live,
last_full_poll: None,
};
app.on_event(AppEvent::Market(MarketUpdate::Chain(snapshot)));
assert!(!app.dirty);
assert!(live(&app).store.contains_strike(pos(60_000.0)));
assert!(!live(&app).store.contains_strike(pos(61_000.0)));
}
#[test]
fn test_atm_index_of_finds_nearest_strike() {
let chain = chain_with(&[50_000.0, 59_000.0, 70_000.0]);
assert_eq!(super::atm_index_of(&chain), Some(1));
assert_eq!(
super::atm_index_of(&OptionChain::new(
"BTC",
pos(1.0),
"x".to_owned(),
None,
None
)),
None
);
}
#[test]
fn test_live_state_caches_atm_index_and_refreshes_on_poll() {
let (tx, _rx) = mpsc::channel::<Command>(8);
let state = LiveState::new(
source_binding("deribit", full_caps()),
store(&[58_000.0, 60_000.0, 62_000.0], "deribit"),
);
assert_eq!(state.atm_index(), Some(1), "seeded from the initial chain");
let mut app = App::new(Mode::Live(state), ThemeChoice::Auto, tx);
app.on_event(AppEvent::Market(MarketUpdate::Quote(quote(
"deribit",
60_000.0,
OptionStyle::Call,
Some(1.4),
Some(1.6),
EXP + 100,
))));
assert_eq!(
live(&app).atm_index(),
Some(1),
"a quote does not move the ATM"
);
let snapshot = ChainSnapshot {
chain_key: (pid("deribit"), "BTC".to_owned(), utc(EXP)),
chain: chain_with(&[59_000.0, 60_000.0]),
aliases: AliasCatalog::new(),
source: ChainSource::Merged,
health: StreamHealth::Live,
last_full_poll: Some(utc(EXP + 200)),
};
app.on_event(AppEvent::Market(MarketUpdate::Chain(snapshot)));
assert_eq!(
live(&app).atm_index(),
Some(1),
"the poll refreshed the cached ATM index"
);
}
#[test]
fn test_on_event_market_depth_folds_to_store_but_no_redraw_off_screen() {
let (mut app, _rx) = live_app(full_caps());
let ladder = crate::chain::DepthLadder {
instrument: instrument("deribit", 60_000.0, OptionStyle::Call),
bids: Vec::new(),
asks: Vec::new(),
event_time: None,
received_time: utc(EXP + 100),
change_id: None,
};
app.on_event(AppEvent::Market(MarketUpdate::Depth(ladder)));
assert!(!app.dirty, "an off-screen depth fold does not redraw");
assert_eq!(
live(&app).depth_store.len(),
1,
"the ladder was still folded into the store (the store path exists)",
);
}
fn surface_store_strikes(strikes: &[f64]) -> ChainStore {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
for &strike in strikes {
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();
let _ = chain.options.insert(od);
}
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
refresh(),
utc(EXP),
)
}
#[test]
fn test_hidden_surface_fold_defers_rebuild_until_switch_to_surface() {
let mut live = LiveState::new(
source_binding("deribit", full_caps()),
surface_store_strikes(&[60_000.0, 62_000.0, 64_000.0]),
);
assert_eq!(live.screen, LiveScreen::Chain);
assert!(!live.surface.dirty, "the seeded panel is clean");
let rev0 = live.surface.graph_revision();
live.store = surface_store_strikes(&[60_000.0, 62_000.0]);
live.refresh_surface();
assert!(
live.surface.dirty,
"a hidden-screen fold marks the surface dirty (deferred), not rebuilt",
);
assert_eq!(
live.surface.graph_revision(),
rev0,
"the hidden fold does not rebuild the surface geometry (no revision bump)",
);
live.enter_screen(LiveScreen::Surface);
assert!(
!live.surface.dirty,
"the switch clears the deferred-dirty flag"
);
let rev1 = live.surface.graph_revision();
assert_ne!(
rev1, rev0,
"switch-to-surface rebuilt the deferred geometry once"
);
live.store = surface_store_strikes(&[58_000.0, 60_000.0, 62_000.0, 64_000.0]);
live.refresh_surface();
assert!(
!live.surface.dirty,
"an active-screen fold rebuilds immediately (stays clean)",
);
assert_ne!(
live.surface.graph_revision(),
rev1,
"an active-screen fold bumps the revision immediately",
);
}
#[test]
fn test_overlay_aware_health_combines_source_and_overlay() {
let mut live = LiveState::new(
source_binding("deribit", full_caps()),
surface_store_strikes(&[60_000.0, 62_000.0]),
);
assert!(matches!(live.overlay_aware_health(), StreamHealth::Live));
live.overlay = Some(OverlayBinding::new(
pid("dxlink"),
full_caps(),
StreamHealth::Stale { since: utc(EXP) },
));
assert!(
matches!(live.overlay_aware_health(), StreamHealth::Stale { .. }),
"a stale overlay surfaces even while the source is live",
);
live.source.health = StreamHealth::Reconnecting { attempt: 2 };
assert!(
matches!(
live.overlay_aware_health(),
StreamHealth::Reconnecting { .. }
),
"the source health is preferred when both sides are degraded",
);
}
#[test]
fn test_on_event_market_depth_redraws_for_visible_selected_contract() {
let (mut app, _rx) = live_app(full_caps());
match &mut app.mode {
Mode::Live(live) => {
live.screen = LiveScreen::Depth;
live.selection.focused_row = Some(0);
live.selection.focused_leg = LegFocus::Call;
}
Mode::Replay(_) => panic!("expected a live app"),
}
let ladder = crate::chain::DepthLadder {
instrument: instrument("deribit", 60_000.0, OptionStyle::Call),
bids: Vec::new(),
asks: Vec::new(),
event_time: None,
received_time: utc(EXP + 100),
change_id: Some(1),
};
app.on_event(AppEvent::Market(MarketUpdate::Depth(ladder)));
assert!(
app.dirty,
"a depth update for the visible selected contract redraws",
);
}
fn overlay_caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.greeks(GreeksCapability::Provided)
.build()
}
fn live_app_with_overlay() -> (App, mpsc::Receiver<Command>) {
let (tx, rx) = mpsc::channel::<Command>(8);
let live = LiveState::new(
source_binding("deribit", full_caps()),
store(&[60_000.0], "deribit"),
)
.with_overlay(OverlayBinding::new(
pid("dxlink"),
overlay_caps(),
StreamHealth::Live,
));
let mut app = App::new(Mode::Live(live), ThemeChoice::Auto, tx);
app.mark_drawn();
(app, rx)
}
#[test]
fn test_on_event_market_health_degrades_source_side_only() {
let (mut app, _rx) = live_app_with_overlay();
app.on_event(AppEvent::Market(MarketUpdate::Health(
pid("deribit"),
StreamHealth::Reconnecting { attempt: 2 },
)));
assert!(app.dirty);
let live = live(&app);
assert!(matches!(
live.source.health,
StreamHealth::Reconnecting { attempt: 2 }
));
match &live.overlay {
Some(overlay) => assert!(matches!(overlay.health, StreamHealth::Live)),
None => panic!("expected an overlay binding"),
}
}
#[test]
fn test_on_event_market_health_degrades_overlay_side_only() {
let (mut app, _rx) = live_app_with_overlay();
app.on_event(AppEvent::Market(MarketUpdate::Health(
pid("dxlink"),
StreamHealth::Stale { since: utc(EXP) },
)));
assert!(app.dirty);
let live = live(&app);
assert!(matches!(live.source.health, StreamHealth::Live));
match &live.overlay {
Some(overlay) => assert!(matches!(overlay.health, StreamHealth::Stale { .. })),
None => panic!("expected an overlay binding"),
}
}
#[test]
fn test_on_event_market_health_for_unknown_provider_is_noop() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::Market(MarketUpdate::Health(
pid("alpaca"),
StreamHealth::Reconnecting { attempt: 1 },
)));
assert!(!app.dirty);
}
#[test]
fn test_on_key_r_emits_reconnect_command_in_live() {
let (mut app, mut rx) = live_app(full_caps());
app.on_event(key(KeyCode::Char('r')));
match rx.try_recv() {
Ok(Command::Reconnect) => {}
other => panic!("expected Reconnect, got {other:?}"),
}
}
#[test]
fn test_on_key_shift_r_emits_rediscover_command_in_live() {
let (mut app, mut rx) = live_app(full_caps());
app.on_event(key(KeyCode::Char('R')));
match rx.try_recv() {
Ok(Command::Rediscover) => {}
other => panic!("expected Rediscover, got {other:?}"),
}
}
#[test]
fn test_on_key_shift_r_emits_reload_bundle_in_replay() {
let (mut app, mut rx) = replay_app("/bundle");
app.on_event(key(KeyCode::Char('R')));
match rx.try_recv() {
Ok(Command::ReloadBundle(dir)) => assert_eq!(dir, PathBuf::from("/bundle")),
other => panic!("expected ReloadBundle, got {other:?}"),
}
}
#[test]
fn test_on_key_r_in_replay_emits_no_command() {
let (mut app, mut rx) = replay_app("/bundle");
app.on_event(key(KeyCode::Char('r')));
assert!(rx.try_recv().is_err(), "`r` has no replay binding");
}
#[track_caller]
fn replay(app: &App) -> &ReplayState {
match &app.mode {
Mode::Replay(replay) => replay,
Mode::Live(_) => panic!("expected a replay app"),
}
}
#[track_caller]
fn cursor_position(app: &App) -> u32 {
match replay(app).loaded() {
Some(loaded) => loaded.cursor.position(),
None => panic!("expected a Ready bundle"),
}
}
#[test]
fn test_on_replay_seek_folds_into_cursor_no_command() {
let (mut app, mut rx) = ready_replay_app(6);
app.on_event(AppEvent::ReplaySeek(SeekTo::Step(3)));
assert!(app.dirty, "a seek that moves the cursor redraws");
assert_eq!(cursor_position(&app), 3);
assert!(rx.try_recv().is_err(), "a seek emits no command (#33)");
}
#[test]
fn test_on_replay_seek_step_by_walks_and_clamps() {
let (mut app, _rx) = ready_replay_app(4);
app.on_event(AppEvent::ReplaySeek(SeekTo::StepBy(1)));
assert_eq!(cursor_position(&app), 1);
app.on_event(AppEvent::ReplaySeek(SeekTo::Step(u32::MAX)));
assert_eq!(cursor_position(&app), 3, "Step clamps to end_step");
app.mark_drawn();
app.on_event(AppEvent::ReplaySeek(SeekTo::StepBy(1)));
assert!(!app.dirty, "a clamped no-op seek requests no redraw");
assert_eq!(cursor_position(&app), 3);
}
#[test]
fn test_on_replay_seek_while_loading_is_noop() {
let (mut app, mut rx) = replay_app("/bundle");
app.on_event(AppEvent::ReplaySeek(SeekTo::Step(3)));
assert!(!app.dirty, "a seek with no loaded bundle is a no-op");
assert!(rx.try_recv().is_err());
}
#[test]
fn test_on_replay_seek_in_live_is_noop() {
let (mut app, mut rx) = live_app(full_caps());
app.on_event(AppEvent::ReplaySeek(SeekTo::StepBy(1)));
assert!(!app.dirty);
assert!(
rx.try_recv().is_err(),
"a scrub is meaningless in live mode"
);
}
#[test]
fn test_bundle_loaded_transitions_loading_to_ready() {
let (mut app, _rx) = replay_app("/bundle");
assert!(matches!(replay(&app).bundle, BundleLoad::Loading));
app.on_event(AppEvent::BundleLoaded(BundleLoadResult::Loaded(Box::new(
loaded_bundle(5),
))));
assert!(app.dirty, "a completed load redraws");
match replay(&app).loaded() {
Some(loaded) => {
assert_eq!(loaded.cursor.position(), 0, "the cursor starts at step 0");
assert_eq!(loaded.cursor.end_step(), 4, "end_step is n_steps - 1");
assert!(loaded.selection.is_none(), "no drill-down yet");
}
None => panic!("expected Ready after a successful load"),
}
}
#[test]
fn test_bundle_loaded_transitions_loading_to_error() {
let (mut app, _rx) = replay_app("/bundle");
app.on_event(AppEvent::BundleLoaded(BundleLoadResult::Failed(
"invariant violated: equity != cash + position at step 3".to_owned(),
)));
assert!(app.dirty);
match &replay(&app).bundle {
BundleLoad::Error { message } => {
assert!(message.contains("equity"), "the message is actionable");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn test_shift_r_resets_ready_bundle_to_loading_and_reloads() {
let (mut app, mut rx) = ready_replay_app(5);
assert!(replay(&app).loaded().is_some());
app.on_event(key(KeyCode::Char('R')));
assert!(app.dirty);
assert!(
matches!(replay(&app).bundle, BundleLoad::Loading),
"reload resets to Loading",
);
match rx.try_recv() {
Ok(Command::ReloadBundle(dir)) => assert_eq!(dir, PathBuf::from("/bundle")),
other => panic!("expected ReloadBundle, got {other:?}"),
}
}
#[test]
fn test_shift_r_on_a_closed_command_channel_keeps_prior_state_not_loading() {
let (mut app, rx) = ready_replay_app(5);
assert!(replay(&app).loaded().is_some(), "starts Ready");
drop(rx);
app.on_event(key(KeyCode::Char('R')));
assert!(
replay(&app).loaded().is_some(),
"a dropped reload send keeps the prior Ready bundle, not a stuck Loading",
);
assert!(
!matches!(replay(&app).bundle, BundleLoad::Loading),
"the load screen must not spin forever with no load in flight",
);
assert_eq!(app.commands_dropped, 1, "the dropped reload is counted");
assert!(
app.status_hint.is_some(),
"a dropped reload flashes a hint instead of silently no-op'ing",
);
}
#[test]
fn test_reload_after_error_returns_to_loading() {
let (mut app, mut rx) = replay_app("/bundle");
app.on_event(AppEvent::BundleLoaded(BundleLoadResult::Failed(
"missing table: fills.parquet".to_owned(),
)));
assert!(matches!(replay(&app).bundle, BundleLoad::Error { .. }));
app.on_event(key(KeyCode::Char('R')));
assert!(matches!(replay(&app).bundle, BundleLoad::Loading));
assert!(matches!(rx.try_recv(), Ok(Command::ReloadBundle(_))));
app.on_event(AppEvent::BundleLoaded(BundleLoadResult::Loaded(Box::new(
loaded_bundle(3),
))));
assert!(replay(&app).loaded().is_some());
}
#[test]
fn test_bundle_loaded_in_live_is_ignored() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::BundleLoaded(BundleLoadResult::Loaded(Box::new(
loaded_bundle(3),
))));
assert!(!app.dirty);
assert!(matches!(app.mode, Mode::Live(_)));
}
#[test]
fn test_replay_control_play_pause_toggles() {
let (mut app, _rx) = ready_replay_app(6);
assert!(!replay(&app).is_playing());
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
assert!(app.dirty);
assert!(replay(&app).is_playing());
assert_eq!(replay(&app).play, Playback::playing(PlaybackSpeed::X1));
app.mark_drawn();
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
assert!(app.dirty);
assert_eq!(replay(&app).play, Playback::Paused);
}
#[test]
fn test_replay_control_speed_adjusts_only_while_playing() {
let (mut app, _rx) = ready_replay_app(6);
app.on_event(AppEvent::ReplayControl(ReplayControl::SpeedFaster));
assert!(!app.dirty, "speed while paused is a no-op");
assert_eq!(replay(&app).play, Playback::Paused);
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
app.mark_drawn();
app.on_event(AppEvent::ReplayControl(ReplayControl::SpeedFaster));
assert_eq!(replay(&app).play, Playback::playing(PlaybackSpeed::X2));
app.on_event(AppEvent::ReplayControl(ReplayControl::SpeedSlower));
assert_eq!(replay(&app).play, Playback::playing(PlaybackSpeed::X1));
app.mark_drawn();
app.on_event(AppEvent::ReplayControl(ReplayControl::SpeedSlower));
assert!(!app.dirty, "speed clamped at the slowest is a no-op");
}
#[test]
fn test_tick_advances_play_head_only_while_playing() {
let (mut app, _rx) = ready_replay_app(6);
app.on_event(AppEvent::Tick);
assert_eq!(cursor_position(&app), 0);
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
app.on_event(AppEvent::Tick);
assert_eq!(cursor_position(&app), 1);
app.on_event(AppEvent::Tick);
assert_eq!(cursor_position(&app), 2);
}
#[test]
fn test_tick_advance_stops_and_auto_pauses_at_end() {
let (mut app, _rx) = ready_replay_app(3);
app.on_event(AppEvent::ReplaySeek(SeekTo::Step(1)));
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
app.on_event(AppEvent::Tick); assert_eq!(cursor_position(&app), 2);
assert_eq!(
replay(&app).play,
Playback::Paused,
"playback auto-pauses at the tape end"
);
app.mark_drawn();
app.on_event(AppEvent::Tick);
assert!(!app.dirty, "a paused tick at the end does not spin");
assert_eq!(cursor_position(&app), 2);
}
#[test]
fn test_replay_control_in_live_is_ignored() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
assert!(!app.dirty);
}
#[test]
fn test_on_key_q_requests_quit_and_sets_dirty() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(key(KeyCode::Char('q')));
assert!(app.should_quit);
assert!(app.dirty);
}
#[test]
fn test_on_key_ctrl_c_requests_quit() {
let (mut app, _rx) = live_app(full_caps());
app.on_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
assert!(app.should_quit);
}
#[test]
fn test_on_key_question_toggles_help_and_sets_dirty() {
let (mut app, _rx) = live_app(full_caps());
assert!(!app.help_open);
app.on_event(key(KeyCode::Char('?')));
assert!(app.help_open);
assert!(app.dirty);
app.mark_drawn();
app.on_event(key(KeyCode::Char('?')));
assert!(!app.help_open);
assert!(app.dirty);
}
#[track_caller]
fn key_event(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_dispatch_key_global_bound_global_is_consumed() {
let (mut app, _rx) = live_app(full_caps());
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Char('q'))),
KeyRoute::Consumed
);
assert!(app.should_quit);
}
#[test]
fn test_dispatch_key_global_unbound_key_routes_to_screen() {
let (mut app, _rx) = live_app(full_caps());
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Char('j'))),
KeyRoute::ToScreen
);
assert!(!app.dirty, "forwarding a key mutates no global state");
}
#[test]
fn test_dispatch_key_global_modal_help_swallows_and_never_forwards() {
let (mut app, _rx) = live_app(full_caps());
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Char('?'))),
KeyRoute::Consumed
);
assert!(app.help_open);
app.mark_drawn();
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Char('j'))),
KeyRoute::Consumed
);
assert!(app.help_open, "a swallowed key leaves the overlay open");
assert!(!app.dirty, "a swallowed key mutates no state");
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Esc)),
KeyRoute::Consumed
);
assert!(!app.help_open);
assert!(app.dirty);
}
#[test]
fn test_dispatch_key_global_non_press_is_consumed_not_forwarded() {
let (mut app, _rx) = live_app(full_caps());
let release = KeyEvent::new_with_kind(
KeyCode::Char('j'),
KeyModifiers::NONE,
KeyEventKind::Release,
);
assert_eq!(app.dispatch_key_global(release), KeyRoute::Consumed);
}
#[test]
fn test_dispatch_key_global_modal_swallows_out_of_vocab_key() {
let (mut app, _rx) = live_app(full_caps());
assert_eq!(
app.dispatch_key_global(key_event(KeyCode::Char('?'))),
KeyRoute::Consumed
);
assert!(app.help_open);
app.mark_drawn();
for code in [
KeyCode::F(5),
KeyCode::PageUp,
KeyCode::Insert,
KeyCode::Delete,
] {
assert_eq!(
app.dispatch_key_global(key_event(code)),
KeyRoute::Consumed,
"an out-of-vocab key must be swallowed while modal, not forwarded"
);
assert!(app.help_open, "the overlay stays open");
assert!(!app.dirty, "a swallowed key mutates no state");
}
}
#[test]
fn test_is_screen_reachable_depth_gated_on_capability() {
let with_depth = full_caps();
assert!(is_screen_reachable(LiveScreen::Depth, &with_depth));
let without_depth = ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.greeks(GreeksCapability::Provided)
.build();
assert!(!is_screen_reachable(LiveScreen::Depth, &without_depth));
assert!(is_screen_reachable(LiveScreen::Chain, &without_depth));
assert!(is_screen_reachable(LiveScreen::Payoff, &without_depth));
assert!(is_screen_reachable(LiveScreen::Surface, &without_depth));
}
#[test]
fn test_is_screen_reachable_surface_gated_on_greeks() {
let no_greeks = ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.greeks(GreeksCapability::None)
.build();
assert!(!is_screen_reachable(LiveScreen::Surface, &no_greeks));
assert!(is_screen_reachable(LiveScreen::Chain, &no_greeks));
}
#[test]
fn test_set_screen_refuses_unreachable_and_keeps_prior() {
let no_depth = ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.greeks(GreeksCapability::Provided)
.depth(false)
.build();
let (mut app, _rx) = live_app(no_depth);
let switched = match &mut app.mode {
Mode::Live(live) => (live.set_screen(LiveScreen::Depth), live.screen),
Mode::Replay(_) => panic!("expected live"),
};
assert!(!switched.0, "an unreachable screen switch is refused");
assert_eq!(switched.1, LiveScreen::Chain, "the prior screen is kept");
}
#[test]
fn test_set_screen_switches_to_reachable() {
let (mut app, _rx) = live_app(full_caps());
let (switched, screen) = match &mut app.mode {
Mode::Live(live) => (live.set_screen(LiveScreen::Surface), live.screen),
Mode::Replay(_) => panic!("expected live"),
};
assert!(switched);
assert_eq!(screen, LiveScreen::Surface);
}
#[test]
fn test_effective_capabilities_unions_overlay_depth_and_greeks() {
let bare_source = ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.depth(false)
.greeks(GreeksCapability::None)
.build();
let overlay_with_greeks = ProviderCapabilities::builder()
.depth(true)
.greeks(GreeksCapability::ComputedLocally)
.build();
let live = LiveState::new(source_binding("ig", bare_source), store(&[60_000.0], "ig"))
.with_overlay(OverlayBinding::new(
pid("dxlink"),
overlay_with_greeks,
StreamHealth::Live,
));
assert!(live.screen_reachable(LiveScreen::Surface));
assert!(live.screen_reachable(LiveScreen::Depth));
let effective = live.effective_capabilities();
assert!(effective.depth);
assert_eq!(effective.greeks, GreeksCapability::ComputedLocally);
}
#[test]
fn test_mode_and_screen_matches_are_wildcard_free() {
fn mode_label(mode: &Mode) -> &'static str {
match mode {
Mode::Live(_) => "live",
Mode::Replay(_) => "replay",
}
}
fn live_label(screen: LiveScreen) -> &'static str {
match screen {
LiveScreen::Chain => "chain",
LiveScreen::Depth => "depth",
LiveScreen::Surface => "surface",
LiveScreen::Payoff => "payoff",
}
}
fn replay_label(screen: ReplayScreen) -> &'static str {
match screen {
ReplayScreen::Replay => "replay",
ReplayScreen::Payoff => "payoff",
}
}
let (app, _rx) = live_app(full_caps());
assert_eq!(mode_label(&app.mode), "live");
assert_eq!(live_label(LiveScreen::Chain), "chain");
assert_eq!(replay_label(ReplayScreen::Replay), "replay");
}
#[test]
fn test_out_of_mode_screen_pair_is_unrepresentable() {
let replay = ReplayState::new(PathBuf::from("/bundle"));
assert_eq!(replay.screen, ReplayScreen::Replay);
assert!(matches!(replay.bundle, BundleLoad::Loading));
assert_eq!(replay.play, Playback::Paused);
}
#[test]
fn test_screen_load_default_is_loading() {
assert_eq!(ScreenLoad::default(), ScreenLoad::Loading);
let error = ScreenLoad::Error {
message: "no chain for BTC".to_owned(),
};
assert_ne!(error, ScreenLoad::Loading);
}
}