Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions crates/cdcx-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,26 @@ impl App {
.map(|tab| tab.on_key(key, &mut self.state))
.unwrap_or(false);

// Handle staged trade prefill from the Market book cursor. The
// tab staged a (instrument, prefill) pair; we materialize it into
// the appropriate workflow here because only `App` owns the
// workflow slot.
if let Some((instrument, prefill)) = self.state.pending_trade_from_book.take() {
if self.state.paper_mode {
self.workflow = Some(Box::new(PaperOrderWorkflow::new_with_prefill(
instrument, prefill,
)));
} else {
self.workflow = Some(Box::new(PlaceOrderWorkflow::new_with_prefill(
instrument,
&self.state,
prefill,
)));
}
self.mode = Mode::Workflow;
return;
}

// Handle cross-tab navigation requests (e.g. watchlist Enter → market detail)
if let Some((target_tab, instrument)) = self.state.pending_navigation.take() {
let origin_tab = TabKind::ALL[self.active_tab];
Expand Down Expand Up @@ -1041,6 +1061,7 @@ const HELP_PAGES: &[HelpPage] = &[
bindings: &[
("Esc", "Clear cursor / back to table"),
("\u{2191}\u{2193}", "Move book cursor between levels"),
("Enter", "Place LIMIT at cursor level (prefills modal)"),
("k", "Switch to candlestick chart"),
("m", "Switch to compare view"),
("D", "Cycle order-book depth (10/50/150)"),
Expand Down Expand Up @@ -1238,6 +1259,7 @@ mod tests {
volume_unit: crate::state::VolumeUnit::Usd,
pending_navigation: None,
pending_return_tab: None,
pending_trade_from_book: None,
instrument_types: std::collections::HashMap::new(),
user_connection: crate::state::ConnectionStatus::Error,
isolated_positions: std::collections::HashMap::new(),
Expand Down Expand Up @@ -1532,4 +1554,34 @@ mod tests {
app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!app.show_help, "Esc must dismiss help");
}

/// When MarketTab stages a trade prefill in `pending_trade_from_book`,
/// the next app-level key tick must consume it and flip into
/// Workflow mode with the PlaceOrderWorkflow active. The inverse
/// (paper mode → PaperOrderWorkflow) is covered by code review +
/// manual QA; here we pin the live path which is the hot one.
#[test]
fn app_consumes_staged_trade_prefill_into_workflow() {
let mut app = make_app();
app.state.paper_mode = false;
app.state.pending_trade_from_book = Some((
"BTC_USDT".into(),
crate::workflows::TradePrefill {
side: "BUY",
price: "78550.2".into(),
qty: "0.125".into(),
},
));

// Send any no-op key through the main dispatch path. The prefill
// handler runs after tab.on_key and must pick up the stage.
app.on_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));

assert!(
app.state.pending_trade_from_book.is_none(),
"prefill must be consumed"
);
assert_eq!(app.mode, Mode::Workflow);
assert!(app.workflow.is_some(), "workflow must have been created");
}
}
1 change: 1 addition & 0 deletions crates/cdcx-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ pub async fn run(opts: TuiOptions) -> Result<(), Box<dyn std::error::Error>> {
volume_unit: crate::state::VolumeUnit::Usd,
pending_navigation: None,
pending_return_tab: None,
pending_trade_from_book: None,
isolated_positions: std::collections::HashMap::new(),
positions_snapshot: Vec::new(),
update_notice: None,
Expand Down
6 changes: 6 additions & 0 deletions crates/cdcx-tui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ pub struct AppState {
pub pending_navigation: Option<(crate::tabs::TabKind, String)>,
/// Return to a previous tab (e.g. Esc from detail navigated via another tab).
pub pending_return_tab: Option<crate::tabs::TabKind>,
/// Staged trade parameters from the Market book cursor. A non-None
/// value means "Enter was pressed on a selected book level — open the
/// place-order workflow with these pre-filled". Consumed by app.rs
/// after the tab's `on_key` returns. Mirrors the `pending_navigation`
/// pattern — tab signals intent, app.rs instantiates the modal.
pub pending_trade_from_book: Option<(String, crate::workflows::TradePrefill)>,
/// instrument_name → isolation_id for currently-open isolated-margin positions.
/// Populated from `user.positions` WS channel + `private/get-positions` REST responses.
/// Required to add to / trim an existing isolated position without triggering error 617.
Expand Down
108 changes: 108 additions & 0 deletions crates/cdcx-tui/src/tabs/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,32 @@ impl MarketTab {
format!("book.{}.{}", self.detail_instrument, self.book_depth)
}

/// Build a `TradePrefill` from the current `book_cursor` position,
/// reading price + qty straight from the raw book JSON at the cursor's
/// level. Returns `None` if the cursor is inactive, the book hasn't
/// arrived, or the level has no usable price/qty strings.
///
/// Side flip: an Ask cursor means the user wants to *buy from* that
/// ask → `side = "BUY"`. Bid cursor → `side = "SELL"`. Documented in
/// the test `prefill_side_flips_*` cases.
fn build_prefill_from_cursor(&self) -> Option<crate::workflows::TradePrefill> {
let cursor = self.book_cursor?;
let book = self.book_data.as_ref()?.get("data")?.as_array()?.first()?;
let (side_key, side_str, idx) = match cursor {
BookCursor::Ask(i) => ("asks", "BUY", i),
BookCursor::Bid(i) => ("bids", "SELL", i),
};
let level = book.get(side_key)?.as_array()?.get(idx)?;
let arr = level.as_array()?;
let price = arr.first()?.as_str()?.to_string();
let qty = arr.get(1)?.as_str()?.to_string();
Some(crate::workflows::TradePrefill {
side: side_str,
price,
qty,
})
}

/// Re-validate `book_cursor` against the current level counts. Called
/// after each `book_data` refresh — depth changes and WS snapshots can
/// shrink either side, and a cursor pointing past the end would render
Expand Down Expand Up @@ -708,6 +734,20 @@ impl Tab for MarketTab {
}
KeyCode::Down => self.cursor_move_down(),
KeyCode::Up => self.cursor_move_up(),
KeyCode::Enter => {
// Enter with a cursor active → stage a trade prefill
// for app.rs to materialize into a workflow modal.
// Without a cursor, Enter is inert on Detail (nothing
// selectable to trade against yet).
if let Some(prefill) = self.build_prefill_from_cursor() {
state.pending_trade_from_book =
Some((self.detail_instrument.clone(), prefill));
self.book_cursor = None;
true
} else {
false
}
}
KeyCode::Char('k') => {
self.book_cursor = None;
self.enter_chart(state);
Expand Down Expand Up @@ -2070,4 +2110,72 @@ mod tests {
assert_eq!(bps_from_mid(99.0, 100.0), -100.0);
assert_eq!(bps_from_mid(100.0, 0.0), 0.0);
}

// ---- Enter-on-cursor → prefill (Issue #34) ----

/// Ready a Market tab with a fake book + cursor for the prefill tests.
/// Mirrors the shape that `public/get-book` returns so the same code
/// path (`self.book_data.get("data")[0]["asks"|"bids"]`) is exercised.
fn tab_with_book_and_cursor(cursor: BookCursor) -> MarketTab {
let mut tab = MarketTab::new();
tab.detail_instrument = "BTC_USDT".into();
tab.view_mode = ViewMode::Detail;
tab.book_data = Some(serde_json::json!({
"data": [{
"asks": [["78550.2", "0.125"], ["78551.0", "0.300"], ["78552.5", "1.000"]],
"bids": [["78549.8", "0.075"], ["78549.0", "0.500"], ["78548.0", "2.000"]],
}]
}));
tab.book_cursor = Some(cursor);
tab
}

/// Ask cursor → BUY side. The user is inspecting "who's selling at
/// this price" — Enter should set them up to buy into that offer.
#[test]
fn prefill_side_flips_buy_for_ask() {
let tab = tab_with_book_and_cursor(BookCursor::Ask(1));
let prefill = tab.build_prefill_from_cursor().expect("prefill present");
assert_eq!(prefill.side, "BUY");
assert_eq!(prefill.price, "78551.0");
assert_eq!(prefill.qty, "0.300");
}

/// Bid cursor → SELL side. User is inspecting liquidity on the bid;
/// Enter sets them up to sell into that bid.
#[test]
fn prefill_side_flips_sell_for_bid() {
let tab = tab_with_book_and_cursor(BookCursor::Bid(2));
let prefill = tab.build_prefill_from_cursor().expect("prefill present");
assert_eq!(prefill.side, "SELL");
assert_eq!(prefill.price, "78548.0");
assert_eq!(prefill.qty, "2.000");
}

/// No cursor → no prefill. Enter on Detail without ↑↓ pressed first
/// must not accidentally open a workflow with stale state.
#[test]
fn prefill_returns_none_when_cursor_inactive() {
let mut tab = tab_with_book_and_cursor(BookCursor::Ask(0));
tab.book_cursor = None;
assert!(tab.build_prefill_from_cursor().is_none());
}

/// Book hasn't arrived yet → no prefill. Defends against a race
/// between entering Detail and the first REST snapshot landing.
#[test]
fn prefill_returns_none_when_book_empty() {
let mut tab = tab_with_book_and_cursor(BookCursor::Ask(0));
tab.book_data = None;
assert!(tab.build_prefill_from_cursor().is_none());
}

/// Cursor points past the end of its side → None rather than panic.
/// The `clamp_book_cursor` path normally prevents this, but we defend
/// at the leaf in case a clamp is skipped or races a book update.
#[test]
fn prefill_returns_none_when_cursor_out_of_bounds() {
let tab = tab_with_book_and_cursor(BookCursor::Ask(99));
assert!(tab.build_prefill_from_cursor().is_none());
}
}
19 changes: 15 additions & 4 deletions crates/cdcx-tui/src/widgets/detail_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,21 @@ pub fn draw_detail(
// Footer
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(
"Esc:back \u{2191}\u{2193}:level k:chart D:depth({}) t:trade",
book_depth
),
// Footer content shifts when the cursor is active: Enter
// becomes meaningful (trade at selected level) and takes
// priority over the trade-anywhere 't' binding. Users without
// an active cursor see the original hint.
if book_cursor.is_some() {
format!(
"Esc:clear \u{2191}\u{2193}:level Enter:trade@level k:chart D:depth({})",
book_depth
)
} else {
format!(
"Esc:back \u{2191}\u{2193}:level k:chart D:depth({}) t:trade",
book_depth
)
},
Style::default().fg(state.theme.colors.muted),
))),
footer_area,
Expand Down
19 changes: 19 additions & 0 deletions crates/cdcx-tui/src/workflows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ pub enum WorkflowResult {
Cancel,
}

/// Pre-populated order parameters handed from the Market book-cursor into
/// the place-order workflow. Kept as a flat value type so the constructor
/// signature stays cheap and the shape is trivial to assert in tests.
/// Only LIMIT orders: MARKET prefill makes no semantic sense when the
/// user explicitly picked a price on the book.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TradePrefill {
/// "BUY" or "SELL" — already flipped from the cursor side by the caller
/// (Ask cursor → BUY, Bid cursor → SELL).
pub side: &'static str,
/// Raw price string from the selected book level. Kept as a string so
/// the exchange's native tick precision is preserved (parsing to f64
/// and back would mangle trailing zeros and crypto decimals).
pub price: String,
/// Default quantity = the selected level's qty. The user is expected
/// to override this; it's a starting quote, not a commitment.
pub qty: String,
}

