Ctrl K
ComunidadGuías…

src/app.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! App state and the event loop. Feed caching with its request-budget
//! guards lives in `feeds`, the settings overlay in `settings`; views under
//! `crate::ui` are stateless renderers over `&mut App`.

mod alerts;
mod feeds;
mod mouse;
pub(crate) mod report_calendar;
mod runtime;
mod settings;
mod ticker_search;

pub use feeds::{FeedBundle, FeedKind};
pub use settings::{SettingsRow, SettingsState, settings_rows};
pub use ticker_search::TickerSearch;

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::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::Style;
use ratatui::widgets::TableState;
use tokio::sync::Notify;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};

use crate::alphai::{self, Article};
use crate::category::{self, Categories};
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::motion;
use crate::poller::{SharedEvery, SharedParams, SharedSource, SharedSymbols, SourceEvent};
use crate::portfolio::{self, Position};
use crate::theme::Theme;
use crate::ui;

pub use crate::motion::PRICE_FLASH;

/// How long every symbol has to be failing before the app looks for another
/// source. Three cycles at the default 15s poll, which is enough to tell a
/// blocked provider from one unlucky round of timeouts, and on a long poll
/// interval it simply means the next cycle.
const FALLBACK_AFTER: Duration = Duration::from_secs(45);

/// How long a footer notice stays up. Long enough to be read on a glance
/// back at the terminal, short enough not to sit on top of the key hints
/// for the rest of the session; the header keeps naming the live source
/// after it fades.
const NOTICE: Duration = Duration::from_secs(60);

/// How the price chart draws history: candlesticks or the classic close line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChartStyle {
    Candles,
    Line,
}

/// News feed scope the f key cycles: the selected ticker, the whole market
/// (story-collapsed), or the 48h trending top 10. Session-only, like the
/// chart options.
#[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,
        }
    }

    /// Label for block titles and head lines.
    pub fn label(self, symbol: &str) -> &str {
        match self {
            Self::Ticker => symbol,
            Self::Market => "market",
            Self::Trending => "trending",
        }
    }
}

/// State of the full-article card overlay (v in the News/Insider views).
#[derive(Default)]
pub struct ArticleOverlay {
    pub open: bool,
    pub scroll: u16,
    /// Set by the view during a frame when it drew the card in place (the
    /// News chart layout), so `ui::draw` skips the fullscreen one.
    pub inline: bool,
}

/// Which line the one-line prompt is taking.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub enum PromptKind {
    /// A ticker for the watchlist (a).
    #[default]
    Ticker,
    /// A holding: quantity and average price, for `Prompt::target` (p).
    Position,
    /// A one-shot price crossing for the focused ticker (A).
    Alert,
}

/// State of the prompt overlay (a, p and A anywhere). Ticker syntax is checked
/// before adding it; whether it exists is still the source's answer.
#[derive(Default)]
pub struct Prompt {
    pub open: bool,
    pub kind: PromptKind,
    pub input: String,
    /// The fixed ticker a position or alert applies to.
    pub target: String,
    /// Set for the cases the prompt itself can rule on, such as a symbol
    /// already on the list or a quantity that is not a number.
    pub error: Option<String>,
}

/// State of the help overlay (? anywhere): the full key table.
#[derive(Default)]
pub struct HelpOverlay {
    pub open: bool,
    pub scroll: u16,
}

/// State of the category picker (C where a news feed is shown). It edits a
/// draft that Enter applies, so walking the list costs nothing and Esc
/// leaves the feed as it was.
#[derive(Default)]
pub struct CategoryPicker {
    pub open: bool,
    /// Row under the cursor, an index into `category::ALL`.
    pub cursor: usize,
    pub draft: Categories,
}

/// Where the News view puts the article card pane relative to the list
/// (x cycles, and so does the settings row). Seeded from
/// `[ui] news_layout`; Save in the settings screen writes it back.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NewsLayout {
    /// List on the left, card on the right.
    Side,
    /// Side, plus the ticker's chart above the list with the selected
    /// article marked on it: the story and the move it went with on one
    /// screen. Ticker scope on a wide terminal only; elsewhere it reads as
    /// Side, since a market-wide story has no one chart to belong to.
    #[default]
    Chart,
    /// List on top, card below.
    Stacked,
}

impl NewsLayout {
    /// Every layout in cycle order: the default first.
    pub const ALL: [Self; 3] = [Self::Chart, Self::Stacked, Self::Side];

    pub fn next(self) -> Self {
        self.step(1)
    }

    /// The neighbour in cycle order, `dir` 1 forward and -1 back, wrapping.
    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]
    }

    /// The name `[ui] news_layout` spells it with.
    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()))
    }
}

/// Window of the Insider view's Form 4 chart panel, cycled by the g key:
/// hidden, trailing 3 months, trailing 12 months. Seeded from
/// `[ui] insider_chart`, then session-only, like the chart options.
#[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,
        }
    }

    /// Calendar days the window covers; whole weeks, so the weekly bars
    /// stay uniform. None = the panel is hidden.
    pub fn days(self) -> Option<u32> {
        match self {
            Self::Off => None,
            Self::M3 => Some(91),
            Self::M12 => Some(364),
        }
    }

    /// Label for the panel title and the config value.
    pub fn label(self) -> &'static str {
        match self {
            Self::Off => "off",
            Self::M3 => "3m",
            Self::M12 => "12m",
        }
    }
}

