1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum CellTone {
21 #[default]
23 Neutral,
24 Positive,
26 Negative,
28 Buy,
30 Sell,
32}
33
34impl CellTone {
35 #[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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FormattedCell {
51 pub text: String,
53 pub tone: CellTone,
55}
56
57impl FormattedCell {
58 #[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 #[must_use]
69 pub fn missing() -> Self {
70 Self::neutral("--")
71 }
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct RowTechMap {
77 map: HashMap<String, Value>,
78}
79
80impl RowTechMap {
81 #[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 pub fn insert(&mut self, field_name: impl Into<String>, value: Value) {
95 self.map.insert(field_name.into(), value);
96 }
97
98 #[must_use]
100 pub fn get(&self, field_name: &str) -> Option<&Value> {
101 self.map.get(field_name)
102 }
103}
104
105#[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#[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 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
335pub const DEFAULT_TABLE_MAX_COLUMNS: usize = 12;
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub struct TableFormatOptions {
341 pub max_columns: usize,
343 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#[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#[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}