mod feeds;
mod settings;
pub use feeds::{FeedBundle, FeedKind};
pub use settings::{SettingsRow, SettingsState, settings_rows};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Local, NaiveDate, Utc};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use ratatui::widgets::TableState;
use tokio::sync::Notify;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::alphai::{self, Article};
use crate::config::{self, ChartDefaults, Config, UiDefaults};
use crate::domain::{Interval, Range, Sessions, TickerData};
use crate::indicators::MaType;
use crate::keymap::{Action, Keymap};
use crate::poller::{SharedEvery, SharedParams, SharedSource, SharedSymbols, SourceEvent};
use crate::portfolio::{self, Position};
use crate::theme::Theme;
use crate::ui;
pub const PRICE_FLASH: Duration = Duration::from_millis(1500);
const FALLBACK_AFTER: Duration = Duration::from_secs(45);
const NOTICE: Duration = Duration::from_secs(60);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChartStyle {
Candles,
Line,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NewsScope {
#[default]
Ticker,
Market,
Trending,
}
impl NewsScope {
pub fn next(self) -> Self {
match self {
Self::Ticker => Self::Market,
Self::Market => Self::Trending,
Self::Trending => Self::Ticker,
}
}
pub fn label(self, symbol: &str) -> &str {
match self {
Self::Ticker => symbol,
Self::Market => "market",
Self::Trending => "trending",
}
}
}
#[derive(Default)]
pub struct ArticleOverlay {
pub open: bool,
pub scroll: u16,
pub inline: bool,
}
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub enum PromptKind {
#[default]
Ticker,
Position,
}
#[derive(Default)]
pub struct Prompt {
pub open: bool,
pub kind: PromptKind,
pub input: String,
pub target: String,
pub error: Option<String>,
}
#[derive(Default)]
pub struct HelpOverlay {
pub open: bool,
pub scroll: u16,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NewsLayout {
Side,
#[default]
Chart,
Stacked,
}
impl NewsLayout {
pub const ALL: [Self; 3] = [Self::Chart, Self::Stacked, Self::Side];
pub fn next(self) -> Self {
self.step(1)
}
pub fn step(self, dir: isize) -> Self {
let n = Self::ALL.len() as isize;
let i = Self::ALL.iter().position(|l| *l == self).unwrap_or(0) as isize;
Self::ALL[(i + dir).rem_euclid(n) as usize]
}
pub fn name(self) -> &'static str {
match self {
Self::Side => "side",
Self::Chart => "chart",
Self::Stacked => "stacked",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|l| l.name().eq_ignore_ascii_case(name.trim()))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InsiderChartWindow {
Off,
#[default]
M3,
M12,
}
impl InsiderChartWindow {
pub fn next(self) -> Self {
match self {
Self::Off => Self::M3,
Self::M3 => Self::M12,
Self::M12 => Self::Off,
}
}
pub fn days(self) -> Option<u32> {
match self {
Self::Off => None,
Self::M3 => Some(91),
Self::M12 => Some(364),
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "off",
Self::M3 => "3m",
Self::M12 => "12m",
}
}
}
pub struct EarningsSlot {
pub data: alphai::TickerEarnings,
pub fetched: Instant,
}
pub struct CalendarSlot {
pub events: Vec<alphai::CalendarEvent>,
pub fetched: Instant,
pub from: NaiveDate,
pub to: NaiveDate,
}
pub const RANGE_PRESETS: [(Range, Interval); 5] = [
(Range::D1, Interval::M5),
(Range::D5, Interval::M15),
(Range::Mo1, Interval::M60),
(Range::Mo6, Interval::D1),
(Range::Y1, Interval::D1),
];
fn next_preset(
presets: &[(Range, Interval)],
cur: (Range, Interval),
dir: isize,
) -> (Range, Interval) {
let n = presets.len() as isize;
match presets.iter().position(|&p| p == cur) {
Some(i) => presets[((i as isize + dir).rem_euclid(n)) as usize],
None if dir > 0 => presets[0],
None => presets[presets.len() - 1],
}
}
pub struct AppInit {
pub symbols: Vec<String>,
pub positions: Vec<Position>,
pub shared_symbols: SharedSymbols,
pub source: SharedSource,
pub source_name: &'static str,
pub range: Range,
pub interval: Interval,
pub sessions: Sessions,
pub params: SharedParams,
pub every: SharedEvery,
pub rx: UnboundedReceiver<SourceEvent>,
pub refresh: Arc<Notify>,
pub alphai_tx: UnboundedSender<alphai::Cmd>,
pub config: Config,
pub config_path: Option<PathBuf>,
pub theme: Theme,
pub theme_name: &'static str,
pub chart: ChartDefaults,
pub ui: UiDefaults,
pub keymap: Keymap,
pub alphai_enabled: bool,
pub first_run: bool,
pub source_fallback: bool,
}
pub struct App {
pub symbols: Vec<String>,
pub positions: Vec<Position>,
pub data: HashMap<String, TickerData>,
pub errors: HashMap<String, String>,
error_window: HashMap<String, (Range, Interval, Sessions)>,
pub price_flash: HashMap<String, (Instant, bool)>,
pub from_cache: HashMap<String, i64>,
quote_fetched: HashMap<String, i64>,
pub(crate) source_trouble_since: Option<Instant>,
abandoned: Vec<&'static str>,
pub(crate) source_fallback: bool,
fallback_exhausted: bool,
pub notice: Option<(String, Instant)>,
pub selected: usize,
pub view_idx: usize,
pub source_name: &'static str,
pub source_delay: Option<&'static str>,
pub source_extended: bool,
pub range: Range,
pub interval: Interval,
pub sessions: Sessions,
data_window: HashMap<String, (Range, Interval, Sessions)>,
pub last_update: Option<DateTime<Local>>,
pub table_state: TableState,
pub portfolio_selected: usize,
pub portfolio_state: TableState,
pub chart_style: ChartStyle,
pub show_sma: bool,
pub show_rsi: bool,
pub show_volume: bool,
pub show_news_markers: bool,
pub ma_type: MaType,
pub chart: ChartDefaults,
pub feeds: HashMap<String, FeedBundle>,
pub feed_seen: HashMap<String, HashSet<String>>,
pub alphai_errors: HashMap<String, String>,
pub alphai_enabled: bool,
pub news_selected: usize,
pub news_scope: NewsScope,
pub news_layout: NewsLayout,
pub news_min_score: u8,
pub insider_min_score: u8,
pub insider_chart: InsiderChartWindow,
pub insider_card: bool,
pub insider_card_fits: bool,
pub show_rail: bool,
pub bare: bool,
pub earnings: HashMap<String, EarningsSlot>,
pub calendar: Option<CalendarSlot>,
pub calendar_selection: ui::calendar::Selection,
pub calendar_state: TableState,
pub(crate) calendar_refresh_requested: bool,
pub(crate) report_date_asked: Option<Instant>,
pub(crate) report_date_retry: HashSet<String>,
pub(crate) report_dates_paused: Option<String>,
pub(crate) alphai_key_pending: usize,
pub earnings_scroll: u16,
pub alphai_ttl: Duration,
pub card_scroll: u16,
pub news_table_state: TableState,
pub article_overlay: ArticleOverlay,
pub help: HelpOverlay,
pub settings: SettingsState,
pub prompt: Prompt,
pub shared_symbols: SharedSymbols,
pub watchlist_saved: bool,
pub config: Config,
pub config_path: Option<PathBuf>,
pub theme: Theme,
pub theme_name: &'static str,
pub keymap: Keymap,
source: SharedSource,
pub params: SharedParams,
every: SharedEvery,
inflight: HashSet<String>,
alphai_tx: UnboundedSender<alphai::Cmd>,
rx: UnboundedReceiver<SourceEvent>,
refresh: Arc<Notify>,
}
impl App {
pub fn new(init: AppInit) -> Self {
let source_delay = init.source.read().unwrap().delay_note();
let source_extended = init.source.read().unwrap().extended_candles();
let watchlist_saved = watchlist_is_saved(&init.config.watchlist, &init.symbols);
let mut app = Self {
watchlist_saved,
symbols: init.symbols,
shared_symbols: init.shared_symbols,
prompt: Prompt::default(),
data: HashMap::new(),
errors: HashMap::new(),
error_window: HashMap::new(),
price_flash: HashMap::new(),
from_cache: HashMap::new(),
quote_fetched: HashMap::new(),
source_trouble_since: None,
abandoned: Vec::new(),
source_fallback: init.source_fallback,
fallback_exhausted: false,
notice: None,
selected: 0,
view_idx: init.ui.view_idx,
source_name: init.source_name,
source_delay,
source_extended,
range: init.range,
interval: init.interval,
sessions: init.sessions,
data_window: HashMap::new(),
last_update: None,
table_state: TableState::default(),
chart_style: init.chart.style,
show_sma: init.chart.sma,
show_rsi: init.chart.rsi,
show_volume: init.chart.volume,
show_news_markers: init.chart.news_markers,
ma_type: init.chart.ma_type,
chart: init.chart,
feeds: HashMap::new(),
feed_seen: HashMap::new(),
alphai_errors: HashMap::new(),
alphai_enabled: init.alphai_enabled,
news_selected: 0,
news_scope: init.ui.news_scope,
news_layout: init.ui.news_layout,
news_min_score: init.ui.news_min_score,
insider_min_score: init.ui.insider_min_score,
insider_chart: init.ui.insider_chart,
insider_card: true,
insider_card_fits: false,
show_rail: init.ui.quote_rail,
bare: init.ui.bare,
earnings: HashMap::new(),
calendar: None,
calendar_selection: ui::calendar::Selection::default(),
calendar_state: TableState::default(),
calendar_refresh_requested: false,
report_date_asked: None,
report_date_retry: HashSet::new(),
report_dates_paused: None,
alphai_key_pending: 0,
earnings_scroll: 0,
alphai_ttl: init.ui.alphai_ttl,
card_scroll: 0,
news_table_state: TableState::default(),
positions: init.positions,
portfolio_selected: 0,
portfolio_state: TableState::default(),
article_overlay: ArticleOverlay::default(),
help: HelpOverlay::default(),
settings: SettingsState::default(),
config: init.config,
config_path: init.config_path,
theme: init.theme,
theme_name: init.theme_name,
keymap: init.keymap,
source: init.source,
params: init.params,
every: init.every,
inflight: HashSet::new(),
alphai_tx: init.alphai_tx,
rx: init.rx,
refresh: init.refresh,
};
if init.first_run {
app.open_settings();
app.settings.first_run = true;
}
app
}
pub fn selected_symbol(&self) -> &str {
&self.symbols[self.selected]
}
pub fn seed_cached(&mut self, symbol: String, entry: crate::cache::Entry) {
self.quote_fetched.insert(symbol.clone(), entry.fetched);
self.from_cache.insert(symbol.clone(), entry.fetched);
self.data_window.insert(symbol.clone(), self.window());
self.data.insert(symbol, entry.data);
}
fn window(&self) -> (Range, Interval, Sessions) {
(self.range, self.interval, self.sessions)
}
pub fn chart_window(&self, symbol: &str) -> ((Range, Interval, Sessions), bool) {
let current = self.window();
let Some(&drawn) = self.data_window.get(symbol) else {
return (current, false);
};
let pending = drawn.0 != current.0
|| drawn.1 != current.1
|| (drawn.2 != current.2 && sessions_matter(symbol, current.1));
(if pending { drawn } else { current }, pending)
}
pub fn window_failed(&self, symbol: &str) -> bool {
self.error_window.get(symbol) == Some(&self.window())
}
pub fn cached_age(&self, symbol: &str) -> Option<String> {
self.from_cache
.get(symbol)
.map(|at| crate::cache::age_label(*at))
}
pub fn notice(&self) -> Option<&str> {
self.notice
.as_ref()
.filter(|(_, at)| at.elapsed() < NOTICE)
.map(|(text, _)| text.as_str())
}
pub fn price_flash_dir(&self, symbol: &str) -> Option<bool> {
self.price_flash
.get(symbol)
.filter(|(at, _)| at.elapsed() < PRICE_FLASH)
.map(|&(_, up)| up)
}
pub fn view_id(&self) -> ui::ViewId {
ui::VIEWS[self.view_idx].id()
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
loop {
while let Ok(ev) = self.rx.try_recv() {
self.apply(ev);
}
self.fallback_if_stuck();
self.ensure_alphai_data();
terminal.draw(|f| ui::draw(f, self))?;
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& self.handle_key(key)
{
return Ok(());
}
}
}
pub(crate) fn apply(&mut self, event: SourceEvent) {
match event {
SourceEvent::Data {
params,
source,
symbol,
mut data,
} => {
if !self.accepts_price_event(&source, &symbol)
|| params.is_some_and(|p| p != (self.range, self.interval, self.sessions))
{
return;
}
self.errors.remove(&symbol);
self.error_window.remove(&symbol);
self.quote_fetched
.insert(symbol.clone(), chrono::Utc::now().timestamp());
self.source_trouble_since = None;
let was_cached = self.from_cache.remove(&symbol).is_some();
if let Some(old) = self.data.get(&symbol)
&& old.quote.timing.extended.is_some()
&& old.quote.extended_price().is_some()
&& data.quote.extended_price().is_none()
{
data.quote.extended = old.quote.extended;
data.quote.timing.extended = old.quote.timing.extended;
data.quote.timing.extended_feed = old.quote.timing.extended_feed;
data.quote.timing.extended_reference = Some(old.quote.extended_reference());
}
if let Some(old) = self.data.get(&symbol)
&& old.quote.price != data.quote.price
&& !was_cached
{
self.price_flash.insert(
symbol.clone(),
(Instant::now(), data.quote.price > old.quote.price),
);
}
data.update_current_bar(self.interval);
self.data_window
.insert(symbol.clone(), params.unwrap_or(self.window()));
self.data.insert(symbol, data);
self.last_update = Some(Local::now());
}
SourceEvent::Error {
params,
source,
symbol,
error,
} => {
if !self.accepts_price_event(&source, &symbol)
|| params.is_some_and(|p| p != (self.range, self.interval, self.sessions))
{
return;
}
self.error_window.insert(symbol.clone(), self.window());
self.errors.insert(symbol, error);
self.last_update = Some(Local::now());
if self.source_trouble_since.is_none() && self.all_prices_failing() {
self.source_trouble_since = Some(Instant::now());
}
}
SourceEvent::Alphai(ev) => self.apply_alphai(ev),
}
}
fn accepts_price_event(
&self,
source: &Arc<dyn crate::source::DataSource>,
symbol: &str,
) -> bool {
Arc::ptr_eq(source, &self.price_source())
&& self
.shared_symbols
.read()
.unwrap()
.iter()
.any(|s| s == symbol)
}
pub(crate) fn price_source(&self) -> Arc<dyn crate::source::DataSource> {
self.source.read().unwrap().clone()
}
fn all_prices_failing(&self) -> bool {
let symbols = self.shared_symbols.read().unwrap();
!symbols.is_empty() && symbols.iter().all(|s| self.errors.contains_key(s))
}
fn set_price_source(&mut self, source: Arc<dyn crate::source::DataSource>) {
self.source_name = source.name();
self.source_delay = source.delay_note();
self.source_extended = source.extended_candles();
*self.source.write().unwrap() = source;
self.errors.clear();
self.error_window.clear();
self.source_trouble_since = None;
self.price_flash.clear();
self.notice = None;
self.refresh.notify_one();
}
pub(crate) fn fallback_if_stuck(&mut self) {
if !self.source_fallback || self.fallback_exhausted || self.settings.open {
return;
}
if !self.all_prices_failing() {
self.source_trouble_since = None;
return;
}
let Some(since) = self.source_trouble_since else {
return;
};
if since.elapsed() < FALLBACK_AFTER {
return;
}
let Some(src) = self.fallback_source() else {
self.fallback_exhausted = true;
return;
};
let failed = self.source_name;
self.abandoned.push(failed);
self.from_cache.extend(self.quote_fetched.clone());
self.set_price_source(src);
let name = self.source_name;
self.notice = Some((
format!("{failed} stopped answering, switched to {name} (s to choose another)"),
Instant::now(),
));
}
fn fallback_source(&self) -> Option<Arc<dyn crate::source::DataSource>> {
crate::source::registry::SOURCES
.iter()
.filter(|info| {
!info.id.eq_ignore_ascii_case(self.source_name)
&& !self.abandoned.contains(&info.id)
})
.find_map(|info| crate::source::make_source(info.id, &self.config).ok())
}
pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
if self.prompt.open {
return self.handle_prompt_key(key);
}
if self.settings.open {
return self.handle_settings_key(key);
}
if self.article_overlay.open {
return self.handle_overlay_key(key);
}
if self.help.open {
return self.handle_help_key(key);
}
if key.code == KeyCode::Esc {
return true;
}
if let KeyCode::Char(c @ '1'..='9') = key.code {
let idx = c as usize - '1' as usize;
if idx < ui::VIEWS.len() {
self.switch_view(idx);
}
return false;
}
let news_view = ui::VIEWS[self.view_idx].navigates_articles();
let chart_view = ui::VIEWS[self.view_idx].has_chart_panel();
let earnings_view = ui::VIEWS[self.view_idx].shows_earnings();
let calendar_view = ui::VIEWS[self.view_idx].shows_calendar();
let lr_ticker = news_view || earnings_view;
let news_feed = ui::VIEWS[self.view_idx].feed_shown() == Some(FeedKind::News);
let any_feed = ui::VIEWS[self.view_idx].feed_shown().is_some();
let Some(action) = self.keymap.resolve(&key) else {
return false;
};
match action {
Action::Quit => return true,
Action::AddTicker => {
self.prompt = Prompt {
open: true,
..Default::default()
}
}
Action::RemoveTicker => self.remove_selected_symbol(),
Action::Position => self.open_position_prompt(),
Action::NextView => self.switch_view((self.view_idx + 1) % ui::VIEWS.len()),
Action::PrevView => {
self.switch_view((self.view_idx + ui::VIEWS.len() - 1) % ui::VIEWS.len())
}
Action::Settings => self.open_settings(),
Action::Help => {
self.help = HelpOverlay {
open: true,
scroll: 0,
}
}
Action::Refresh => self.manual_refresh(),
Action::Up if calendar_view => self.move_calendar(-1),
Action::Down if calendar_view => self.move_calendar(1),
Action::PageUp if calendar_view => self.move_calendar(-10),
Action::PageDown if calendar_view => self.move_calendar(10),
Action::Open if calendar_view => self.open_calendar_row(),
Action::Up if news_view => {
self.news_selected = self.news_selected.saturating_sub(1);
self.card_scroll = 0;
}
Action::Down if news_view => {
let max = self.visible_articles().map_or(0, <[Article]>::len);
if self.news_selected + 1 < max {
self.news_selected += 1;
self.card_scroll = 0;
} else {
self.request_more_articles();
}
}
Action::Left if lr_ticker => self.select_symbol(self.selected.saturating_sub(1)),
Action::Right if lr_ticker => {
self.select_symbol((self.selected + 1).min(self.symbols.len() - 1))
}
Action::Up if earnings_view => {
self.earnings_scroll = self.earnings_scroll.saturating_sub(1)
}
Action::Down if earnings_view => {
self.earnings_scroll = self.earnings_scroll.saturating_add(1)
}
Action::PageUp if earnings_view => {
self.earnings_scroll = self.earnings_scroll.saturating_sub(10)
}
Action::PageDown if earnings_view => {
self.earnings_scroll = self.earnings_scroll.saturating_add(10)
}
Action::PageUp if news_view => self.card_scroll = self.card_scroll.saturating_sub(10),
Action::PageDown if news_view => self.card_scroll = self.card_scroll.saturating_add(10),
Action::CycleLayout if self.view_id() == ui::ViewId::News => {
self.news_layout = self.news_layout.next();
self.card_scroll = 0;
}
Action::Open if news_view => {
if let Some(a) = self
.visible_articles()
.and_then(|list| list.get(self.news_selected))
{
open_url(&self.article_url(a));
}
}
Action::Open if earnings_view => {
if let Some(url) = ui::earnings::open_url(self) {
open_url(&url);
}
}
Action::Card if self.view_id() == ui::ViewId::Insider && self.insider_card_fits => {
self.insider_card = !self.insider_card;
self.card_scroll = 0;
}
Action::Card
if news_view && self.visible_articles().is_some_and(|list| !list.is_empty()) =>
{
self.article_overlay = ArticleOverlay {
open: true,
..Default::default()
};
}
Action::CycleScope if news_feed => {
self.news_scope = self.news_scope.next();
self.news_selected = 0;
self.card_scroll = 0;
}
Action::InsiderChart if self.view_id() == ui::ViewId::Insider => {
self.insider_chart = self.insider_chart.next();
}
Action::ScoreUp if any_feed => self.adjust_min_score(1),
Action::ScoreDown if any_feed => self.adjust_min_score(-1),
Action::ChartStyle if chart_view => {
self.chart_style = match self.chart_style {
ChartStyle::Candles => ChartStyle::Line,
ChartStyle::Line => ChartStyle::Candles,
}
}
Action::ToggleSma if chart_view => self.show_sma = !self.show_sma,
Action::ToggleRsi if chart_view => self.show_rsi = !self.show_rsi,
Action::ToggleVolume if chart_view => self.show_volume = !self.show_volume,
Action::NewsMarkers if chart_view => self.show_news_markers = !self.show_news_markers,
Action::MaType if chart_view => {
self.ma_type = match self.ma_type {
MaType::Sma => MaType::Ema,
MaType::Ema => MaType::Sma,
}
}
Action::NextPreset => self.cycle_range(1),
Action::PrevPreset => self.cycle_range(-1),
Action::ToggleBare => self.bare = !self.bare,
Action::ToggleExtended => self.toggle_sessions(),
Action::NextTheme => self.cycle_theme(1),
Action::PrevTheme => self.cycle_theme(-1),
Action::Up if self.view_id() == ui::ViewId::Portfolio => self.move_portfolio(-1),
Action::Down if self.view_id() == ui::ViewId::Portfolio => self.move_portfolio(1),
Action::Up => self.selected = self.selected.saturating_sub(1),
Action::Down => self.selected = (self.selected + 1).min(self.symbols.len() - 1),
_ => {}
}
false
}
fn handle_overlay_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Esc {
self.article_overlay = ArticleOverlay::default();
return false;
}
match self.keymap.resolve(&key) {
Some(Action::Quit) => return true,
Some(Action::Card) => self.article_overlay = ArticleOverlay::default(),
Some(Action::Up) => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(1)
}
Some(Action::Down) => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(1)
}
Some(Action::PageUp) => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(10)
}
Some(Action::PageDown) => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(10)
}
Some(Action::Open) => {
if let Some(a) = self
.visible_articles()
.and_then(|list| list.get(self.news_selected))
{
open_url(&self.article_url(a));
}
}
_ => {}
}
false
}
fn handle_prompt_key(&mut self, key: KeyEvent) -> bool {
let cap = match self.prompt.kind {
PromptKind::Ticker => 16,
PromptKind::Position => 32,
};
match key.code {
KeyCode::Esc => self.prompt = Prompt::default(),
KeyCode::Enter => match self.commit_prompt() {
Ok(()) => self.prompt = Prompt::default(),
Err(msg) => self.prompt.error = Some(msg),
},
KeyCode::Backspace => {
self.prompt.input.pop();
self.prompt.error = None;
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.prompt.input.clear();
self.prompt.error = None;
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& self.prompt.input.chars().count() < cap =>
{
self.prompt.input.push(c);
self.prompt.error = None;
}
_ => {}
}
false
}
fn commit_prompt(&mut self) -> Result<(), String> {
let typed = self.prompt.input.clone();
match self.prompt.kind {
PromptKind::Ticker => self.add_symbol(typed.trim().to_uppercase().as_str()),
PromptKind::Position => {
let target = self.prompt.target.clone();
let entry = portfolio::parse_entry(&typed, &target)?;
self.apply_position(entry)
}
}
}
fn open_position_prompt(&mut self) {
let target = self.position_target();
let input = self
.position(&target)
.map(|p| format!("{} {}", p.qty, p.avg_price))
.unwrap_or_default();
self.prompt = Prompt {
open: true,
kind: PromptKind::Position,
input,
target,
error: None,
};
}
fn position_target(&self) -> String {
if self.view_id() == ui::ViewId::Portfolio
&& let Some(p) = self.positions.get(self.portfolio_selected)
{
return p.symbol.clone();
}
self.selected_symbol().to_string()
}
pub fn position(&self, symbol: &str) -> Option<&Position> {
self.positions.iter().find(|p| p.symbol == symbol)
}
fn move_portfolio(&mut self, dir: isize) {
if self.positions.is_empty() {
return;
}
let last = self.positions.len() as isize - 1;
let next = (self.portfolio_selected as isize + dir).clamp(0, last) as usize;
self.portfolio_selected = next;
let symbol = self.positions[next].symbol.clone();
if let Some(at) = self.symbols.iter().position(|s| *s == symbol) {
self.select_symbol(at);
}
}
fn apply_position(&mut self, entry: portfolio::Entry) -> Result<(), String> {
match entry {
portfolio::Entry::Set {
symbol,
qty,
avg_price,
} => {
let next = Position {
symbol,
qty,
avg_price,
};
match self.positions.iter_mut().find(|p| p.symbol == next.symbol) {
Some(slot) => *slot = next,
None => self.positions.push(next),
}
}
portfolio::Entry::Clear { symbol } => self.positions.retain(|p| p.symbol != symbol),
}
self.portfolio_selected = self
.portfolio_selected
.min(self.positions.len().saturating_sub(1));
self.sync_shared_symbols();
self.refresh.notify_one();
self.persist_positions()
}
fn persist_positions(&mut self) -> Result<(), String> {
self.config.positions = self.positions.clone();
if self.config_path.is_none() {
return Ok(());
}
config::save_at(self.config_path.as_deref(), &self.config)
.map_err(|e| format!("kept for this session only: {e}"))
}
fn persist_watchlist(&mut self) {
if !self.watchlist_saved {
self.notice = Some((
"Tickers from the command line are not saved (s, then Save, keeps them)".into(),
Instant::now(),
));
return;
}
self.config.watchlist = self.symbols.clone();
if self.config_path.is_none() {
return;
}
if let Err(e) = config::save_at(self.config_path.as_deref(), &self.config) {
self.notice = Some((
format!("Watchlist kept for this session only: {e}"),
Instant::now(),
));
}
}
fn add_symbol(&mut self, symbol: &str) -> Result<(), String> {
if symbol.is_empty() {
return Err("type a ticker, e.g. AAPL".into());
}
if let Some(at) = self.symbols.iter().position(|s| s == symbol) {
self.select_symbol(at);
return Err(format!("{symbol} is already on the watchlist"));
}
self.symbols.push(symbol.to_string());
self.sync_shared_symbols();
self.select_symbol(self.symbols.len() - 1);
self.refresh.notify_one();
self.persist_watchlist();
Ok(())
}
fn remove_selected_symbol(&mut self) {
if self.symbols.len() < 2 {
return;
}
let gone = self.symbols.remove(self.selected);
self.report_date_retry.remove(&gone);
self.sync_shared_symbols();
self.persist_watchlist();
if self.position(&gone).is_none() {
self.data.remove(&gone);
self.data_window.remove(&gone);
self.errors.remove(&gone);
self.error_window.remove(&gone);
self.price_flash.remove(&gone);
self.from_cache.remove(&gone);
self.quote_fetched.remove(&gone);
}
self.select_symbol(self.selected.min(self.symbols.len() - 1));
}
fn select_symbol(&mut self, idx: usize) {
self.selected = idx;
self.news_selected = 0;
self.card_scroll = 0;
self.earnings_scroll = 0;
}
pub fn polled_symbols(&self) -> Vec<String> {
portfolio::polled_symbols(&self.symbols, &self.positions)
}
fn sync_shared_symbols(&self) {
*self.shared_symbols.write().unwrap() = self.polled_symbols();
}
fn handle_help_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Esc {
self.help = HelpOverlay::default();
return false;
}
match self.keymap.resolve(&key) {
Some(Action::Quit) => return true,
Some(Action::Help) => self.help = HelpOverlay::default(),
Some(Action::Up) => self.help.scroll = self.help.scroll.saturating_sub(1),
Some(Action::Down) => self.help.scroll = self.help.scroll.saturating_add(1),
Some(Action::PageUp) => self.help.scroll = self.help.scroll.saturating_sub(10),
Some(Action::PageDown) => self.help.scroll = self.help.scroll.saturating_add(10),
_ => {}
}
false
}
fn article_url(&self, a: &Article) -> String {
let url = if self.view_id() == ui::ViewId::News && !self.config.news_open_original() {
a.alphai_url().unwrap_or_else(|| a.original.url.clone())
} else {
a.original.url.clone()
};
with_utm(&url)
}
fn cycle_theme(&mut self, dir: isize) {
self.set_theme(crate::theme::step_preset(self.theme_name, dir));
}
pub(crate) fn set_theme(&mut self, name: &'static str) {
let panels = self.theme.panels;
let mut warnings = Vec::new();
let (mut theme, name) =
Theme::resolve(self.config.theme.as_ref(), Some(name), &mut warnings);
theme.panels = panels;
self.theme = theme;
self.theme_name = name;
}
fn switch_view(&mut self, idx: usize) {
if idx != self.view_idx {
self.view_idx = idx;
self.news_selected = 0;
self.card_scroll = 0;
self.earnings_scroll = 0;
if ui::VIEWS[idx].shows_calendar() {
self.calendar_selection = ui::calendar::Selection::default();
self.calendar_state = TableState::default();
}
}
}
fn move_calendar(&mut self, delta: isize) {
let rows = ui::calendar::agenda(self, Utc::now());
let Some(current) = self.calendar_selection.resolve(&rows) else {
return;
};
let next = current.saturating_add_signed(delta).min(rows.len() - 1);
self.calendar_selection.choose(&rows, next);
if let ui::calendar::Kind::Report { symbol } = &rows[next].kind
&& let Some(idx) = self.symbols.iter().position(|s| s == symbol)
{
self.select_symbol(idx);
}
}
fn open_calendar_row(&mut self) {
match ui::calendar::open_target(self, Utc::now()) {
Some(ui::calendar::OpenTarget::Url(url)) => open_url(&url),
Some(ui::calendar::OpenTarget::Earnings(symbol)) => {
if let Some(idx) = self.symbols.iter().position(|s| s == &symbol) {
self.select_symbol(idx);
self.switch_view(ui::view_index(ui::ViewId::Earnings));
}
}
Some(ui::calendar::OpenTarget::Unavailable) => {
self.notice = Some(("No source link for this event".into(), Instant::now()));
}
None => {}
}
}
fn cycle_range(&mut self, dir: isize) {
let (range, interval) = next_preset(&self.chart.presets, (self.range, self.interval), dir);
self.range = range;
self.interval = interval;
self.push_params();
}
pub fn extended_unavailable(&self) -> Option<String> {
let symbol = self.selected_symbol();
if !self.source_extended {
return Some(format!(
"{} has no candle history, so there are no pre or post market candles to draw",
self.source_name
));
}
if self.interval == Interval::D1 {
return Some(
"Daily candles cover the regular session only, pick an intraday preset (t)".into(),
);
}
if crate::market::is_crypto(symbol) {
return Some(format!(
"{symbol} trades around the clock, it has no pre or post market"
));
}
if !crate::market::is_us_equity(symbol) {
return Some(format!(
"Pre and post market candles are drawn for US listings only, not {symbol}"
));
}
None
}
fn toggle_sessions(&mut self) {
let before = self.window();
self.sessions = self.sessions.toggled();
if self.sessions == Sessions::Extended
&& let Some(why) = self.extended_unavailable()
{
self.notice = Some((why, Instant::now()));
}
if self.sessions == Sessions::Regular {
for (symbol, data) in &mut self.data {
let window = self.data_window.entry(symbol.clone()).or_insert(before);
if sessions_matter(symbol, window.1) {
data.candles.retain(|c| {
crate::market::window_at(c.ts)
.is_some_and(|w| w.session == crate::market::Session::Open)
});
}
window.2 = Sessions::Regular;
}
}
self.push_params();
}
fn push_params(&mut self) {
*self.params.write().unwrap() = (self.range, self.interval, self.sessions);
self.refresh.notify_one();
}
}
pub(crate) fn watchlist_is_saved(saved: &[String], symbols: &[String]) -> bool {
saved.is_empty()
|| saved
.iter()
.map(|s| s.to_uppercase())
.eq(symbols.iter().cloned())
}
fn sessions_matter(symbol: &str, interval: Interval) -> bool {
interval != Interval::D1 && crate::market::is_us_equity(symbol)
}
fn with_utm(url: &str) -> String {
if url.contains("utm_source=") {
return url.to_string();
}
let (base, frag) = match url.split_once('#') {
Some((base, frag)) => (base, Some(frag)),
None => (url, None),
};
let sep = if base.contains('?') { '&' } else { '?' };
match frag {
Some(frag) => format!("{base}{sep}utm_source=alphai-tui#{frag}"),
None => format!("{base}{sep}utm_source=alphai-tui"),
}
}
pub fn open_url(url: &str) {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return;
}
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("open");
#[cfg(all(unix, not(target_os = "macos")))]
let mut cmd = std::process::Command::new("xdg-open");
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
};
let _ = cmd
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(test)]
mod tests {
use super::{RANGE_PRESETS, next_preset, with_utm};
use crate::domain::{Interval, Range};
#[test]
fn range_presets_wrap_both_ways() {
let first = RANGE_PRESETS[0];
let last = RANGE_PRESETS[RANGE_PRESETS.len() - 1];
assert_eq!(next_preset(&RANGE_PRESETS, first, 1), RANGE_PRESETS[1]);
assert_eq!(next_preset(&RANGE_PRESETS, last, 1), first);
assert_eq!(next_preset(&RANGE_PRESETS, first, -1), last);
}
#[test]
fn utm_tag_appended_to_plain_and_query_urls() {
assert_eq!(
with_utm("https://alphai.io/news/article/07-10/abc/slug"),
"https://alphai.io/news/article/07-10/abc/slug?utm_source=alphai-tui"
);
assert_eq!(
with_utm("https://example.com/story?id=7"),
"https://example.com/story?id=7&utm_source=alphai-tui"
);
}
#[test]
fn utm_tag_respects_existing_source_and_fragment() {
let tagged = "https://example.com/story?utm_source=newsletter";
assert_eq!(with_utm(tagged), tagged);
assert_eq!(
with_utm("https://example.com/story#section"),
"https://example.com/story?utm_source=alphai-tui#section"
);
}
#[test]
fn range_presets_absorb_unknown_startup_combo() {
let odd = (Range::Mo3, Interval::M5);
assert_eq!(next_preset(&RANGE_PRESETS, odd, 1), RANGE_PRESETS[0]);
assert_eq!(
next_preset(&RANGE_PRESETS, odd, -1),
RANGE_PRESETS[RANGE_PRESETS.len() - 1]
);
}
}