/// One ticker's earnings reads with the moment they were fetched. Cached far
/// longer than a feed (a read changes once a quarter), so the timestamp lives
/// beside the data rather than in a feed bundle.
pub struct EarningsSlot {
    pub data: alphai::TickerEarnings,
    pub fetched: Instant,
}

/// The last successful macro response and its requested UTC date window.
pub struct CalendarSlot {
    pub events: Vec<alphai::CalendarEvent>,
    pub fetched: Instant,
    pub from: NaiveDate,
    pub to: NaiveDate,
}

/// The default combos the t and T keys cycle through (`[chart] presets`
/// overrides them); wraps at the ends. A startup combo not in the table
/// (e.g. -r 3mo) jumps to the first preset on t and to the last on T.
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>,
    /// Validated `[[positions]]`; empty when the section is absent.
    pub positions: Vec<Position>,
    pub shared_symbols: SharedSymbols,
    pub source: SharedSource,
    pub source_name: &'static str,
    pub range: Range,
    pub interval: Interval,
    /// Whether the chart draws the pre and post market candles too.
    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,
    /// Where Save writes the config back; None disables persisting.
    pub config_path: Option<PathBuf>,
    pub theme: Theme,
    pub theme_name: &'static str,
    pub chart: ChartDefaults,
    pub ui: UiDefaults,
    /// Resolved from `[keybindings]` (defaults when the section is absent).
    pub keymap: Keymap,
    pub alphai_enabled: bool,
    pub first_run: bool,
    /// Why the config file did not load, when it exists but does not: the
    /// session runs on the defaults and says so in the footer.
    pub config_error: Option<String>,
    /// Whether a source that stops answering may be swapped for one that
    /// still does (`source_fallback` in the config).
    pub source_fallback: bool,
}

