use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::alphai::Article;
use crate::domain::{Candle, Interval};
use crate::theme::Theme;
use crate::ui::{ellipsize, news};
const HEADLINE_MIN_WIDTH: u16 = 46;
const TITLE_MIN_WIDTH: usize = 18;
pub(crate) struct Mark<'a> {
pub col: usize,
pub article: &'a Article,
pub focused: bool,
}
pub(crate) fn place<'a>(
articles: &'a [Article],
candles: &[Candle],
interval: Interval,
focus: Option<&str>,
) -> Vec<Mark<'a>> {
let Some(first) = candles.first() else {
return Vec::new();
};
let mut best: Vec<Option<(bool, i64, i64, &Article)>> = vec![None; candles.len()];
for a in articles {
let Some(ts) = a.published().map(|t| t.timestamp()) else {
continue;
};
if ts < first.ts {
continue;
}
let col = candles.partition_point(|c| c.ts <= ts) - 1;
if ts >= candles[col].ts.saturating_add(interval.secs()) {
continue;
}
let focused = focus.is_some_and(|uid| !uid.is_empty() && a.original.uid == uid);
let cand = (focused, a.score(), ts, a);
if best[col].is_none_or(|(f, score, at, _)| (f, score, at) < (cand.0, cand.1, cand.2)) {
best[col] = Some(cand);
}
}
best.into_iter()
.enumerate()
.filter_map(|(col, slot)| {
slot.map(|(focused, _, _, article)| Mark {
col,
article,
focused,
})
})
.collect()
}
pub(crate) fn latest<'a, 'm>(marks: &'m [Mark<'a>]) -> Option<&'m Mark<'a>> {
marks
.iter()
.max_by_key(|m| m.article.published().map(|t| t.timestamp()).unwrap_or(0))
}
fn shape(a: &Article, symbol: &str) -> char {
match news::display_impact(a, symbol).and_then(|i| i.sentiment.as_deref()) {
Some("positive") => '▲',
Some("negative") => '▼',
_ => '◆',
}
}
pub(crate) fn style(a: &Article, theme: &Theme) -> Style {
let style = Style::new().fg(theme.accent);
match a.score() {
8..=10 => style.add_modifier(Modifier::BOLD),
..=5 => style.add_modifier(Modifier::DIM),
_ => style,
}
}
pub(crate) fn glyph(a: &Article, symbol: &str, theme: &Theme) -> (char, Style) {
(shape(a, symbol), style(a, theme))
}
pub(crate) fn headline(
mark: &Mark,
symbol: &str,
width: u16,
theme: &Theme,
) -> Option<Line<'static>> {
if width < HEADLINE_MIN_WIDTH {
return None;
}
let a = mark.article;
let (ch, style) = glyph(a, symbol, theme);
let age = a.age(chrono::Utc::now());
let prefix = if age.is_empty() {
" ".to_string()
} else {
format!(" {age} · ")
};
let room = (width as usize).saturating_sub(prefix.chars().count() + 5);
if room < TITLE_MIN_WIDTH {
return None;
}
Some(Line::from(vec![
Span::styled(format!(" {ch}"), style),
Span::styled(prefix, theme.subtle()),
Span::styled(ellipsize(&a.original.title, room), theme.subtle()),
Span::raw(" "),
]))
}
#[cfg(test)]
mod tests {
use super::*;
fn candles(start: i64, step: i64, n: usize) -> Vec<Candle> {
(0..n as i64)
.map(|i| Candle {
feed: Default::default(),
ts: start + i * step,
open: 100.0,
high: 101.0,
low: 99.0,
close: 100.5,
volume: None,
})
.collect()
}
fn row(ts: i64, score: i64, sentiment: &str) -> Article {
let published = chrono::DateTime::from_timestamp(ts, 0)
.unwrap()
.to_rfc3339();
serde_json::from_str(&format!(
r#"{{
"original": {{"title": "Headline at {ts}", "uid": "u{ts}",
"time_published": "{published}"}},
"enrichment": {{"tickers": ["AAPL"], "relevance_score": {score},
"ai_trading_insights": {{"ticker_analysis": [
{{"ticker": "AAPL", "impact_analysis": {{"sentiment": "{sentiment}"}}}}
]}}}}
}}"#
))
.unwrap()
}
#[test]
fn rows_land_on_the_candle_they_were_published_in() {
let candles = candles(1_000, 300, 5); let articles = vec![
row(1_450, 7, "positive"), row(2_300, 7, "negative"), row(1_000, 7, "neutral"), ];
let marks = place(&articles, &candles, Interval::M5, None);
let cols: Vec<usize> = marks.iter().map(|m| m.col).collect();
assert_eq!(cols, vec![0, 1, 4]);
}
#[test]
fn history_older_than_the_window_is_dropped_not_clamped() {
let candles = candles(1_000, 300, 3);
let old = [row(900, 9, "positive")];
let marks = place(&old, &candles, Interval::M5, None);
assert!(marks.is_empty(), "an older row must not stick to the edge");
}
#[test]
fn news_after_the_last_candle_is_not_pinned_to_old_history() {
let candles = candles(1_000, 300, 3);
let articles = vec![row(1_899, 6, "positive"), row(1_900, 9, "negative")];
let marks = place(&articles, &candles, Interval::M5, None);
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].article.score(), 6);
}
#[test]
fn news_in_a_session_gap_is_not_assigned_to_the_previous_session() {
let mut candles = candles(1_000, 300, 2);
candles.extend(self::candles(90_000, 300, 2));
let articles = vec![row(2_000, 9, "positive")];
assert!(place(&articles, &candles, Interval::M5, None).is_empty());
}
#[test]
fn a_crowded_candle_keeps_its_biggest_story() {
let candles = candles(1_000, 300, 2);
let articles = vec![
row(1_050, 6, "positive"),
row(1_100, 9, "negative"), row(1_200, 6, "neutral"),
];
let marks = place(&articles, &candles, Interval::M5, None);
assert_eq!(marks.len(), 1, "one mark per column");
assert_eq!(marks[0].article.score(), 9);
}
#[test]
fn the_shape_is_the_sentiment_and_the_weight_is_the_score() {
let theme = Theme::default();
assert_eq!(glyph(&row(1, 9, "positive"), "AAPL", &theme).0, '▲');
assert_eq!(glyph(&row(1, 9, "negative"), "AAPL", &theme).0, '▼');
assert_eq!(glyph(&row(1, 9, "neutral"), "AAPL", &theme).0, '◆');
let (_, loud) = glyph(&row(1, 9, "positive"), "AAPL", &theme);
let (_, quiet) = glyph(&row(1, 3, "negative"), "AAPL", &theme);
assert!(loud.add_modifier.contains(Modifier::BOLD));
assert!(quiet.add_modifier.contains(Modifier::DIM));
assert_eq!(loud.fg, Some(theme.accent));
assert_eq!(quiet.fg, Some(theme.accent));
}
#[test]
fn the_headline_yields_on_a_narrow_chart() {
let candles = candles(1_000, 300, 2);
let articles = vec![row(1_050, 9, "positive")];
let marks = place(&articles, &candles, Interval::M5, None);
let mark = latest(&marks).unwrap();
let theme = Theme::default();
assert!(headline(mark, "AAPL", 40, &theme).is_none());
assert!(headline(mark, "AAPL", 80, &theme).is_some());
}
}