Skip to main content

tvscreener/
beautify.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Field-aware cell formatting (Python `tvscreener.beauty` without Jupyter HTML).
5//!
6//! Applies catalog `format` hints: percent coloring, rating labels, millify groups,
7//! recommendation arrows, and ADX / AO / Bollinger computed signals.
8
9use std::collections::{HashMap, HashSet};
10
11use serde_json::Value;
12
13use crate::field::{rating_find, FieldDef};
14use crate::ta::{self, Signal};
15use crate::util::{add_rec, format_recommendation, millify};
16use crate::ScreenerRow;
17
18/// Semantic color for a formatted cell (map to ANSI / Ratatui later).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum CellTone {
21    /// Default / missing / unstyled.
22    #[default]
23    Neutral,
24    /// Positive percent (`TradingView` green).
25    Positive,
26    /// Negative percent (`TradingView` red).
27    Negative,
28    /// Buy / strong-buy recommendation arrow.
29    Buy,
30    /// Sell / strong-sell recommendation arrow.
31    Sell,
32}
33
34impl CellTone {
35    /// TradingView-ish sRGB triple used by Python CSS (`beauty.py`).
36    #[must_use]
37    pub const fn rgb(self) -> (u8, u8, u8) {
38        match self {
39            Self::Neutral => (157, 178, 189),
40            Self::Positive => (0, 169, 127),
41            Self::Negative => (255, 23, 62),
42            Self::Buy => (41, 98, 255),
43            Self::Sell => (255, 74, 104),
44        }
45    }
46}
47
48/// One display cell: plain text plus optional tone.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FormattedCell {
51    /// Rendered text (`"--"` for missing).
52    pub text: String,
53    /// Color hint for terminal / TUI.
54    pub tone: CellTone,
55}
56
57impl FormattedCell {
58    /// Neutral cell with the given text.
59    #[must_use]
60    pub fn neutral(text: impl Into<String>) -> Self {
61        Self {
62            text: text.into(),
63            tone: CellTone::Neutral,
64        }
65    }
66
67    /// Missing-value cell (`"--"`).
68    #[must_use]
69    pub fn missing() -> Self {
70        Self::neutral("--")
71    }
72}
73
74/// Technical `field_name` → raw JSON value for one row (Python uses technical columns).
75#[derive(Debug, Clone, Default)]
76pub struct RowTechMap {
77    map: HashMap<String, Value>,
78}
79
80impl RowTechMap {
81    /// Builds a map from `(technical_name, label)` pairs and a label-keyed row.
82    #[must_use]
83    pub fn from_row(row: &ScreenerRow, columns: &[(String, String)]) -> Self {
84        let mut map = HashMap::with_capacity(columns.len());
85        for (tech, label) in columns {
86            if let Some(value) = row.data.get(label) {
87                map.insert(tech.clone(), value.clone());
88            }
89        }
90        Self { map }
91    }
92
93    /// Inserts a technical → value pair (tests / manual assembly).
94    pub fn insert(&mut self, field_name: impl Into<String>, value: Value) {
95        self.map.insert(field_name.into(), value);
96    }
97
98    /// Looks up a value by technical field name.
99    #[must_use]
100    pub fn get(&self, field_name: &str) -> Option<&Value> {
101        self.map.get(field_name)
102    }
103}
104
105/// Formats one JSON value using a catalog format hint (no row companions).
106///
107/// Unhandled formats (`float`, `text`, `date`, …) keep a plain display (no millify).
108/// For `recommendation` / `computed_recommendation` without companions, returns the
109/// plain value (Python needs the full row for arrows).
110#[must_use]
111pub fn format_cell(value: &Value, format: Option<&str>) -> FormattedCell {
112    match format {
113        Some("bool") => format_bool(value),
114        Some("rating") => format_rating_label(value),
115        Some("round") => format_round(value),
116        Some("percent") => format_percent(value),
117        Some("currency") => format_currency(value),
118        Some("number_group") => format_number_group(value),
119        Some("recommendation" | "computed_recommendation") => {
120            if is_missing(value) {
121                FormattedCell::missing()
122            } else {
123                FormattedCell::neutral(display_plain(value))
124            }
125        }
126        _ => format_plain(value),
127    }
128}
129
130/// Formats a cell using [`FieldDef`] metadata and optional technical companions.
131///
132/// Companion map enables Python parity for `recommendation` (`Rec.{name}`) and for
133/// ADX / AO / Bollinger `computed_recommendation` columns.
134#[must_use]
135pub fn format_cell_for_field(
136    value: &Value,
137    field: &FieldDef,
138    tech: Option<&RowTechMap>,
139) -> FormattedCell {
140    match field.format.as_deref() {
141        Some("recommendation") => format_recommendation_field(value, field, tech),
142        Some("computed_recommendation") => format_computed_recommendation(value, field, tech),
143        other => format_cell(value, other),
144    }
145}
146
147fn format_recommendation_field(
148    value: &Value,
149    field: &FieldDef,
150    tech: Option<&RowTechMap>,
151) -> FormattedCell {
152    if is_missing(value) {
153        return FormattedCell::missing();
154    }
155    let plain = display_plain(value);
156    let Some(map) = tech else {
157        return FormattedCell::neutral(plain);
158    };
159    let rec_key = add_rec(&field.field_name);
160    let Some(rec) = map.get(&rec_key).and_then(as_finite_f64) else {
161        return FormattedCell::neutral(plain);
162    };
163    let letter = format_recommendation(rec);
164    let tone = tone_from_letter(&letter);
165    FormattedCell {
166        text: format!("{plain} {letter}"),
167        tone,
168    }
169}
170
171fn format_computed_recommendation(
172    value: &Value,
173    field: &FieldDef,
174    tech: Option<&RowTechMap>,
175) -> FormattedCell {
176    if is_missing(value) {
177        return FormattedCell::missing();
178    }
179    let plain = display_plain(value);
180    let Some(map) = tech else {
181        return FormattedCell::neutral(plain);
182    };
183    let signal = match field.field_name.as_str() {
184        "ADX" => computed_adx(map),
185        "AO" => computed_ao(map),
186        "BB.lower" => computed_bb_lower(map, value),
187        "BB.upper" => computed_bb_upper(map, value),
188        _ => None,
189    };
190    match signal {
191        Some(signal) => FormattedCell {
192            text: format!("{plain} {}", signal.letter()),
193            tone: tone_from_signal(signal),
194        },
195        None => FormattedCell::neutral(plain),
196    }
197}
198
199fn computed_adx(map: &RowTechMap) -> Option<Signal> {
200    let adx_v = map.get("ADX").and_then(as_finite_f64)?;
201    let dminus = map.get("ADX-DI").and_then(as_finite_f64)?;
202    let dplus = map.get("ADX+DI").and_then(as_finite_f64)?;
203    let dminus_old = map.get("ADX-DI[1]").and_then(as_finite_f64)?;
204    let dplus_old = map.get("ADX+DI[1]").and_then(as_finite_f64)?;
205    Some(ta::adx(adx_v, dminus, dplus, dminus_old, dplus_old))
206}
207
208fn computed_ao(map: &RowTechMap) -> Option<Signal> {
209    let ao_v = map.get("AO").and_then(as_finite_f64)?;
210    let ao1 = map.get("AO[1]").and_then(as_finite_f64)?;
211    let ao2 = map.get("AO[2]").and_then(as_finite_f64)?;
212    Some(ta::ao(ao_v, ao1, ao2))
213}
214
215fn computed_bb_lower(map: &RowTechMap, band: &Value) -> Option<Signal> {
216    let low = as_finite_f64(band)?;
217    let close = map.get("close").and_then(as_finite_f64)?;
218    Some(ta::bb_lower(low, close))
219}
220
221fn computed_bb_upper(map: &RowTechMap, band: &Value) -> Option<Signal> {
222    let up = as_finite_f64(band)?;
223    let close = map.get("close").and_then(as_finite_f64)?;
224    Some(ta::bb_upper(up, close))
225}
226
227fn format_bool(value: &Value) -> FormattedCell {
228    if is_missing(value) {
229        return FormattedCell::missing();
230    }
231    let flag = match value {
232        Value::Bool(b) => *b,
233        Value::String(s) => s == "true",
234        _ => false,
235    };
236    FormattedCell::neutral(flag.to_string())
237}
238
239fn format_rating_label(value: &Value) -> FormattedCell {
240    let Some(n) = as_finite_f64(value) else {
241        return FormattedCell::missing();
242    };
243    FormattedCell::neutral(rating_find(n).label)
244}
245
246fn format_round(value: &Value) -> FormattedCell {
247    let Some(n) = as_finite_f64(value) else {
248        return FormattedCell::missing();
249    };
250    FormattedCell::neutral(format!("{:.2}", round2(n)))
251}
252
253fn format_percent(value: &Value) -> FormattedCell {
254    let Some(n) = as_finite_f64(value) else {
255        return FormattedCell::missing();
256    };
257    let text = format!("{n:.2}%");
258    let tone = if text.starts_with('-') {
259        CellTone::Negative
260    } else {
261        CellTone::Positive
262    };
263    FormattedCell { text, tone }
264}
265
266fn format_currency(value: &Value) -> FormattedCell {
267    let Some(n) = as_finite_f64(value) else {
268        return FormattedCell::missing();
269    };
270    FormattedCell::neutral(millify(round2(n)))
271}
272
273fn format_number_group(value: &Value) -> FormattedCell {
274    // Python fillna(0) then millify.
275    let n = as_finite_f64(value).unwrap_or(0.0);
276    FormattedCell::neutral(millify(n))
277}
278
279fn format_plain(value: &Value) -> FormattedCell {
280    if is_missing(value) {
281        FormattedCell::missing()
282    } else {
283        FormattedCell::neutral(display_plain(value))
284    }
285}
286
287fn display_plain(value: &Value) -> String {
288    match value {
289        Value::Null => "--".into(),
290        Value::String(s) => s.clone(),
291        Value::Bool(b) => b.to_string(),
292        Value::Number(n) => n.to_string(),
293        Value::Array(_) | Value::Object(_) => value.to_string(),
294    }
295}
296
297fn is_missing(value: &Value) -> bool {
298    match value {
299        Value::Null => true,
300        Value::Number(n) => n.as_f64().is_some_and(f64::is_nan),
301        _ => false,
302    }
303}
304
305fn as_finite_f64(value: &Value) -> Option<f64> {
306    match value {
307        Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),
308        Value::String(s) => s.parse::<f64>().ok().filter(|f| f.is_finite()),
309        _ => None,
310    }
311}
312
313fn round2(n: f64) -> f64 {
314    (n * 100.0).round() / 100.0
315}
316
317const fn tone_from_signal(signal: Signal) -> CellTone {
318    match signal {
319        Signal::Buy => CellTone::Buy,
320        Signal::Sell => CellTone::Sell,
321        Signal::Neutral => CellTone::Neutral,
322    }
323}
324
325fn tone_from_letter(letter: &str) -> CellTone {
326    if letter == Signal::Buy.letter() {
327        CellTone::Buy
328    } else if letter == Signal::Sell.letter() {
329        CellTone::Sell
330    } else {
331        CellTone::Neutral
332    }
333}
334
335/// Default max data columns in [`format_rows_table`] (plus the Symbol column).
336pub const DEFAULT_TABLE_MAX_COLUMNS: usize = 12;
337
338/// Options for aligned table rendering.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub struct TableFormatOptions {
341    /// Max field columns (Symbol is always included).
342    pub max_columns: usize,
343    /// When true, apply truecolor ANSI from [`CellTone::rgb`].
344    pub color: bool,
345}
346
347impl Default for TableFormatOptions {
348    fn default() -> Self {
349        Self {
350            max_columns: DEFAULT_TABLE_MAX_COLUMNS,
351            color: false,
352        }
353    }
354}
355
356/// Renders rows as an aligned text table using field-aware [`format_cell_for_field`].
357///
358/// Columns follow `fields` order (skipping candlestick), preferring labels present in
359/// the rows, truncated to [`TableFormatOptions::max_columns`].
360#[must_use]
361pub fn format_rows_table(
362    rows: &[ScreenerRow],
363    fields: &[FieldDef],
364    opts: &TableFormatOptions,
365) -> String {
366    if rows.is_empty() {
367        return "(no rows)".into();
368    }
369    let selected = select_table_fields(rows, fields, opts.max_columns.max(1));
370    let columns = crate::util::get_columns_to_request(fields);
371
372    let mut headers: Vec<String> = Vec::with_capacity(selected.len() + 1);
373    headers.push("Symbol".into());
374    headers.extend(selected.iter().map(|f| f.label.clone()));
375
376    let mut plain_rows: Vec<Vec<String>> = Vec::with_capacity(rows.len());
377    let mut tones: Vec<Vec<CellTone>> = Vec::with_capacity(rows.len());
378
379    for row in rows {
380        let tech = RowTechMap::from_row(row, &columns);
381        let mut plain = Vec::with_capacity(selected.len() + 1);
382        let mut row_tones = Vec::with_capacity(selected.len() + 1);
383        plain.push(row.symbol.clone());
384        row_tones.push(CellTone::Neutral);
385        for field in &selected {
386            let value = row.data.get(&field.label).unwrap_or(&Value::Null);
387            let cell = format_cell_for_field(value, field, Some(&tech));
388            plain.push(cell.text);
389            row_tones.push(cell.tone);
390        }
391        plain_rows.push(plain);
392        tones.push(row_tones);
393    }
394
395    let widths = column_widths(&headers, &plain_rows);
396    let mut out = String::new();
397    out.push_str(&format_table_line(&headers, &widths, None, false));
398    out.push('\n');
399    for (plain, row_tones) in plain_rows.iter().zip(tones.iter()) {
400        out.push_str(&format_table_line(
401            plain,
402            &widths,
403            Some(row_tones),
404            opts.color,
405        ));
406        out.push('\n');
407    }
408    out
409}
410
411/// Picks non-candlestick fields present in `rows`, capped at `max_columns`.
412#[must_use]
413pub(crate) fn select_table_fields<'a>(
414    rows: &[ScreenerRow],
415    fields: &'a [FieldDef],
416    max_columns: usize,
417) -> Vec<&'a FieldDef> {
418    let max_columns = max_columns.max(1);
419    let present: HashSet<&str> = rows
420        .iter()
421        .flat_map(|r| r.data.keys().map(String::as_str))
422        .collect();
423
424    let mut selected: Vec<&FieldDef> = fields
425        .iter()
426        .filter(|f| !f.field_name.starts_with("candlestick"))
427        .filter(|f| present.contains(f.label.as_str()))
428        .take(max_columns)
429        .collect();
430
431    if selected.is_empty() {
432        selected = fields
433            .iter()
434            .filter(|f| !f.field_name.starts_with("candlestick"))
435            .take(max_columns)
436            .collect();
437    }
438    selected
439}
440
441fn column_widths(headers: &[String], rows: &[Vec<String>]) -> Vec<usize> {
442    let mut widths: Vec<usize> = headers.iter().map(String::len).collect();
443    for row in rows {
444        for (i, cell) in row.iter().enumerate() {
445            if let Some(w) = widths.get_mut(i) {
446                *w = (*w).max(cell.len());
447            }
448        }
449    }
450    widths
451}
452
453fn format_table_line(
454    cells: &[String],
455    widths: &[usize],
456    tones: Option<&[CellTone]>,
457    color: bool,
458) -> String {
459    let mut parts = Vec::with_capacity(cells.len());
460    for (i, cell) in cells.iter().enumerate() {
461        let width = widths.get(i).copied().unwrap_or(cell.len());
462        let padded = format!("{cell:<width$}");
463        let tone = tones.and_then(|t| t.get(i)).copied().unwrap_or_default();
464        parts.push(paint_cell(&padded, tone, color));
465    }
466    parts.join("  ")
467}
468
469fn paint_cell(text: &str, tone: CellTone, color: bool) -> String {
470    if !color || matches!(tone, CellTone::Neutral) {
471        return text.to_string();
472    }
473    let (r, g, b) = tone.rgb();
474    format!("\x1b[38;2;{r};{g};{b}m{text}\x1b[0m")
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use serde_json::json;
481
482    fn field(name: &str, format: &str) -> FieldDef {
483        FieldDef {
484            label: name.into(),
485            field_name: name.into(),
486            format: Some(format.into()),
487            interval: false,
488            historical: false,
489        }
490    }
491
492    #[test]
493    fn percent_formats_and_tones() {
494        let up = format_cell(&json!(1.234), Some("percent"));
495        assert_eq!(up.text, "1.23%");
496        assert_eq!(up.tone, CellTone::Positive);
497
498        let down = format_cell(&json!(-2.5), Some("percent"));
499        assert_eq!(down.text, "-2.50%");
500        assert_eq!(down.tone, CellTone::Negative);
501
502        assert_eq!(
503            format_cell(&json!(null), Some("percent")),
504            FormattedCell::missing()
505        );
506    }
507
508    #[test]
509    fn rating_uses_band_labels() {
510        assert_eq!(format_cell(&json!(0.75), Some("rating")).text, "Strong Buy");
511        assert_eq!(format_cell(&json!(0.0), Some("rating")).text, "Neutral");
512        assert_eq!(
513            format_cell(&json!(-0.75), Some("rating")).text,
514            "Strong Sell"
515        );
516    }
517
518    #[test]
519    fn currency_rounds_then_millifies() {
520        assert_eq!(format_cell(&json!(1500.0), Some("currency")).text, "1.500K");
521        assert_eq!(
522            format_cell(&json!(150.256), Some("currency")).text,
523            "150.260"
524        );
525    }
526
527    #[test]
528    fn number_group_null_becomes_zero() {
529        assert_eq!(
530            format_cell(&json!(null), Some("number_group")).text,
531            "0.000"
532        );
533    }
534
535    #[test]
536    fn round_and_bool() {
537        assert_eq!(format_cell(&json!(1.239), Some("round")).text, "1.24");
538        assert_eq!(format_cell(&json!("true"), Some("bool")).text, "true");
539        assert_eq!(format_cell(&json!("false"), Some("bool")).text, "false");
540        assert_eq!(format_cell(&json!(true), Some("bool")).text, "true");
541    }
542
543    #[test]
544    fn float_stays_plain_not_millified() {
545        let cell = format_cell(&json!(1500.5), Some("float"));
546        assert_eq!(cell.text, "1500.5");
547        assert_eq!(cell.tone, CellTone::Neutral);
548    }
549
550    #[test]
551    fn recommendation_appends_rec_companion() {
552        let f = field("RSI", "recommendation");
553        let mut map = RowTechMap::default();
554        map.insert("RSI", json!(55.2));
555        map.insert("Rec.RSI", json!(1.0));
556        let cell = format_cell_for_field(&json!(55.2), &f, Some(&map));
557        assert_eq!(cell.text, "55.2 ↑ B");
558        assert_eq!(cell.tone, CellTone::Buy);
559
560        map.insert("Rec.RSI", json!(-1.0));
561        let cell = format_cell_for_field(&json!(55.2), &f, Some(&map));
562        assert_eq!(cell.text, "55.2 ↓ S");
563        assert_eq!(cell.tone, CellTone::Sell);
564    }
565
566    #[test]
567    fn computed_bb_lower_buy_when_close_below() {
568        let f = field("BB.lower", "computed_recommendation");
569        let mut map = RowTechMap::default();
570        map.insert("BB.lower", json!(100.0));
571        map.insert("close", json!(99.0));
572        let cell = format_cell_for_field(&json!(100.0), &f, Some(&map));
573        assert_eq!(cell.text, "100.0 ↑ B");
574        assert_eq!(cell.tone, CellTone::Buy);
575    }
576
577    #[test]
578    fn computed_adx_buy_on_cross() {
579        let f = field("ADX", "computed_recommendation");
580        let mut map = RowTechMap::default();
581        map.insert("ADX", json!(25.0));
582        map.insert("ADX-DI", json!(10.0));
583        map.insert("ADX+DI", json!(15.0));
584        map.insert("ADX-DI[1]", json!(12.0));
585        map.insert("ADX+DI[1]", json!(8.0));
586        let cell = format_cell_for_field(&json!(25.0), &f, Some(&map));
587        assert_eq!(cell.text, "25.0 ↑ B");
588        assert_eq!(cell.tone, CellTone::Buy);
589    }
590
591    #[test]
592    fn computed_unknown_indicator_stays_plain() {
593        let f = field("RSI", "computed_recommendation");
594        let cell = format_cell_for_field(&json!(55.0), &f, None);
595        assert_eq!(cell.text, "55.0");
596        assert_eq!(cell.tone, CellTone::Neutral);
597    }
598
599    #[test]
600    fn row_tech_map_from_labels() {
601        let mut data = serde_json::Map::new();
602        data.insert("Price".into(), json!(42.0));
603        data.insert("Reco. RSI".into(), json!(1.0));
604        let row = ScreenerRow {
605            symbol: "X".into(),
606            data,
607        };
608        let cols = vec![
609            ("close".into(), "Price".into()),
610            ("Rec.RSI".into(), "Reco. RSI".into()),
611        ];
612        let map = RowTechMap::from_row(&row, &cols);
613        assert_eq!(map.get("close"), Some(&json!(42.0)));
614        assert_eq!(map.get("Rec.RSI"), Some(&json!(1.0)));
615    }
616
617    #[test]
618    fn format_rows_table_aligns_and_beautifies() {
619        let fields = vec![
620            FieldDef {
621                label: "Change %".into(),
622                field_name: "change".into(),
623                format: Some("percent".into()),
624                interval: true,
625                historical: false,
626            },
627            FieldDef {
628                label: "Volume".into(),
629                field_name: "volume".into(),
630                format: Some("number_group".into()),
631                interval: false,
632                historical: false,
633            },
634        ];
635        let mut data = serde_json::Map::new();
636        data.insert("Change %".into(), json!(-1.5));
637        data.insert("Volume".into(), json!(1_500_000.0));
638        let row = ScreenerRow {
639            symbol: "X".into(),
640            data,
641        };
642        let plain = format_rows_table(
643            std::slice::from_ref(&row),
644            &fields,
645            &TableFormatOptions {
646                max_columns: 12,
647                color: false,
648            },
649        );
650        assert!(plain.contains("Symbol"));
651        assert!(plain.contains("Change %"));
652        assert!(plain.contains("-1.50%"));
653        assert!(plain.contains("1.500M"));
654
655        let colored = format_rows_table(
656            &[row],
657            &fields,
658            &TableFormatOptions {
659                max_columns: 12,
660                color: true,
661            },
662        );
663        assert!(colored.contains("\x1b[38;2;255;23;62m"));
664    }
665
666    #[test]
667    fn cell_tone_rgb_matches_python() {
668        assert_eq!(CellTone::Positive.rgb(), (0, 169, 127));
669        assert_eq!(CellTone::Negative.rgb(), (255, 23, 62));
670        assert_eq!(CellTone::Buy.rgb(), (41, 98, 255));
671        assert_eq!(CellTone::Sell.rgb(), (255, 74, 104));
672        assert_eq!(CellTone::Neutral.rgb(), (157, 178, 189));
673    }
674}