pub struct App {
    pub symbols: Vec<String>,
    /// What the user holds, in config order. Off-watchlist holdings are
    /// polled too (`polled_symbols`), so every row can be valued.
    pub positions: Vec<Position>,
    pub data: HashMap<String, TickerData>,
    pub alerts: crate::alerts::Alerts,
    pub errors: HashMap<String, String>,
    /// The window each entry of `errors` was asked for, so a failure of the
    /// old window is not read as the new one's until it is actually tried
    /// (see `window_failed`). Kept beside `errors` rather than in it: the
    /// message is what every view reads, and tests set it plainly.
    error_window: HashMap<String, (Range, Interval, Sessions)>,
    /// When a poll changed a symbol's price: (moment, tick was up). Views
    /// pulse the price for `PRICE_FLASH` after it via `price_flash_dir`;
    /// the first data a symbol ever gets sets no flash (nothing changed).
    pub price_flash: HashMap<String, (Instant, bool)>,
    pub animations: bool,
    /// Whether the app takes the mouse (`[ui] mouse`, `M`). The runtime
    /// loop enables and disables the terminal's capture to match.
    pub mouse: bool,
    /// Where the last frame put the things the mouse can act on.
    pub hits: ui::hits::Hits,
    pub synchronized_output: bool,
    price_refreshing: bool,
    animation_epoch: Instant,
    /// Symbols whose rows came off disk or from an abandoned source,
    /// with the unix second they were fetched. The rail
    /// badges them so a blocked source never passes old prices off as live;
    /// the first real poll of a symbol clears its entry.
    pub from_cache: HashMap<String, i64>,
    /// Last successful fetch per symbol, for labelling retained prices
    /// when a fallback changes the source before new quotes arrive.
    quote_fetched: HashMap<String, i64>,
    /// Since when every symbol on the watchlist has been failing. The
    /// fallback is measured from here; any successful poll clears it.
    pub(crate) source_trouble_since: Option<Instant>,
    /// Sources this session has already given up on, so a fallback never
    /// walks back into the one that just failed.
    abandoned: Vec<&'static str>,
    /// Whether an unusable source may be swapped for a working one
    /// (`source_fallback` in the config).
    pub(crate) source_fallback: bool,
    /// Set once there is nothing left to fall back to: without it the app
    /// would rebuild every source on every frame of a long outage. Cleared
    /// whenever the user picks a source themselves.
    fallback_exhausted: bool,
    /// A short-lived line for the footer: what the app did on its own, such
    /// as switching source. Errors have their own place; this is for
    /// actions, and it fades.
    pub notice: Option<(String, Instant)>,
    pub selected: usize,
    pub view_idx: usize,
    pub source_name: &'static str,
    /// How stale the active source's prices are (`DataSource::delay_note`),
    /// re-read whenever the settings screen swaps the source. The quote
    /// rail badges it so a delayed price is never read as a live one.
    pub source_delay: Option<&'static str>,
    /// Whether the source has extended-hours candles at all, cached like
    /// `source_delay` (see `extended_unavailable`).
    pub source_extended: bool,
    /// A test's frozen clock, read through `now`. The app itself always
    /// runs on the wall clock.
    #[cfg(test)]
    pub(crate) frozen_now: Option<DateTime<Utc>>,
    pub range: Range,
    pub interval: Interval,
    /// Whether the chart draws the pre and post market candles too.
    pub sessions: Sessions,
    /// The window each symbol's candles were fetched for. A preset or
    /// session switch changes `range`/`interval`/`sessions` at once, but
    /// the rows only follow when the poller answers, which on a paged
    /// source takes seconds. Until then the chart draws the rows on their
    /// own window (see `chart_window`) rather than relabelling them.
    data_window: HashMap<String, (Range, Interval, Sessions)>,
    pub last_update: Option<DateTime<Local>>,
    pub table_state: TableState,
    /// The portfolio view's own cursor: its rows are the positions, which
    /// are neither the watchlist nor in its order.
    pub portfolio_selected: usize,
    pub portfolio_state: TableState,
    // Chart options: seeded from [chart], then session-only toggles
    pub chart_style: ChartStyle,
    pub show_sma: bool,
    pub show_rsi: bool,
    pub show_volume: bool,
    /// Whether the price chart marks the ticker's news on its candles
    /// (`n`, `[chart] news_markers`). The marks are drawn from the feed
    /// already in the cache, so they never cost a request.
    pub show_news_markers: bool,
    pub ma_type: MaType,
    /// Validated [chart] values: indicator periods and the t/T preset cycle.
    pub chart: ChartDefaults,
    // AlphAI feed state: every fetched feed by cache key (news under the
    // symbol/market/trending keys, insider under `ins:SYM`)
    pub feeds: HashMap<String, FeedBundle>,
    /// Uids the reader has had under the cursor, plus the baseline of each
    /// feed's first fetch, by feed cache key. Rows outside this set are new
    /// since the user last looked and render the unseen marker; resting the
    /// cursor on a row retires it (`mark_selected_seen`). Session-only and
    /// survives bundle replacement, so markers live through TTL refetches
    /// and manual `r`.
    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,
    /// Group ticker and market news by story; changed on Save in settings.
    pub collapse_news: bool,
    /// Requests still in flight when grouping changed. Their replies must
    /// retire before a fresh request can refill the cleared news cache.
    pub(super) discarded_news_requests: HashSet<String>,
    /// Minimum relevance score in the ticker and market news feeds (1..=10,
    /// applied server-side). Seeded from `[ui] news_min_score`, then
    /// session-only via +/-, like the scope and layout.
    pub news_min_score: u8,
    /// Same for the insider feed, where the score tracks the trade size, so
    /// this filters by dollar value. Seeded from `[ui] insider_min_score`;
    /// +/- adjust whichever feed the visible view shows.
    pub insider_min_score: u8,
    /// Which categories the ticker and market news feeds show (applied
    /// server-side, trending has no such filter). Seeded from `[ui]
    /// news_categories` or `news_exclude_categories`, then session-only via
    /// the C picker, like the score.
    pub news_categories: Categories,
    /// Window of the Insider view's chart panel (g cycles off/3m/12m).
    pub insider_chart: InsiderChartWindow,
    /// The Insider view's card column beside the filing list; v toggles it.
    /// Session-only, on at start.
    pub insider_card: bool,
    /// Set by the Insider view each frame: whether the terminal is wide
    /// enough for that column, so v knows whether to toggle it or fall back
    /// to the fullscreen card.
    pub insider_card_fits: bool,
    /// Whether the quote rail is drawn (`[ui] quote_rail`).
    pub show_rail: bool,
    /// Bare mode (`z`, `--bare`, `[ui] bare`): the header and footer give
    /// their rows to the view. For a tmux pane, which carries its own
    /// status bar and needs the rows more than it needs the app's chrome.
    pub bare: bool,
    /// Earnings reads by symbol, each with the moment it was fetched. Its
    /// own map rather than a feed bundle: the payload has no pagination, no
    /// sort and no score filter, and it outlives a feed's TTL by an order of
    /// magnitude (a read changes once a quarter).
    pub earnings: HashMap<String, EarningsSlot>,
    /// The macro calendar window and when it landed. One market-wide payload
    /// for every ticker, so it is not keyed by symbol.
    pub calendar: Option<CalendarSlot>,
    pub calendar_selection: ui::calendar::Selection,
    pub calendar_state: TableState,
    pub(crate) calendar_refresh_requested: bool,
    pub(crate) report_calendar: report_calendar::ReportCalendar,
    pub(crate) earnings_target: Option<report_calendar::EarningsTarget>,
    pub(crate) calendar_filter: ui::calendar::Filter,
    pub(crate) calendar_overlay: Option<ui::calendar::CalendarOverlay>,
    /// SetKey acknowledgements form a barrier against old-key responses.
    pub(crate) alphai_key_pending: usize,
    /// Scroll of the Earnings view's body; reset when the ticker changes.
    pub earnings_scroll: u16,
    /// How long a fetched AlphAI bundle stays fresh. Seeded from
    /// `[ui] alphai_ttl_secs` (default `alphai::CACHE_TTL`), file-only.
    pub alphai_ttl: Duration,
    /// Scroll of the embedded card pane (News view); reset on selection moves.
    pub card_scroll: u16,
    pub news_table_state: TableState,
    pub article_overlay: ArticleOverlay,
    pub help: HelpOverlay,
    pub category_picker: CategoryPicker,
    pub ticker_search: TickerSearch,
    pub settings: SettingsState,
    pub prompt: Prompt,
    /// The watchlist as the price poller sees it; kept in step with
    /// `symbols` by `add_symbol` and `remove_selected_symbol`.
    pub shared_symbols: SharedSymbols,
    /// Whether `a` and `d` write the watchlist to the config. False for a
    /// session started on tickers from the command line while the file
    /// holds a list of its own (see `persist_watchlist`).
    pub watchlist_saved: bool,
    pub config: Config,
    pub config_path: Option<PathBuf>,
    /// `a`, `d` and `p` edits that never reached the file because a write
    /// failed. Every later write, Save in the settings screen included,
    /// replays them over the file as it is then, and only a write that
    /// lands clears them: otherwise a holding typed while the file was
    /// broken went missing at the first save after it was fixed.
    pub pending_edits: Vec<config::Edit>,
    /// The session started on a file that did not load, so `config` holds
    /// the defaults rather than what the file says. Save refuses to write
    /// them over the file once it loads again; a successful Save ends it.
    pub on_defaults: bool,
    pub theme: Theme,
    /// The preset `theme` was built from. The cycle key and the settings
    /// row move this name and rebuild the theme from it, so what is on
    /// screen always matches what the same name would give after a
    /// restart (explicit `[theme]` slots included).
    pub theme_name: &'static str,
    pub keymap: Keymap,
    source: SharedSource,
    /// The fetch window the poller re-reads each cycle. Public like the
    /// shared watchlist: what the keys push into it is the behaviour worth
    /// asserting on, not the field they set on `App`.
    pub params: SharedParams,
    /// The poll interval the poller re-reads before every sleep; the
    /// settings screen edits it live.
    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,
            pending_edits: Vec::new(),
            on_defaults: init.config_error.is_some(),
            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(),
            alerts: Default::default(),
            animations: init.ui.animations,
            mouse: init.ui.mouse,
            hits: ui::hits::Hits::default(),
            synchronized_output: init.ui.synchronized_output,
            price_refreshing: false,
            animation_epoch: Instant::now(),
            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,
            #[cfg(test)]
            frozen_now: None,
            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,
            collapse_news: init.ui.collapse_news,
            discarded_news_requests: HashSet::new(),
            news_min_score: init.ui.news_min_score,
            insider_min_score: init.ui.insider_min_score,
            news_categories: init.ui.news_categories,
            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_calendar: report_calendar::ReportCalendar::default(),
            earnings_target: None,
            calendar_filter: ui::calendar::Filter::default(),
            calendar_overlay: 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(),
            category_picker: CategoryPicker::default(),
            ticker_search: TickerSearch::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;
        }
        // The stderr warning is behind the TUI until it exits, and a reader
        // who sees no keys and no watchlist would think the file was lost.
        if let Some(error) = init.config_error {
            let name = config::file_label(app.config_path.as_deref());
            app.notice = Some((
                format!(
                    "{name} did not load ({error}): running on the defaults, fix it and restart"
                ),
                Instant::now(),
            ));
        }
        app
    }

    pub fn selected_symbol(&self) -> &str {
        self.symbols.get(self.selected).map_or("", String::as_str)
    }

    /// Puts one ticker's last-good rows on screen before the first poll
    /// lands. Marked as cached until that poll replaces them, so the age of
    /// what is on screen is never a guess.
    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);
        // The store only hands out rows saved for the starting window.
        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)
    }

    /// The window to draw `symbol`'s candles on, and whether a different
    /// one is on its way. Rows are drawn on the window they were fetched
    /// for: drawn on the new one, 15m bars read as hours and the axis
    /// extrapolates the wrong step until the poller answers.
    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)
    }

    /// Whether the window on the keys was tried for `symbol` and refused.
    /// An error left over from the previous window says nothing about the
    /// one on its way: after a preset change that symbol is loading again.
    pub fn window_failed(&self, symbol: &str) -> bool {
        self.error_window.get(symbol) == Some(&self.window())
    }

    /// How old the retained rows are, as a label.
    pub fn cached_age(&self, symbol: &str) -> Option<String> {
        self.from_cache
            .get(symbol)
            .map(|at| crate::cache::age_label(*at))
    }

    /// The still-current footer notice, if it has not faded yet.
    pub fn notice(&self) -> Option<&str> {
        self.notice
            .as_ref()
            .filter(|(_, at)| at.elapsed() < NOTICE)
            .map(|(text, _)| text.as_str())
    }

    /// The still-active price pulse of a symbol: Some(tick was up) for
    /// `PRICE_FLASH` after a poll changed its price, then None.
    pub fn price_flash_dir(&self, symbol: &str) -> Option<bool> {
        self.price_flash
            .get(symbol)
            .filter(|(at, _)| self.animations && at.elapsed() < PRICE_FLASH)
            .map(|&(_, up)| up)
    }

    pub fn price_style(&self, symbol: &str, base: Style) -> Style {
        let Some(up) = self.price_flash_dir(symbol) else {
            return base;
        };
        let elapsed = self.price_flash[symbol].0.elapsed();
        motion::pulse(
            base,
            if up { self.theme.up } else { self.theme.down },
            elapsed,
            PRICE_FLASH,
        )
    }

    pub fn refreshing(&self) -> bool {
        self.price_refreshing || !self.inflight.is_empty()
    }

    /// A fixed-width ASCII cell, including when idle or motion is off.
    pub fn refresh_marker(&self) -> char {
        if !self.refreshing() {
            ' '
        } else if self.animations {
            motion::spinner(self.animation_epoch.elapsed())
        } else {
            '*'
        }
    }

    /// Identity of the visible view (its `view_idx` is the tab position).
    pub fn view_id(&self) -> ui::ViewId {
        ui::VIEWS[self.view_idx].id()
    }

    pub(crate) fn apply(&mut self, event: SourceEvent) {
        match event {
            SourceEvent::Refreshing { source, active } => {
                if Arc::ptr_eq(&source, &self.price_source()) {
                    self.price_refreshing = active;
                }
            }
            SourceEvent::Data {
                params,
                source,
                symbol,
                mut data,
            } => {
                if !self.accepts_price_event(&source, &symbol) {
                    return;
                }
                // Quotes do not depend on the requested candle window.
                // Check the incoming print before retaining old extended data.
                self.check_alert(&symbol, &data.quote);
                if 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());
                // A live answer clears the outage clock and retires the
                // cached rows this symbol started on.
                self.source_trouble_since = None;
                let was_cached = self.from_cache.remove(&symbol).is_some();
                // A successful but partial response must not make a known
                // current-session print disappear. Its original timestamp
                // and reference close remain visible; a new session retires it.
                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
                    // The first real price after a cached one is not a tick:
                    // pulsing it would report yesterday's move as just now.
                    && !was_cached
                    && self.animations
                {
                    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());
                // One failing ticker is a bad symbol; all of them is the
                // source, and that is what the fallback waits on.
                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 {
        // Name alone cannot distinguish rebuilt credentials for the same
        // provider. Pending responses retain the exact source instance.
        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))
    }

    /// State shared by manual and automatic source changes. The caller
    /// decides whether to retain quotes and reset the fallback history.
    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.price_refreshing = false;
        self.notice = None;
        self.refresh.notify_one();
    }

    /// Swaps a source that has stopped answering for one that has not.
    ///
    /// Yahoo, the keyless default, blocks by IP for tens of minutes at a
    /// time and no retry can shorten that, so the only real repair is a
    /// different provider. The swap waits for every symbol to be failing
    /// (one bad ticker is a bad ticker) and for `FALLBACK_AFTER` on top of
    /// that, keeps the rows that are on screen, and says what it did. It
    /// never switches back on its own: probing a throttled source is how
    /// the block gets extended, and the header names the source anyway.
    pub(crate) fn fallback_if_stuck(&mut self) {
        if !self.source_fallback || self.fallback_exhausted || self.settings.open {
            return;
        }
        // The polled symbols can change while the grace period runs.
        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 {
            // Nothing else is configured: stop looking until the user picks
            // a source themselves, or this rebuilds a client every frame.
            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(),
        ));
    }

    /// The first registered source that is not the current one, has not
    /// already failed this session, and has the credentials it needs.
    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())
    }

    /// Returns true when the app should quit. Fixed keys resolve first
    /// (Ctrl-C, Esc, the positional 1-9 digits); everything else goes
    /// through the keymap and dispatches on `Action`, with the same view
    /// guards the raw keys used — a remap can never bypass them.
    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.ticker_search.open {
            return self.handle_ticker_search_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.calendar_overlay.is_some() {
            return self.handle_calendar_card_key(key);
        }
        if self.help.open {
            return self.handle_help_key(key);
        }
        if self.category_picker.open {
            return self.handle_category_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();
        // ←→ walk the watchlist wherever the view is scoped to one ticker.
        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::PriceAlert => self.open_alert_prompt(),
            Action::FindTicker => self.open_ticker_search(),
            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::Card if calendar_view => {
                let rows = ui::calendar::agenda(self, self.now());
                self.calendar_overlay =
                    self.calendar_selection
                        .resolve(&rows)
                        .map(|i| ui::calendar::CalendarOverlay {
                            row: rows[i].clone(),
                            scroll: 0,
                        });
            }
            Action::CycleScope if calendar_view => {
                self.calendar_filter = self.calendar_filter.next()
            }
            // News/Insider: up/down scroll articles, left/right switch ticker.
            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 {
                    // Already on the last row: page the feed instead.
                    self.request_more_articles();
                }
            }
            Action::Left if lr_ticker => self.select_symbol(self.selected.saturating_sub(1)),
            Action::Right if lr_ticker && !self.symbols.is_empty() => {
                self.select_symbol((self.selected + 1).min(self.symbols.len() - 1))
            }
            // The earnings body is a document: up/down scroll it, and these
            // arms must stay above the unguarded ones at the end of the
            // match, which move the watchlist selection instead.
            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)
            }
            // Card pane scrolling (the list keeps up/down).
            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));
                }
            }
            // The read's own page on alphai.io, which also links the filing.
            // The payload carries no source URL of its own, so this ignores
            // the "open the original source" setting.
            Action::Open if earnings_view => {
                if let Some(url) = ui::earnings::open_url(self) {
                    open_url(&url);
                }
            }
            // Insider: v shows and hides the card column beside the list,
            // where there is room for one.
            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::Categories if news_feed => {
                self.category_picker = CategoryPicker {
                    open: true,
                    cursor: 0,
                    draft: self.news_categories,
                }
            }
            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::ToggleMouse => self.toggle_mouse(),
            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().saturating_sub(1))
            }
            _ => {}
        }
        false
    }

    /// Keys while the article card overlay is open; it swallows everything so
    /// list navigation does not shift under the reader. Esc always closes.
    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)
            }
            // The render pass clamps the scroll to the card's real height.
            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
    }

    /// Keys while the help overlay is open; like the article card it swallows
    /// everything. Esc or ? closes, q still quits.
    /// Keys while the add-ticker prompt is open. It swallows everything,
    /// the settings pattern, so typing "d" is a letter rather than the
    /// remove action.
    fn handle_prompt_key(&mut self, key: KeyEvent) -> bool {
        // Tickers are short; a position line carries two numbers as well.
        // The caps are there so a stuck key cannot grow the line past its
        // box.
        let cap = match self.prompt.kind {
            PromptKind::Ticker => 16,
            PromptKind::Position | PromptKind::Alert => 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;
            }
            // The readline gesture for "start over". The position prompt
            // opens prefilled with what is held, so clearing the line is a
            // common move rather than an exotic one.
            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.prompt.input.clear();
                self.prompt.error = None;
            }
            // Modified keys are gestures, not text: ctrl-h used to arrive
            // as a plain "h" and land in the middle of a quantity.
            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),
            PromptKind::Alert => self.commit_alert(&typed),
            PromptKind::Position => {
                let target = self.prompt.target.clone();
                let entry = portfolio::parse_entry(&typed, &target)?;
                self.apply_position(entry)
            }
        }
    }

    /// Opens the position prompt on the ticker in front of the reader: the
    /// row under the portfolio cursor in that view, the selected ticker
    /// anywhere else. Prefilled with what is held already, so correcting a
    /// number is not a retype, and an emptied line clears the holding.
    fn open_position_prompt(&mut self) {
        let target = self.focused_symbol();
        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,
        };
    }

    pub(crate) fn focused_symbol(&self) -> String {
        if self.view_id() == ui::ViewId::Earnings {
            return self.earnings_symbol().to_string();
        }
        if self.view_id() == ui::ViewId::Calendar {
            let rows = ui::calendar::agenda(self, self.now());
            if let Some(ui::calendar::Kind::Report { symbol }) = self
                .calendar_selection
                .resolve(&rows)
                .map(|i| &rows[i].kind)
            {
                return symbol.clone();
            }
        }
        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)
    }

    /// Moves the portfolio cursor, and the watchlist cursor with it when
    /// the row's ticker is on the watchlist: the rail sits above every
    /// view and would otherwise quote a different ticker than the row the
    /// cursor is on.
    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);
        }
    }

    /// Writes a position through and saves it. Like the watchlist keys, and
    /// unlike the display options, it does not wait for Save in the
    /// settings screen: a quantity and a price someone typed are their
    /// data, not a display preference, and losing them on quit would read
    /// as a bug.
    fn apply_position(&mut self, entry: portfolio::Entry) -> Result<(), String> {
        let saved = 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.clone(),
                    None => self.positions.push(next.clone()),
                }
                self.save_edit(config::Edit::Hold(next))
            }
            portfolio::Entry::Clear { symbol } => {
                self.positions.retain(|p| p.symbol != symbol);
                self.save_edit(config::Edit::Clear(symbol))
            }
        };
        self.portfolio_selected = self
            .portfolio_selected
            .min(self.positions.len().saturating_sub(1));
        // A holding off the watchlist is polled too, and the nudge saves
        // the new row a full interval of waiting for its first price.
        self.sync_shared_symbols();
        self.refresh.notify_one();
        saved.map_err(|e| format!("kept for this session only: {e}"))
    }

    /// Writes one keypress's change into the config file as it is on disk
    /// now, under the file's lock, leaving every other section alone. Not
    /// the copy this session loaded: two panes on one config each wrote
    /// theirs back, and the later save dropped what the other had added.
    /// A file that does not load is left as it is (`config::update`), and
    /// the edit waits in `pending_edits` for the next write that lands.
    /// The change also lands in `self.config`, which Save in the settings
    /// screen starts from.
    fn save_edit(&mut self, edit: config::Edit) -> Result<(), String> {
        edit.apply(&mut self.config, &self.symbols);
        self.pending_edits.push(edit);
        let Some(path) = self.config_path.clone() else {
            self.pending_edits.clear();
            return Ok(());
        };
        let live = &self.symbols;
        let pending = &self.pending_edits;
        let on_disk = config::update(&path, &self.config, |cfg| {
            for edit in pending {
                edit.apply(cfg, live);
            }
        })
        .map_err(|e| format!("{e:#}"))?;
        self.pending_edits.clear();
        // What another pane wrote is the file's now; Save must not take it
        // back out.
        self.config.watchlist = on_disk.watchlist;
        self.config.positions = on_disk.positions;
        Ok(())
    }

    /// Writes the watchlist through after `a` or `d`, the way a holding is:
    /// a ticker someone added is their data, and losing it on quit read as
    /// a bug. A session started on tickers from the command line is left
    /// alone while the file holds another list, or `alphai-tui TSLA` and
    /// one keypress would replace the saved watchlist with two tickers.
    /// Save in the settings screen still keeps such a list on purpose.
    fn persist_watchlist(&mut self, edit: config::Edit) {
        if !self.watchlist_saved {
            self.notice = Some((
                "Tickers from the command line are not saved (s, then Save, keeps them)".into(),
                Instant::now(),
            ));
            return;
        }
        if let Err(e) = self.save_edit(edit) {
            self.notice = Some((
                format!("Watchlist kept for this session only: {e}"),
                Instant::now(),
            ));
        }
    }

    /// Adds a symbol to the live watchlist, selects it and saves the list.
    /// The poller reads the shared list at the top of its next cycle, and
    /// the nudge makes that cycle start now instead of up to one interval
    /// later.
    fn add_symbol(&mut self, symbol: &str) -> Result<(), String> {
        let symbol = crate::domain::normalize_symbol(symbol)?;
        if let Some(at) = self.symbols.iter().position(|s| s == &symbol) {
            // Not an error worth dwelling on: put the cursor where the
            // ticker already is, and say why nothing was added.
            self.select_symbol(at);
            return Err(format!("{symbol} is already on the watchlist"));
        }
        self.symbols.push(symbol.clone());
        self.sync_shared_symbols();
        self.select_symbol(self.symbols.len() - 1);
        self.refresh.notify_one();
        self.persist_watchlist(config::Edit::Watch(symbol));
        Ok(())
    }

    /// Drops the selected symbol. The last one stays: every view reads
    /// `selected_symbol()` directly, so an empty watchlist needs empty
    /// states before it can be reached.
    fn remove_selected_symbol(&mut self) {
        if self.symbols.len() < 2 {
            return;
        }
        let gone = self.symbols.remove(self.selected);
        self.sync_shared_symbols();
        self.persist_watchlist(config::Edit::Unwatch(gone.clone()));
        // Prices are cheap to fetch again; the AlphAI feeds are not, so
        // their cache survives a removal and a re-add costs no request.
        // A holding keeps its price: it is still polled, and blanking the
        // row would leave the portfolio view showing "…" until the tick.
        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));
    }

    /// Moves the watchlist cursor. Everything scoped to the selected
    /// ticker starts over, or the new ticker's feed would open at the old
    /// one's scroll position.
    fn select_symbol(&mut self, idx: usize) {
        self.earnings_target = None;
        self.selected = idx;
        self.news_selected = 0;
        self.card_scroll = 0;
        self.earnings_scroll = 0;
    }

    /// Every symbol the poller fetches: the watchlist plus anything held
    /// that is not on it. A holding with no price is a row that cannot be
    /// valued, so it is worth the request; the budget warning in `main`
    /// counts this list for the same reason.
    pub fn polled_symbols(&self) -> Vec<String> {
        portfolio::polled_symbols(&self.symbols, &self.positions)
    }

    fn sync_shared_symbols(&mut self) {
        let symbols = self.polled_symbols();
        self.alerts.retain_symbols(&symbols);
        *self.shared_symbols.write().unwrap() = 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),
            // The render pass clamps the scroll to the table's real height.
            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
    }

    /// Keys while the category picker is open; it swallows everything, like
    /// the help overlay. The picker's own keys are fixed (space, o, a,
    /// Enter); moving and quitting follow the keymap, and the key that
    /// opened the picker applies it, so pressing it twice reads as a toggle.
    fn handle_category_key(&mut self, key: KeyEvent) -> bool {
        let plain = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
        let picker = &mut self.category_picker;
        match key.code {
            KeyCode::Esc => {
                self.category_picker = CategoryPicker::default();
                return false;
            }
            KeyCode::Enter => {
                self.apply_categories();
                return false;
            }
            KeyCode::Char(' ') if plain => {
                picker.draft.toggle(picker.cursor);
                return false;
            }
            KeyCode::Char('o') if plain => {
                picker.draft = Categories::only([picker.cursor]);
                return false;
            }
            KeyCode::Char('a') if plain => {
                picker.draft = Categories::default();
                return false;
            }
            _ => {}
        }
        match self.keymap.resolve(&key) {
            Some(Action::Quit) => return true,
            Some(Action::Categories) => self.apply_categories(),
            Some(Action::Up) => picker.cursor = picker.cursor.saturating_sub(1),
            Some(Action::Down) => picker.cursor = (picker.cursor + 1).min(category::ALL.len() - 1),
            _ => {}
        }
        false
    }

    /// URL Enter opens for an article. News items default to their alphai.io
    /// article page (settings can flip this to the original source); insider
    /// filings always open the original, which points at the SEC filing.
    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)
    }

    /// p / P: walk the preset list forward or back.
    fn cycle_theme(&mut self, dir: isize) {
        self.set_theme(crate::theme::step_preset(self.theme_name, dir));
    }

    /// Switch to a named preset (the p key, the settings row). Rebuilt
    /// through the same path the config takes, so the `[theme]` slots the
    /// user spelled out keep overriding the palette; the frame style comes
    /// from `[ui] borders` and does not belong to the preset. Session-only
    /// until Save writes the name into the config.
    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;
    }

    /// The time the Calendar reads, for its rows, its cursor and what
    /// Enter opens alike. A test that pins its agenda to a date freezes
    /// this too; otherwise the keys would act on today's agenda instead of
    /// the one the test built.
    pub(crate) fn now(&self) -> DateTime<Utc> {
        #[cfg(test)]
        if let Some(now) = self.frozen_now {
            return now;
        }
        Utc::now()
    }

    fn switch_view(&mut self, idx: usize) {
        self.earnings_target = None;
        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, self.now());
        let Some(current) = self.calendar_selection.resolve(&rows) else {
            return;
        };
        let next = current.saturating_add_signed(delta).min(rows.len() - 1);
        self.choose_calendar_row(&rows, next);
    }

    /// Puts the calendar cursor on an agenda row, and the watchlist cursor
    /// on its company when it is a report date: the rail above the view
    /// would otherwise quote a different ticker than the row.
    pub(crate) fn choose_calendar_row(&mut self, rows: &[ui::calendar::AgendaRow], next: usize) {
        self.calendar_selection.choose(rows, next);
        if let Some(ui::calendar::Kind::Report { symbol }) = rows.get(next).map(|r| &r.kind)
            && let Some(idx) = self.symbols.iter().position(|s| s == symbol)
        {
            self.select_symbol(idx);
        }
    }

    fn open_calendar_row(&mut self) {
        let rows = ui::calendar::agenda(self, self.now());
        if let Some(row) = self
            .calendar_selection
            .resolve(&rows)
            .map(|i| rows[i].clone())
        {
            self.open_calendar_event(&row);
        }
    }

    fn open_calendar_event(&mut self, row: &ui::calendar::AgendaRow) {
        match ui::calendar::row_target(row) {
            ui::calendar::OpenTarget::Url(url) => open_url(&url),
            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));
                self.earnings_scroll = 0;
                self.earnings_target =
                    row.report
                        .clone()
                        .map(|event| report_calendar::EarningsTarget {
                            pending_read: self
                                .earnings
                                .get(&symbol)
                                .and_then(|s| s.data.latest())
                                .cloned()
                                .map(Box::new),
                            symbol,
                            event,
                            phase: report_calendar::TargetPhase::Owner,
                            refresh_pending: false,
                            no_longer_listed: false,
                        });
            }
            ui::calendar::OpenTarget::Unavailable => {
                self.notice = Some(("No source link for this event".into(), Instant::now()));
            }
        }
    }

    fn handle_calendar_card_key(&mut self, key: KeyEvent) -> bool {
        let action = self.keymap.resolve(&key);
        if key.code == KeyCode::Esc || action == Some(Action::Card) {
            self.calendar_overlay = None;
        } else if action == Some(Action::Quit) {
            return true;
        } else if action == Some(Action::Open) {
            let card = self.calendar_overlay.take().unwrap();
            self.open_calendar_event(&card.row);
        } else if let Some(card) = &mut self.calendar_overlay {
            match action {
                Some(Action::Up) => card.scroll = card.scroll.saturating_sub(1),
                Some(Action::Down) => card.scroll = card.scroll.saturating_add(1),
                Some(Action::PageUp) => card.scroll = card.scroll.saturating_sub(10),
                Some(Action::PageDown) => card.scroll = card.scroll.saturating_add(10),
                _ => {}
            }
        }
        false
    }

    /// t / T: jump to the next/previous range/interval preset and wake the
    /// price poller. Only `refresh.notify_one()` here: `manual_refresh()`
    /// would also drop the visible AlphAI bundle and burn a request from
    /// its budget for what is purely a price-history change.
    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();
    }

    /// Why `E` cannot change the chart in front of the reader, if it
    /// cannot. The switch still flips (it is one setting for the session,
    /// and the next ticker or preset may use it), but a key that does
    /// nothing visible has to say why.
    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
    }

    /// E: draw the pre and post market candles, or stop. Same shape as the
    /// preset cycle, and the same reason for only nudging the price
    /// poller: nothing about the AlphAI feeds changes.
    ///
    /// Switching off needs no fetch to look right: dropping the extended
    /// candles is all a regular-hours answer would change, so the rows are
    /// trimmed and retagged here and the chart follows the key at once.
    /// The trim goes by the interval the rows were fetched at, which is not
    /// the current one while a preset change is still pending.
    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();
    }
}

