use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use ratatui::layout::Rect;
const STATUS_QUEUE_CAP: usize = 5;
const EQUITY_STREAM_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Debug)]
pub struct StatusMessage {
pub text: String,
pub expires_at: Option<Instant>,
}
impl StatusMessage {
pub fn with_ttl(text: impl Into<String>, ttl: Duration) -> Self {
Self {
text: text.into(),
expires_at: Some(Instant::now() + ttl),
}
}
pub fn persistent(text: impl Into<String>) -> Self {
Self {
text: text.into(),
expires_at: None,
}
}
}
impl Default for StatusMessage {
fn default() -> Self {
Self::persistent("")
}
}
impl PartialEq<str> for StatusMessage {
fn eq(&self, other: &str) -> bool {
self.text == other
}
}
impl PartialEq<&str> for StatusMessage {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
impl PartialEq<String> for StatusMessage {
fn eq(&self, other: &String) -> bool {
self.text == *other
}
}
impl PartialEq<StatusMessage> for StatusMessage {
fn eq(&self, other: &StatusMessage) -> bool {
self.text == other.text
}
}
#[cfg(test)]
pub(crate) mod test_helpers {
use super::*;
use crate::config::{AlpacaConfig, AlpacaEnv};
use crate::types::{Asset, Order, Watchlist};
pub fn make_test_app() -> App {
let (command_tx, _) = tokio::sync::mpsc::channel(1);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
App::new(
AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
Arc::new(tokio::sync::Notify::new()),
command_tx,
symbol_tx,
)
}
pub fn make_order(id: &str, status: &str) -> Order {
Order {
id: id.into(),
symbol: "AAPL".into(),
side: "buy".into(),
qty: Some("10".into()),
notional: None,
order_type: "limit".into(),
limit_price: None,
status: status.into(),
submitted_at: None,
filled_at: None,
filled_qty: "0".into(),
filled_avg_price: None,
time_in_force: "day".into(),
}
}
pub fn make_asset(symbol: &str) -> Asset {
Asset {
id: format!("id-{symbol}"),
symbol: symbol.into(),
name: format!("{symbol} Corp"),
exchange: "NASDAQ".into(),
asset_class: "us_equity".into(),
tradable: true,
shortable: true,
fractionable: true,
easy_to_borrow: true,
}
}
pub fn make_watchlist(symbols: &[&str]) -> Watchlist {
Watchlist {
id: "11111111-1111-1111-1111-111111111111".into(),
name: "Test".into(),
assets: symbols.iter().map(|s| make_asset(s)).collect(),
}
}
}
use ratatui::widgets::TableState;
use tokio::sync::{mpsc, watch, Notify};
use crate::commands::Command;
use crate::config::AlpacaConfig;
use crate::prefs::AppPrefs;
use crate::types::{AccountInfo, MarketClock, Order, Position, Quote, Snapshot, Watchlist};
use crate::ui::theme::Theme;
#[derive(Debug, Clone, PartialEq)]
pub enum Tab {
Account,
Watchlist,
Positions,
Orders,
}
impl Tab {
pub fn index(&self) -> usize {
match self {
Tab::Account => 0,
Tab::Watchlist => 1,
Tab::Positions => 2,
Tab::Orders => 3,
}
}
pub fn from_index(i: usize) -> Self {
match i {
0 => Tab::Account,
1 => Tab::Watchlist,
2 => Tab::Positions,
_ => Tab::Orders,
}
}
pub fn next(&self) -> Self {
Tab::from_index((self.index() + 1) % 4)
}
pub fn prev(&self) -> Self {
Tab::from_index((self.index() + 3) % 4)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrdersSubTab {
Open,
Filled,
Cancelled,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderSide {
Buy,
Sell,
SellShort,
}
impl OrderSide {
pub fn cycle_next(&self) -> Self {
match self {
OrderSide::Buy => OrderSide::Sell,
OrderSide::Sell => OrderSide::SellShort,
OrderSide::SellShort => OrderSide::Buy,
}
}
pub fn cycle_prev(&self) -> Self {
match self {
OrderSide::Buy => OrderSide::SellShort,
OrderSide::Sell => OrderSide::Buy,
OrderSide::SellShort => OrderSide::Sell,
}
}
pub fn as_str(&self) -> &'static str {
match self {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
OrderSide::SellShort => "sell_short",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderField {
Symbol,
Side,
OrderType,
Qty,
Price,
TimeInForce,
Submit,
}
impl OrderField {
pub fn next(&self) -> Self {
match self {
OrderField::Symbol => OrderField::Side,
OrderField::Side => OrderField::OrderType,
OrderField::OrderType => OrderField::Qty,
OrderField::Qty => OrderField::Price,
OrderField::Price => OrderField::TimeInForce,
OrderField::TimeInForce => OrderField::Submit,
OrderField::Submit => OrderField::Symbol,
}
}
pub fn prev(&self) -> Self {
match self {
OrderField::Symbol => OrderField::Submit,
OrderField::Side => OrderField::Symbol,
OrderField::OrderType => OrderField::Side,
OrderField::Qty => OrderField::OrderType,
OrderField::Price => OrderField::Qty,
OrderField::TimeInForce => OrderField::Price,
OrderField::Submit => OrderField::TimeInForce,
}
}
}
#[derive(Debug, Clone)]
pub struct OrderEntryState {
pub symbol: String,
pub side: OrderSide,
pub market_order: bool, pub gtc_order: bool, pub qty_input: String,
pub price_input: String,
pub focused_field: OrderField,
}
impl OrderEntryState {
pub fn new(symbol: String) -> Self {
Self {
symbol,
side: OrderSide::Buy,
market_order: false,
gtc_order: false,
qty_input: String::new(),
price_input: String::new(),
focused_field: OrderField::Qty,
}
}
pub fn with_side(mut self, side: OrderSide) -> Self {
self.side = side;
self
}
}
#[derive(Debug, Clone)]
pub enum ConfirmAction {
CancelOrder(String),
}
#[derive(Debug, Clone)]
pub enum Modal {
Help,
About,
OrderEntry(OrderEntryState),
SymbolDetail(String),
Confirm {
message: String,
action: ConfirmAction,
confirmed: bool,
},
ConfirmRemoveWatchlist {
symbol: String,
watchlist_id: String,
},
AddSymbol {
input: String,
watchlist_id: String,
},
}
#[derive(Default, Clone, Debug)]
pub struct HitAreas {
pub tab_bar: Rect,
pub list_data_start_y: u16,
pub orders_subtab_rects: Vec<Rect>,
pub modal_fields: Vec<(OrderField, Rect)>,
pub modal_submit: Option<Rect>,
pub modal_confirm_buttons: Option<Rect>,
}
pub struct App {
pub config: AlpacaConfig,
pub prefs: AppPrefs,
pub current_theme: Theme,
pub refresh_notify: Arc<Notify>,
pub command_tx: mpsc::Sender<Command>,
pub symbol_tx: watch::Sender<Vec<String>>,
pub account: Option<AccountInfo>,
pub positions: Vec<Position>,
pub orders: Vec<Order>,
pub quotes: HashMap<String, Quote>,
pub watchlist: Option<Watchlist>,
pub watchlist_unavailable: bool,
pub snapshots: HashMap<String, Snapshot>,
pub clock: Option<MarketClock>,
pub equity_history: Vec<u64>,
pub intraday_bars: HashMap<String, Vec<u64>>,
pub active_tab: Tab,
pub watchlist_state: TableState,
pub positions_state: TableState,
pub orders_state: TableState,
pub orders_subtab: OrdersSubTab,
pub modal: Option<Modal>,
pub search_query: String,
pub searching: bool,
pub status_queue: VecDeque<StatusMessage>,
pub should_quit: bool,
pub needs_redraw: bool,
pub pending_requests: u8,
pub last_updated: Option<DateTime<Local>>,
pub spinner_tick: u8,
pub market_stream_ok: bool,
pub account_stream_ok: bool,
pub hit_areas: HitAreas,
pub pending_g_at: Option<Instant>,
pub last_equity_stream_push: Option<Instant>,
pub intraday_fetched_at: HashMap<String, Instant>,
}
impl App {
pub fn new(
config: AlpacaConfig,
prefs: AppPrefs,
refresh_notify: Arc<Notify>,
command_tx: mpsc::Sender<Command>,
symbol_tx: watch::Sender<Vec<String>>,
) -> Self {
let current_theme = Theme::from_str(&prefs.ui.theme);
Self {
config,
prefs,
current_theme,
refresh_notify,
command_tx,
symbol_tx,
account: None,
positions: Vec::new(),
orders: Vec::new(),
quotes: HashMap::new(),
watchlist: None,
watchlist_unavailable: false,
snapshots: HashMap::new(),
clock: None,
equity_history: Vec::new(),
intraday_bars: HashMap::new(),
active_tab: Tab::Account,
watchlist_state: TableState::default(),
positions_state: TableState::default(),
orders_state: TableState::default(),
orders_subtab: OrdersSubTab::Open,
modal: None,
search_query: String::new(),
searching: false,
status_queue: VecDeque::new(),
should_quit: false,
needs_redraw: false,
pending_requests: 0,
last_updated: None,
spinner_tick: 0,
market_stream_ok: false,
account_stream_ok: false,
hit_areas: HitAreas::default(),
pending_g_at: None,
last_equity_stream_push: None,
intraday_fetched_at: HashMap::new(),
}
}
pub fn filtered_orders(&self) -> Vec<&Order> {
self.orders
.iter()
.filter(|o| match self.orders_subtab {
OrdersSubTab::Open => {
matches!(
o.status.as_str(),
"new" | "pending_new" | "accepted" | "held" | "partially_filled"
)
}
OrdersSubTab::Filled => o.status == "filled",
OrdersSubTab::Cancelled => {
matches!(
o.status.as_str(),
"canceled" | "expired" | "rejected" | "replaced"
)
}
})
.collect()
}
pub fn selected_watchlist_symbol(&self) -> Option<String> {
let wl = self.watchlist.as_ref()?;
let assets = if self.searching {
wl.assets
.iter()
.filter(|a| {
a.symbol
.to_lowercase()
.contains(&self.search_query.to_lowercase())
|| a.name
.to_lowercase()
.contains(&self.search_query.to_lowercase())
})
.collect::<Vec<_>>()
} else {
wl.assets.iter().collect()
};
let i = self.watchlist_state.selected()?;
assets.get(i).map(|a| a.symbol.clone())
}
pub fn selected_position_symbol(&self) -> Option<String> {
let i = self.positions_state.selected()?;
self.positions.get(i).map(|p| p.symbol.clone())
}
pub fn selected_order_id(&self) -> Option<String> {
let orders = self.filtered_orders();
let i = self.orders_state.selected()?;
orders.get(i).map(|o| o.id.clone())
}
pub fn selected_order_symbol(&self) -> Option<String> {
let orders = self.filtered_orders();
let i = self.orders_state.selected()?;
orders.get(i).map(|o| o.symbol.clone())
}
pub fn focused_symbol(&self) -> Option<String> {
match self.active_tab {
Tab::Watchlist => self.selected_watchlist_symbol(),
Tab::Positions => self.selected_position_symbol(),
Tab::Orders => self.selected_order_symbol(),
Tab::Account => None,
}
}
pub fn push_equity(&mut self) {
if let Some(account) = &self.account {
if let Ok(v) = account.equity.parse::<f64>() {
self.equity_history.push((v * 100.0) as u64);
if self.equity_history.len() > 120 {
self.equity_history.remove(0);
}
}
}
}
pub fn push_equity_from_quotes(&mut self) {
if let Some(last) = self.last_equity_stream_push {
if last.elapsed() < EQUITY_STREAM_INTERVAL {
return;
}
}
if self.positions.is_empty() {
return;
}
let position_value: f64 = self
.positions
.iter()
.filter_map(|p| {
let qty = p.qty.parse::<f64>().ok()?;
let price = self
.quotes
.get(&p.symbol)
.and_then(|q| q.ap.or(q.bp))
.or_else(|| p.current_price.parse::<f64>().ok())?;
Some(qty * price)
})
.sum();
let cash: f64 = self
.account
.as_ref()
.and_then(|a| a.cash.parse::<f64>().ok())
.unwrap_or(0.0);
let equity = cash + position_value;
if equity > 0.0 {
self.equity_history.push((equity * 100.0) as u64);
if self.equity_history.len() > 120 {
self.equity_history.remove(0);
}
self.last_equity_stream_push = Some(Instant::now());
}
}
pub fn push_status(&mut self, msg: StatusMessage) {
if self.status_queue.len() >= STATUS_QUEUE_CAP {
self.status_queue.pop_front();
}
self.status_queue.push_back(msg);
}
pub fn current_status_text(&self) -> &str {
self.status_queue
.front()
.map(|m| m.text.as_str())
.unwrap_or("")
}
pub fn push_transient_status(&mut self, text: impl Into<String>) {
self.push_status(StatusMessage::with_ttl(text, self.prefs.status_ttl()));
}
pub fn push_fill_notification(&mut self, text: impl Into<String>) {
self.push_status(StatusMessage::with_ttl(text, self.prefs.fill_ttl()));
}
pub fn request_started(&mut self) {
self.pending_requests = self.pending_requests.saturating_add(1);
}
pub fn request_finished(&mut self) {
self.pending_requests = self.pending_requests.saturating_sub(1);
if self.pending_requests == 0 {
self.last_updated = Some(Local::now());
}
}
pub fn spinner_frame(&self) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[(self.spinner_tick as usize) % FRAMES.len()]
}
pub fn tick_spinner(&mut self) {
self.spinner_tick = self.spinner_tick.wrapping_add(1);
}
pub fn cycle_theme(&mut self) {
self.current_theme = self.current_theme.cycle();
self.prefs.ui.theme = self.current_theme.as_str().to_string();
if let Some(path) = AppPrefs::default_path() {
if let Err(e) = self.prefs.write_to(&path) {
tracing::warn!(error = %e, "could not persist theme to config");
}
}
}
}
#[cfg(test)]
mod tests {
use super::test_helpers::*;
use super::*;
use crate::types::AccountInfo;
#[test]
fn tab_next_wraps_full_cycle() {
assert_eq!(Tab::Account.next(), Tab::Watchlist);
assert_eq!(Tab::Watchlist.next(), Tab::Positions);
assert_eq!(Tab::Positions.next(), Tab::Orders);
assert_eq!(Tab::Orders.next(), Tab::Account);
}
#[test]
fn tab_prev_wraps_full_cycle() {
assert_eq!(Tab::Account.prev(), Tab::Orders);
assert_eq!(Tab::Orders.prev(), Tab::Positions);
assert_eq!(Tab::Positions.prev(), Tab::Watchlist);
assert_eq!(Tab::Watchlist.prev(), Tab::Account);
}
#[test]
fn tab_from_index_all_variants() {
assert_eq!(Tab::from_index(0), Tab::Account);
assert_eq!(Tab::from_index(1), Tab::Watchlist);
assert_eq!(Tab::from_index(2), Tab::Positions);
assert_eq!(Tab::from_index(3), Tab::Orders);
assert_eq!(Tab::from_index(4), Tab::Orders); }
#[test]
fn tab_index_all_variants() {
assert_eq!(Tab::Account.index(), 0);
assert_eq!(Tab::Watchlist.index(), 1);
assert_eq!(Tab::Positions.index(), 2);
assert_eq!(Tab::Orders.index(), 3);
}
#[test]
fn order_field_next_full_cycle() {
assert_eq!(OrderField::Symbol.next(), OrderField::Side);
assert_eq!(OrderField::Side.next(), OrderField::OrderType);
assert_eq!(OrderField::OrderType.next(), OrderField::Qty);
assert_eq!(OrderField::Qty.next(), OrderField::Price);
assert_eq!(OrderField::Price.next(), OrderField::TimeInForce);
assert_eq!(OrderField::TimeInForce.next(), OrderField::Submit);
assert_eq!(OrderField::Submit.next(), OrderField::Symbol);
}
#[test]
fn order_field_prev_full_cycle() {
assert_eq!(OrderField::Symbol.prev(), OrderField::Submit);
assert_eq!(OrderField::Submit.prev(), OrderField::TimeInForce);
assert_eq!(OrderField::TimeInForce.prev(), OrderField::Price);
assert_eq!(OrderField::Price.prev(), OrderField::Qty);
assert_eq!(OrderField::Qty.prev(), OrderField::OrderType);
assert_eq!(OrderField::OrderType.prev(), OrderField::Side);
assert_eq!(OrderField::Side.prev(), OrderField::Symbol);
}
#[test]
fn filtered_orders_open_includes_correct_statuses() {
let mut app = make_test_app();
app.orders = vec![
make_order("1", "accepted"),
make_order("2", "pending_new"),
make_order("3", "partially_filled"),
make_order("4", "held"),
make_order("5", "new"),
make_order("6", "filled"),
make_order("7", "canceled"),
];
app.orders_subtab = OrdersSubTab::Open;
let open = app.filtered_orders();
assert_eq!(open.len(), 5);
assert!(!open
.iter()
.any(|o| o.status == "filled" || o.status == "canceled"));
}
#[test]
fn filtered_orders_filled_only() {
let mut app = make_test_app();
app.orders = vec![
make_order("1", "filled"),
make_order("2", "accepted"),
make_order("3", "filled"),
];
app.orders_subtab = OrdersSubTab::Filled;
let filled = app.filtered_orders();
assert_eq!(filled.len(), 2);
assert!(filled.iter().all(|o| o.status == "filled"));
}
#[test]
fn filtered_orders_cancelled_includes_all_terminal_statuses() {
let mut app = make_test_app();
app.orders = vec![
make_order("1", "canceled"),
make_order("2", "expired"),
make_order("3", "rejected"),
make_order("4", "replaced"),
make_order("5", "filled"),
];
app.orders_subtab = OrdersSubTab::Cancelled;
let cancelled = app.filtered_orders();
assert_eq!(cancelled.len(), 4);
}
#[test]
fn filtered_orders_empty_returns_empty() {
let mut app = make_test_app();
app.orders_subtab = OrdersSubTab::Open;
assert!(app.filtered_orders().is_empty());
}
#[test]
fn push_equity_parses_and_appends_cents() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
equity: "1000.50".into(),
..Default::default()
});
app.push_equity();
assert_eq!(app.equity_history, vec![100050]);
}
#[test]
fn push_equity_caps_at_120_entries() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
equity: "1".into(),
..Default::default()
});
for _ in 0..121 {
app.push_equity();
}
assert_eq!(app.equity_history.len(), 120);
}
#[test]
fn push_equity_ignores_non_numeric_string() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
equity: "N/A".into(),
..Default::default()
});
app.push_equity();
assert!(app.equity_history.is_empty());
}
#[test]
fn push_equity_no_account_is_noop() {
let mut app = make_test_app();
app.push_equity();
assert!(app.equity_history.is_empty());
}
fn make_position_with_price(
symbol: &str,
qty: &str,
current_price: &str,
) -> crate::types::Position {
crate::types::Position {
symbol: symbol.into(),
qty: qty.into(),
avg_entry_price: current_price.into(),
current_price: current_price.into(),
market_value: "0".into(),
unrealized_pl: "0".into(),
unrealized_plpc: "0".into(),
side: "long".into(),
asset_class: "us_equity".into(),
}
}
#[test]
fn push_equity_from_quotes_no_positions_is_noop() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "10000.00".into(),
..Default::default()
});
app.push_equity_from_quotes();
assert!(app.equity_history.is_empty());
}
#[test]
fn push_equity_from_quotes_uses_live_quote_ask_price() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("AAPL", "10", "150.00")];
app.quotes.insert(
"AAPL".into(),
crate::types::Quote {
symbol: "AAPL".into(),
ap: Some(200.00),
bp: None,
..Default::default()
},
);
app.push_equity_from_quotes();
assert_eq!(app.equity_history, vec![200_000]);
}
#[test]
fn push_equity_from_quotes_falls_back_to_bid_when_no_ask() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("TSLA", "5", "300.00")];
app.quotes.insert(
"TSLA".into(),
crate::types::Quote {
symbol: "TSLA".into(),
ap: None,
bp: Some(250.00),
..Default::default()
},
);
app.push_equity_from_quotes();
assert_eq!(app.equity_history, vec![125_000]);
}
#[test]
fn push_equity_from_quotes_falls_back_to_current_price_when_no_quote() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("NVDA", "2", "400.00")];
app.push_equity_from_quotes();
assert_eq!(app.equity_history, vec![80_000]);
}
#[test]
fn push_equity_from_quotes_includes_cash() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "500.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("AAPL", "1", "100.00")];
app.push_equity_from_quotes();
assert_eq!(app.equity_history, vec![60_000]);
}
#[test]
fn push_equity_from_quotes_throttles_rapid_calls() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("AAPL", "1", "100.00")];
app.push_equity_from_quotes();
assert_eq!(app.equity_history.len(), 1);
app.push_equity_from_quotes();
assert_eq!(
app.equity_history.len(),
1,
"second immediate call should be throttled"
);
}
#[test]
fn push_equity_from_quotes_caps_at_120_entries() {
let mut app = make_test_app();
app.account = Some(AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position_with_price("AAPL", "1", "100.00")];
app.equity_history = vec![1u64; 120];
app.last_equity_stream_push = None;
app.push_equity_from_quotes();
assert_eq!(app.equity_history.len(), 120, "should stay at 120 cap");
}
#[test]
fn selected_watchlist_symbol_returns_at_index() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA", "NVDA"]));
app.watchlist_state.select(Some(1));
assert_eq!(app.selected_watchlist_symbol(), Some("TSLA".into()));
}
#[test]
fn selected_watchlist_symbol_none_when_no_selection() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["AAPL"]));
assert_eq!(app.selected_watchlist_symbol(), None);
}
#[test]
fn selected_watchlist_symbol_with_search_filter() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA", "AMD"]));
app.searching = true;
app.search_query = "ts".into();
app.watchlist_state.select(Some(0)); assert_eq!(app.selected_watchlist_symbol(), Some("TSLA".into()));
}
#[test]
fn selected_order_symbol_returns_symbol_of_selected_order() {
let mut app = make_test_app();
app.orders = vec![make_order("id-1", "new"), make_order("id-2", "filled")];
app.orders_state.select(Some(0));
assert_eq!(app.selected_order_symbol(), Some("AAPL".into()));
}
#[test]
fn selected_order_symbol_none_when_no_selection() {
let mut app = make_test_app();
app.orders = vec![make_order("id-1", "new")];
assert_eq!(app.selected_order_symbol(), None);
}
#[test]
fn focused_symbol_watchlist_tab_returns_selected_symbol() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA"]));
app.watchlist_state.select(Some(0));
assert_eq!(app.focused_symbol(), Some("AAPL".into()));
}
#[test]
fn focused_symbol_positions_tab_returns_selected_symbol() {
let mut app = make_test_app();
app.active_tab = Tab::Positions;
app.positions = vec![crate::types::Position {
symbol: "MSFT".into(),
qty: "5".into(),
avg_entry_price: "300".into(),
current_price: "310".into(),
market_value: "1550".into(),
unrealized_pl: "50".into(),
unrealized_plpc: "0.032".into(),
side: "long".into(),
asset_class: "us_equity".into(),
}];
app.positions_state.select(Some(0));
assert_eq!(app.focused_symbol(), Some("MSFT".into()));
}
#[test]
fn focused_symbol_orders_tab_returns_selected_symbol() {
let mut app = make_test_app();
app.active_tab = Tab::Orders;
app.orders = vec![make_order("id-1", "new")];
app.orders_state.select(Some(0));
assert_eq!(app.focused_symbol(), Some("AAPL".into()));
}
#[test]
fn focused_symbol_account_tab_returns_none() {
let app = make_test_app();
assert_eq!(app.focused_symbol(), None);
}
#[test]
fn focused_symbol_returns_none_when_nothing_selected() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL"]));
assert_eq!(app.focused_symbol(), None);
}
}