use std::cmp::Reverse;
use std::collections::HashSet;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::alphai::{self, Article, FeedMode, FeedPayload, InsiderTrades, SentimentSummary, Sort};
use crate::ui;
use super::{App, CalendarSlot, EarningsSlot, NewsScope};
const SIDE_REFRESH_FACTOR: u32 = 4;
const EARNINGS_TTL_FACTOR: u32 = 12;
const CALENDAR_TTL_FACTOR: u32 = 72;
pub(crate) const REPORT_DATE_PACE: Duration = Duration::from_secs(4);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeedKind {
News,
Insider,
}
pub struct FeedBundle {
pub articles: Vec<Article>,
pub side: Option<FeedPayload>,
pub next_cursor: Option<String>,
pub delta_cursor: Option<String>,
pub page_error: Option<String>,
pub gated: bool,
pub poll_stopped: bool,
pub min_score: Option<u8>,
pub fetched: Instant,
pub polled: Instant,
}
impl FeedBundle {
pub fn new(
articles: Vec<Article>,
side: Option<FeedPayload>,
next_cursor: Option<String>,
) -> Self {
let now = Instant::now();
Self {
articles,
side,
next_cursor,
delta_cursor: None,
page_error: None,
gated: false,
poll_stopped: false,
min_score: None,
fetched: now,
polled: now,
}
}
pub fn sentiment(&self) -> Option<&SentimentSummary> {
match &self.side {
Some(FeedPayload::Sentiment(s)) => Some(s),
_ => None,
}
}
pub fn insider_trades(&self) -> Option<&InsiderTrades> {
match &self.side {
Some(FeedPayload::Insider(t)) => Some(t.as_ref()),
_ => None,
}
}
}
impl App {
pub fn news_cache_key(&self) -> String {
match self.news_scope {
NewsScope::Ticker => self.selected_symbol().to_string(),
NewsScope::Market => alphai::MARKET_KEY.to_string(),
NewsScope::Trending => alphai::TRENDING_KEY.to_string(),
}
}
pub(crate) fn is_loading(&self, key: &str) -> bool {
self.inflight.contains(key)
}
fn active_feed(&self) -> Option<(String, FeedKind)> {
let kind = ui::VIEWS[self.view_idx].feed_shown()?;
let key = match kind {
FeedKind::News => self.news_cache_key(),
FeedKind::Insider => alphai::insider_key(self.selected_symbol()),
};
Some((key, kind))
}
fn head_cmd(&self, kind: FeedKind) -> alphai::Cmd {
match kind {
FeedKind::News => match self.news_scope {
NewsScope::Ticker => alphai::Cmd::FetchNews {
symbol: Some(self.selected_symbol().to_string()),
cursor: None,
min_relevance: Some(self.news_min_score),
sort: Sort::Published,
},
NewsScope::Market => alphai::Cmd::FetchNews {
symbol: None,
cursor: None,
min_relevance: Some(self.news_min_score),
sort: Sort::Published,
},
NewsScope::Trending => alphai::Cmd::FetchTrending,
},
FeedKind::Insider => alphai::Cmd::FetchInsider {
symbol: self.selected_symbol().to_string(),
cursor: None,
min_relevance: Some(self.insider_min_score),
sort: Sort::Published,
},
}
}
fn page_cmd(&self, kind: FeedKind, cursor: String) -> alphai::Cmd {
match kind {
FeedKind::News => alphai::Cmd::FetchNews {
symbol: (self.news_scope == NewsScope::Ticker)
.then(|| self.selected_symbol().to_string()),
cursor: Some(cursor),
min_relevance: Some(self.news_min_score),
sort: Sort::Published,
},
FeedKind::Insider => alphai::Cmd::FetchInsider {
symbol: self.selected_symbol().to_string(),
cursor: Some(cursor),
min_relevance: Some(self.insider_min_score),
sort: Sort::Published,
},
}
}
fn feed_polls(&self, kind: FeedKind) -> bool {
!(kind == FeedKind::News && self.news_scope == NewsScope::Trending)
}
fn poll_cmd(&self, kind: FeedKind, cursor: Option<String>) -> Option<alphai::Cmd> {
if !self.feed_polls(kind) {
return None;
}
Some(match kind {
FeedKind::News => alphai::Cmd::FetchNews {
symbol: (self.news_scope == NewsScope::Ticker)
.then(|| self.selected_symbol().to_string()),
cursor,
min_relevance: Some(self.news_min_score),
sort: Sort::Ingested,
},
FeedKind::Insider => alphai::Cmd::FetchInsider {
symbol: self.selected_symbol().to_string(),
cursor,
min_relevance: Some(self.insider_min_score),
sort: Sort::Ingested,
},
})
}
pub fn visible_articles(&self) -> Option<&[Article]> {
if !ui::VIEWS[self.view_idx].navigates_articles() {
return None;
}
let (key, _) = self.active_feed()?;
self.feeds.get(&key).map(|b| b.articles.as_slice())
}
pub(crate) fn chart_focus(&self, symbol: &str) -> Option<String> {
if self.view_id() != ui::ViewId::News
|| self.news_layout != crate::app::NewsLayout::Chart
|| self.news_scope != crate::app::NewsScope::Ticker
|| self.selected_symbol() != symbol
{
return None;
}
self.visible_articles()?
.get(self.news_selected)
.map(|a| a.original.uid.clone())
.filter(|uid| !uid.is_empty())
}
pub(crate) fn ticker_articles(&self, symbol: &str) -> &[Article] {
self.feeds
.get(symbol)
.map_or(&[][..], |b| b.articles.as_slice())
}
pub(crate) fn find_earnings_by_uid(&self, uid: &str) -> Option<&alphai::EarningsRead> {
if uid.is_empty() {
return None;
}
self.earnings
.values()
.flat_map(|slot| slot.data.reports.iter())
.find(|read| read.uid == uid)
}
pub(crate) fn apply_alphai(&mut self, event: alphai::Event) {
if matches!(event, alphai::Event::KeyChanged) {
self.alphai_key_pending = self.alphai_key_pending.saturating_sub(1);
return;
}
if self.alphai_key_pending > 0 {
return;
}
match event {
alphai::Event::KeyChanged => unreachable!(),
alphai::Event::Feed {
key,
articles,
side,
next_cursor,
mode,
min_relevance,
} => {
self.inflight.remove(&key);
self.alphai_errors.remove(&key);
let active = ui::VIEWS[self.view_idx]
.navigates_articles()
.then(|| self.active_feed().map(|(k, _)| k))
.flatten();
match self.feeds.get_mut(&key) {
Some(b) if mode == FeedMode::Append => {
b.next_cursor = next_cursor;
b.page_error = None;
self.feed_seen
.entry(key)
.or_default()
.extend(uids(&articles));
append_page(&mut b.articles, articles);
}
Some(b) if mode == FeedMode::Merge => {
let prime = b.delta_cursor.is_none();
b.delta_cursor = next_cursor;
b.page_error = None;
b.polled = Instant::now();
let fresh = merge_arrivals(&mut b.articles, articles, prime);
if active.as_deref() == Some(key.as_str()) {
self.news_selected += fresh.len();
}
if prime {
self.feed_seen.entry(key).or_default().extend(fresh);
}
}
_ if mode == FeedMode::Merge => {}
_ => {
let mut b = FeedBundle::new(articles, side, next_cursor);
b.min_score = min_relevance;
let filter_moved = self
.feeds
.get(&key)
.is_some_and(|old| old.min_score != min_relevance);
let first_sight = !self.feed_seen.contains_key(&key);
let reading = (active.as_deref() == Some(key.as_str()))
.then(|| self.feeds.get(&key))
.flatten()
.and_then(|old| old.articles.get(self.news_selected))
.map(|a| a.original.uid.clone())
.filter(|uid| !uid.is_empty());
if let Some(uid) = reading {
match b.articles.iter().position(|a| a.original.uid == uid) {
Some(i) => self.news_selected = i,
None => self.card_scroll = 0,
}
}
let seen = self.feed_seen.entry(key.clone()).or_default();
if first_sight || filter_moved {
seen.extend(uids(&b.articles));
}
self.feeds.insert(key, b);
}
}
}
alphai::Event::PageError { key, error, gated } => {
self.inflight.remove(&key);
let Some(b) = self.feeds.get_mut(&key) else {
return;
};
b.page_error = Some(error);
if gated {
b.gated = true;
b.next_cursor = None;
}
}
alphai::Event::PollError { key, error } => {
self.inflight.remove(&key);
if let Some(b) = self.feeds.get_mut(&key) {
b.page_error = Some(error);
b.poll_stopped = true;
b.polled = Instant::now();
}
}
alphai::Event::PollReprime { key } => {
self.inflight.remove(&key);
if let Some(b) = self.feeds.get_mut(&key) {
b.delta_cursor = None;
b.polled = Instant::now();
}
}
alphai::Event::Earnings { key, data } => {
self.inflight.remove(&key);
self.alphai_errors.remove(&key);
let symbol = key.strip_prefix("earn:").unwrap_or(&key).to_string();
self.earnings.insert(
symbol,
EarningsSlot {
data: *data,
fetched: Instant::now(),
},
);
}
alphai::Event::Calendar { events, from, to } => {
self.inflight.remove(alphai::CALENDAR_KEY);
self.alphai_errors.remove(alphai::CALENDAR_KEY);
if let (Ok(from), Ok(to)) = (from.parse(), to.parse()) {
self.calendar = Some(CalendarSlot {
events,
fetched: Instant::now(),
from,
to,
});
}
}
alphai::Event::CalendarBlocked { key, error } => {
self.inflight.remove(&key);
self.report_dates_paused = Some(error.clone());
self.alphai_errors.insert(key, error);
}
alphai::Event::Error { key, error } => {
self.inflight.remove(&key);
self.alphai_errors.insert(key, error);
}
}
}
pub(crate) fn ensure_alphai_data(&mut self) {
self.ensure_alphai_data_at(Utc::now(), Instant::now());
}
pub(crate) fn ensure_alphai_data_at(&mut self, now: DateTime<Utc>, clock: Instant) {
if !self.alphai_enabled
|| self.alphai_key_pending > 0
|| self.settings.open
|| self.article_overlay.open
|| self.help.open
|| self.prompt.open
|| self.symbols.is_empty()
{
return;
}
self.ensure_feed_data();
self.ensure_earnings_data();
self.ensure_calendar(now, clock);
self.ensure_report_dates(clock);
}
fn ensure_feed_data(&mut self) {
let Some((key, kind)) = self.active_feed() else {
return;
};
let wanted = match kind {
FeedKind::News if self.news_scope != NewsScope::Trending => Some(self.news_min_score),
FeedKind::News => None,
FeedKind::Insider => Some(self.insider_min_score),
};
if self.inflight.contains(&key) || self.alphai_errors.contains_key(&key) {
return;
}
let at_top = self.news_selected == 0;
let ttl = self.alphai_ttl;
let cmd = match self.feeds.get(&key) {
None => Some(self.head_cmd(kind)),
Some(b) => {
let filter_moved =
matches!((b.min_score, wanted), (Some(have), Some(want)) if have != want);
let head_ttl = if self.feed_polls(kind) {
ttl * SIDE_REFRESH_FACTOR
} else {
ttl
};
if filter_moved || (at_top && b.fetched.elapsed() > head_ttl) {
Some(self.head_cmd(kind))
} else if !b.poll_stopped && b.polled.elapsed() > ttl {
self.poll_cmd(kind, b.delta_cursor.clone())
} else {
None
}
}
};
if let Some(cmd) = cmd {
self.inflight.insert(key);
let _ = self.alphai_tx.send(cmd);
}
}
fn ensure_earnings_data(&mut self) {
if !ui::VIEWS[self.view_idx].shows_earnings() {
return;
}
let ttl = self.alphai_ttl;
let symbol = self.selected_symbol().to_string();
let key = alphai::earnings_key(&symbol);
let busy = self.inflight.iter().any(|k| k.starts_with("earn:"));
let stale = match self.earnings.get(&symbol) {
None => true,
Some(slot) => !slot.data.unknown && slot.fetched.elapsed() > ttl * EARNINGS_TTL_FACTOR,
};
if stale && !busy && !self.alphai_errors.contains_key(&key) {
self.inflight.insert(key);
self.report_date_asked = Some(Instant::now());
let _ = self.alphai_tx.send(alphai::Cmd::FetchEarnings { symbol });
}
}
pub(crate) fn calendar_ttl(&self) -> Duration {
self.alphai_ttl * CALENDAR_TTL_FACTOR
}
pub(crate) fn calendar_symbols(&self) -> impl Iterator<Item = &String> {
self.symbols
.iter()
.filter(|s| !crate::market::is_crypto(s) && !s.contains(['^', '=']))
}
pub(crate) fn report_date_due(&self, symbol: &str, clock: Instant) -> bool {
if self
.alphai_errors
.contains_key(&alphai::earnings_key(symbol))
{
return false;
}
match self.earnings.get(symbol) {
None => true,
Some(slot) => {
!slot.data.unknown
&& (self.report_date_retry.contains(symbol)
|| clock.saturating_duration_since(slot.fetched) >= self.calendar_ttl())
}
}
}
fn ensure_calendar(&mut self, now: DateTime<Utc>, clock: Instant) {
let due = self.calendar_refresh_requested
|| self.calendar.as_ref().is_none_or(|slot| {
clock.saturating_duration_since(slot.fetched) >= self.calendar_ttl()
});
if due
&& !self.inflight.contains(alphai::CALENDAR_KEY)
&& !self.alphai_errors.contains_key(alphai::CALENDAR_KEY)
{
let today = crate::market::et_time(now).date();
self.inflight.insert(alphai::CALENDAR_KEY.to_string());
self.calendar_refresh_requested = false;
let _ = self.alphai_tx.send(alphai::Cmd::FetchCalendar {
from: (today - chrono::Duration::days(alphai::CALENDAR_LOOKBACK_DAYS)).to_string(),
to: (today + chrono::Duration::days(alphai::CALENDAR_DAYS + 1)).to_string(),
});
}
}
fn ensure_report_dates(&mut self, clock: Instant) {
if !ui::VIEWS[self.view_idx].shows_calendar()
|| self.report_dates_paused.is_some()
|| self.inflight.contains(alphai::CALENDAR_KEY)
|| self.inflight.iter().any(|k| k.starts_with("earn:"))
|| self
.report_date_asked
.is_some_and(|at| clock.saturating_duration_since(at) < REPORT_DATE_PACE)
{
return;
}
let candidate = self
.calendar_symbols()
.find(|s| self.report_date_due(s, clock))
.cloned();
if let Some(symbol) = candidate {
self.inflight.insert(alphai::earnings_key(&symbol));
self.report_date_retry.remove(&symbol);
self.report_date_asked = Some(clock);
let _ = self.alphai_tx.send(alphai::Cmd::FetchEarnings { symbol });
}
}
pub(crate) fn change_alphai_key(&mut self, key: Option<String>) {
self.alphai_enabled = key.is_some();
self.alphai_key_pending += 1;
let _ = self.alphai_tx.send(alphai::Cmd::SetKey(key));
self.feeds.clear();
self.feed_seen.clear();
self.earnings.clear();
self.calendar = None;
self.calendar_refresh_requested = false;
self.report_date_retry.clear();
self.report_date_asked = None;
self.report_dates_paused = None;
self.alphai_errors.clear();
self.inflight.clear();
}
pub(super) fn request_more_articles(&mut self) {
if !ui::VIEWS[self.view_idx].navigates_articles() {
return;
}
let Some((key, kind)) = self.active_feed() else {
return;
};
let Some(cursor) = self.feeds.get(&key).and_then(|b| b.next_cursor.clone()) else {
return;
};
if self.inflight.contains(&key) {
return;
}
let cmd = self.page_cmd(kind, cursor);
self.inflight.insert(key);
let _ = self.alphai_tx.send(cmd);
}
pub(super) fn adjust_min_score(&mut self, delta: i8) {
let Some((_, kind)) = self.active_feed() else {
return;
};
let field = match kind {
FeedKind::News => &mut self.news_min_score,
FeedKind::Insider => &mut self.insider_min_score,
};
let new = (*field as i8).saturating_add(delta).clamp(1, 10) as u8;
if new != *field {
*field = new;
self.news_selected = 0;
self.card_scroll = 0;
}
}
pub(super) fn manual_refresh(&mut self) {
self.refresh.notify_one();
if let Some((key, _)) = self.active_feed() {
self.feeds.remove(&key);
self.alphai_errors.remove(&key);
} else if ui::VIEWS[self.view_idx].shows_earnings() {
let symbol = self.selected_symbol().to_string();
if !self
.earnings
.get(&symbol)
.is_some_and(|slot| slot.data.unknown)
{
self.earnings.remove(&symbol);
}
self.alphai_errors.remove(&alphai::earnings_key(&symbol));
} else if ui::VIEWS[self.view_idx].shows_calendar()
&& !self.inflight.contains(alphai::CALENDAR_KEY)
{
self.calendar_refresh_requested = true;
self.alphai_errors.remove(alphai::CALENDAR_KEY);
self.report_dates_paused = None;
for symbol in &self.symbols {
if self
.alphai_errors
.remove(&alphai::earnings_key(symbol))
.is_some()
{
self.report_date_retry.insert(symbol.clone());
}
}
}
}
pub(crate) fn mark_selected_seen(&mut self) {
if !ui::VIEWS[self.view_idx].navigates_articles() {
return;
}
let Some((key, _)) = self.active_feed() else {
return;
};
let Some(uid) = self
.feeds
.get(&key)
.and_then(|b| b.articles.get(self.news_selected))
.map(|a| a.original.uid.clone())
.filter(|u| !u.is_empty())
else {
return;
};
self.feed_seen.entry(key).or_default().insert(uid);
}
pub(crate) fn is_unseen(&self, key: &str, a: &Article) -> bool {
!a.original.uid.is_empty()
&& self
.feed_seen
.get(key)
.is_some_and(|seen| !seen.contains(&a.original.uid))
}
}
fn uids(articles: &[Article]) -> impl Iterator<Item = String> + '_ {
articles
.iter()
.map(|a| a.original.uid.clone())
.filter(|u| !u.is_empty())
}
fn merge_arrivals(articles: &mut Vec<Article>, page: Vec<Article>, prime: bool) -> Vec<String> {
let seen: HashSet<String> = articles
.iter()
.map(|a| a.original.uid.clone())
.filter(|uid| !uid.is_empty())
.collect();
let floor = prime
.then(|| articles.iter().filter_map(|a| a.published()).min())
.flatten();
let mut fresh: Vec<Article> = page
.into_iter()
.filter(|a| !a.original.uid.is_empty() && !seen.contains(&a.original.uid))
.filter(|a| match (floor, a.published()) {
(Some(floor), Some(published)) => published >= floor,
_ => true,
})
.collect();
fresh.sort_by_key(|a| Reverse(a.published().unwrap_or(DateTime::<Utc>::MIN_UTC)));
let uids = fresh.iter().map(|a| a.original.uid.clone()).collect();
articles.splice(0..0, fresh);
uids
}
fn append_page(articles: &mut Vec<Article>, page: Vec<Article>) {
let seen: HashSet<String> = articles
.iter()
.map(|a| a.original.uid.clone())
.filter(|uid| !uid.is_empty())
.collect();
articles.extend(
page.into_iter()
.filter(|a| a.original.uid.is_empty() || !seen.contains(&a.original.uid)),
);
}