/// Whether the watchlist a session starts on is the one the file keeps: it
/// is, unless tickers on the command line replaced a saved list. With no
/// saved list there is nothing to overwrite, and the first `a` saves the
/// list on screen.
pub(crate) fn watchlist_is_saved(saved: &[String], symbols: &[String]) -> bool {
    saved.is_empty()
        || saved
            .iter()
            .map(|s| s.trim().to_uppercase())
            .eq(symbols.iter().cloned())
}

/// Whether a ticker's candles differ between regular and extended hours.
/// Only a US listing's intraday bars do: crypto trades around the clock,
/// and a daily bar is the regular session either way (the same test
/// `source::normalize_sessions` makes).
fn sessions_matter(symbol: &str, interval: Interval) -> bool {
    interval != Interval::D1 && crate::market::is_us_equity(symbol)
}

/// Tag an outgoing article link with this client as the traffic source, so
/// alphai.io and original publishers can attribute the referral. Left as-is
/// when the URL already carries a utm_source (never clobber the feed's own
/// attribution); the fragment, if any, stays at the end where it belongs.
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"),
    }
}

/// Open a URL with the platform handler; failures are ignored (worst case the
/// article just does not open — never crash the TUI over it). Tests that press
/// an open key must not launch the real browser.
pub fn open_url(url: &str) {
    if cfg!(test) || !(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() {
        // A feed URL that already attributes its source is left untouched.
        let tagged = "https://example.com/story?utm_source=newsletter";
        assert_eq!(with_utm(tagged), tagged);
        // The fragment stays terminal, the query lands before it.
        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() {
        // A CLI combo outside the table joins the cycle at the nearest edge.
        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]
        );
    }
}