volumeleaders-agent 0.3.0

Agent-oriented CLI for VolumeLeaders data
use serde::Serialize;
use serde_json::{Map, Value};

use crate::common::trade_transforms::transform_trade_dashboard;
use crate::output::values_to_table;

use super::{DashboardArgs, DateRange};

const DASHBOARD_TOP_LEVEL_FIELDS: [&str; 3] = ["ticker", "date_range", "count"];
const DASHBOARD_COMPACT_TRADE_FIELDS: [&str; 9] = [
    "Date",
    "FullTimeString24",
    "Price",
    "Dollars",
    "TradeRank",
    "TradeCount",
    "type",
    "venue",
    "events",
];
const DASHBOARD_COMPACT_CLUSTER_FIELDS: [&str; 7] = [
    "Date",
    "Price",
    "Dollars",
    "TradeCount",
    "TradeClusterRank",
    "window",
    "events",
];
const DASHBOARD_COMPACT_LEVEL_FIELDS: [&str; 4] = ["Price", "Dollars", "Trades", "TradeLevelRank"];
const DASHBOARD_COMPACT_BOMB_FIELDS: [&str; 6] = [
    "Date",
    "Dollars",
    "TradeCount",
    "TradeClusterBombRank",
    "window",
    "events",
];

#[derive(Debug, Serialize)]
pub(super) struct TradeDashboard {
    pub(super) ticker: String,
    pub(super) date_range: DateRange,
    pub(super) count: usize,
    pub(super) trades: Vec<volumeleaders_client::Trade>,
    pub(super) clusters: Vec<volumeleaders_client::TradeCluster>,
    pub(super) levels: Vec<volumeleaders_client::TradeLevel>,
    pub(super) cluster_bombs: Vec<volumeleaders_client::TradeClusterBomb>,
}

#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct DashboardFieldSelection {
    pub(super) unqualified: Vec<String>,
    pub(super) trades: Vec<String>,
    pub(super) clusters: Vec<String>,
    pub(super) levels: Vec<String>,
    pub(super) cluster_bombs: Vec<String>,
}

pub(super) fn dashboard_output_value(
    dashboard: &TradeDashboard,
    args: &DashboardArgs,
) -> Result<Value, String> {
    let mut value = serde_json::to_value(dashboard).unwrap_or(Value::Null);
    let Some(map) = value.as_object_mut() else {
        return Ok(value);
    };

    transform_trade_dashboard(map);

    match args.fields.as_deref().map(str::trim) {
        _ if args.all_fields => Ok(value),
        Some(fields) if fields.eq_ignore_ascii_case("all") => Ok(value),
        Some(fields) if !fields.is_empty() => {
            let selection = parse_dashboard_fields(fields)?;
            apply_selected_dashboard_fields(map, &selection)?;
            Ok(value)
        }
        _ => {
            apply_compact_dashboard_fields(map);
            Ok(value)
        }
    }
}

pub(super) fn parse_dashboard_fields(fields: &str) -> Result<DashboardFieldSelection, String> {
    let mut selection = DashboardFieldSelection::default();
    for field in fields
        .split(',')
        .map(str::trim)
        .filter(|field| !field.is_empty())
    {
        if let Some((section, name)) = field.split_once('.') {
            let name = name.trim();
            if name.is_empty() {
                continue;
            }
            match section.trim().to_ascii_lowercase().as_str() {
                "trades" | "trade" => selection.trades.push(name.to_string()),
                "clusters" | "cluster" => selection.clusters.push(name.to_string()),
                "levels" | "level" => selection.levels.push(name.to_string()),
                "cluster_bombs" | "cluster-bombs" | "bombs" | "bomb" => {
                    selection.cluster_bombs.push(name.to_string());
                }
                _ => {
                    return Err(format!(
                        "unknown dashboard field section `{}` in `{}`; use trades, clusters, levels, or cluster_bombs",
                        section.trim(),
                        field
                    ));
                }
            }
        } else {
            selection.unqualified.push(field.to_string());
        }
    }
    Ok(selection)
}

fn apply_compact_dashboard_fields(map: &mut Map<String, Value>) {
    retain_dashboard_top_level(map);
    filter_dashboard_section(map, "trades", &DASHBOARD_COMPACT_TRADE_FIELDS, true);
    filter_dashboard_section(map, "clusters", &DASHBOARD_COMPACT_CLUSTER_FIELDS, true);
    filter_dashboard_section(map, "levels", &DASHBOARD_COMPACT_LEVEL_FIELDS, true);
    filter_dashboard_section(map, "cluster_bombs", &DASHBOARD_COMPACT_BOMB_FIELDS, true);
}