pub trait Workflow {
fn on_key(&mut self, key: KeyEvent, state: &mut AppState) -> WorkflowResult;
fn draw(&self, frame: &mut Frame, area: Rect, state: &AppState);
Expand Down
21 changes: 20 additions & 1 deletion crates/cdcx-tui/src/workflows/paper_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;

use crate::state::AppState;
use crate::workflows::{modal_area, Workflow, WorkflowResult};
use crate::workflows::{modal_area, TradePrefill, Workflow, WorkflowResult};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Step {
Expand Down Expand Up @@ -47,6 +47,25 @@ impl PaperOrderWorkflow {
}
}

/// Build a paper workflow pre-filled from the Market book cursor.
/// Mirrors `PlaceOrderWorkflow::new_with_prefill`. Paper's order_type
/// encoding is inverted (0 = MARKET, 1 = LIMIT) vs live, so the LIMIT
/// mapping differs — worth a comment to save the next reader a trip
/// to the Step enum.
pub fn new_with_prefill(instrument: String, prefill: TradePrefill) -> Self {
Self {
instrument,
step: Step::Confirm,
side: if prefill.side == "SELL" { 1 } else { 0 },
order_type: 1, // LIMIT (paper inverts: 0 = MARKET, 1 = LIMIT)
price_input: prefill.price,
qty_input: prefill.qty,
result_msg: None,
result_ok: false,
error: None,
}
}

fn side_str(&self) -> &'static str {
if self.side == 0 {
"BUY"
Expand Down
27 changes: 26 additions & 1 deletion crates/cdcx-tui/src/workflows/place_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;

use crate::state::{AppState, RestRequest};
use crate::workflows::{modal_area, Workflow, WorkflowResult};
use crate::workflows::{modal_area, TradePrefill, Workflow, WorkflowResult};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Step {
Expand Down Expand Up @@ -65,6 +65,31 @@ impl PlaceOrderWorkflow {
}
}

/// Build a workflow pre-filled from the Market book cursor. Skips
/// straight to `Step::Confirm` so the user sees the full summary and
/// presses Enter once to submit; they can Shift+Tab back to edit any
/// field. LIMIT-only — the prefill DTO enforces this (see
/// `workflows::TradePrefill`).
pub fn new_with_prefill(instrument: String, state: &AppState, prefill: TradePrefill) -> Self {
let isolated_default = state
.instrument_types
.get(&instrument)
.map(|t| requires_isolated_margin(t))
.unwrap_or(false);
Self {
instrument_input: instrument.clone(),
instrument,
step: Step::Confirm,
side: if prefill.side == "SELL" { 1 } else { 0 },
order_type: 0, // LIMIT
price_input: prefill.price,
qty_input: prefill.qty,
error: None,
isolated_margin: isolated_default,
rejection: None,
}
}

fn side_str(&self) -> &'static str {
if self.side == 0 {
"BUY"
Expand Down
Loading