fn apply_selected_dashboard_fields(
    map: &mut Map<String, Value>,
    selection: &DashboardFieldSelection,
) -> Result<(), String> {
    retain_dashboard_top_level(map);
    apply_selected_dashboard_section(map, "trades", &selection.trades, selection)?;
    apply_selected_dashboard_section(map, "clusters", &selection.clusters, selection)?;
    apply_selected_dashboard_section(map, "levels", &selection.levels, selection)?;
    apply_selected_dashboard_section(map, "cluster_bombs", &selection.cluster_bombs, selection)?;
    Ok(())
}

fn apply_selected_dashboard_section(
    map: &mut Map<String, Value>,
    section: &str,
    section_fields: &[String],
    selection: &DashboardFieldSelection,
) -> Result<(), String> {
    let fields = dashboard_section_fields(section_fields, &selection.unqualified);
    if fields.is_empty() {
        map.remove(section);
        return Ok(());
    }
    let matched = filter_dashboard_section(map, section, &fields, false);
    if matched == 0 && section_has_rows(map, section) {
        return Err(format!(
            "no requested dashboard fields matched `{section}` rows; field names are case-sensitive"
        ));
    }
    Ok(())
}

fn dashboard_section_fields(section_fields: &[String], unqualified: &[String]) -> Vec<String> {
    section_fields
        .iter()
        .chain(unqualified)
        .filter(|field| !field.trim().is_empty())
        .cloned()
        .collect()
}

fn retain_dashboard_top_level(map: &mut Map<String, Value>) {
    map.retain(|key, _| {
        DASHBOARD_TOP_LEVEL_FIELDS.contains(&key.as_str()) || is_dashboard_section(key)
    });
}

fn is_dashboard_section(key: &str) -> bool {
    matches!(key, "trades" | "clusters" | "levels" | "cluster_bombs")
}

fn filter_dashboard_section<F>(
    map: &mut Map<String, Value>,
    section: &str,
    fields: &[F],
    omit_empty: bool,
) -> usize
where
    F: AsRef<str>,
{
    let Some(Value::Array(rows)) = map.get_mut(section) else {
        return 0;
    };
    let mut matched = 0;
    for row in rows {
        let Some(row_map) = row.as_object_mut() else {
            continue;
        };
        row_map.retain(|key, value| {
            let selected = fields.iter().any(|field| field.as_ref() == key);
            if selected {
                matched += 1;
            }
            selected && (!omit_empty || !is_empty_dashboard_value(value))
        });
    }
    matched
}

fn section_has_rows(map: &Map<String, Value>, section: &str) -> bool {
    matches!(map.get(section), Some(Value::Array(rows)) if !rows.is_empty())
}

const DASHBOARD_SECTIONS: [&str; 4] = ["trades", "clusters", "levels", "cluster_bombs"];

/// Convert each array section in a dashboard value from array-of-objects to
/// array-of-arrays (JSON Table format). Top-level metadata fields are kept
/// as-is.
pub(super) fn convert_dashboard_sections_to_table(value: &mut Value) {
    let Some(map) = value.as_object_mut() else {
        return;
    };
    for section in DASHBOARD_SECTIONS {
        let Some(Value::Array(rows)) = map.remove(section) else {
            continue;
        };
        map.insert(section.to_string(), values_to_table(&rows));
    }
}

fn is_empty_dashboard_value(value: &Value) -> bool {
    match value {
        Value::Null => true,
        Value::Bool(false) => true,
        Value::Number(_) => false,
        Value::String(value) => value.is_empty(),
        Value::Array(values) => values.is_empty(),
        Value::Object(values) => values.is_empty(),
        Value::Bool(true) => false,
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::convert_dashboard_sections_to_table;

    #[test]
    fn dashboard_sections_converted_to_table() {
        let mut value = json!({
            "ticker": "AAPL",
            "count": 2,
            "trades": [
                {"Date": "2025-01-01", "Price": 150.0},
                {"Date": "2025-01-02", "Price": 151.0}
            ],
            "clusters": [],
            "levels": [
                {"Price": 149.0, "Dollars": 1000000}
            ]
        });
        convert_dashboard_sections_to_table(&mut value);

        assert_eq!(value["ticker"], "AAPL");
        assert_eq!(value["count"], 2);

        let trades = value["trades"].as_array().unwrap();
        assert_eq!(trades.len(), 3, "header + 2 data rows");
        assert!(trades[0].as_array().unwrap().contains(&json!("Date")));

        let levels = value["levels"].as_array().unwrap();
        assert_eq!(levels.len(), 2, "header + 1 data row");

        let clusters = value["clusters"].as_array().unwrap();
        assert!(clusters.is_empty(), "empty section stays empty");
    }
}