volumeleaders-agent 0.2.2

Agent-oriented CLI for VolumeLeaders data
use std::collections::HashMap;

use rust_decimal::prelude::ToPrimitive;
use serde::Serialize;

use super::{DateRange, trade_day};

const BULL_TICKERS: &[&str] = &[
    "AAPU", "AMDL", "BITU", "BOIL", "BRZU", "CURE", "CWEB", "DFEN", "DIG", "DPST", "DRN", "EDC",
    "ERX", "FAS", "FNGU", "GUSH", "HIBL", "LABU", "MIDU", "NAIL", "NVDL", "QLD", "ROM", "SOXL",
    "SPXL", "SSO", "TECL", "TMF", "TNA", "TQQQ", "TSLL", "TURB", "UDOW", "UMDD", "UPRO", "URTY",
    "USD", "UWM", "WEBL", "YINN",
];
const BEAR_TICKERS: &[&str] = &[
    "AAPD", "AMDD", "BERZ", "BITI", "BNKD", "BZQ", "DUST", "EDZ", "ERY", "FAZ", "HIBS", "KOLD",
    "LABD", "MEXZ", "MYY", "NVDD", "QID", "REK", "REW", "RXD", "SARK", "SCO", "SDD", "SDOW", "SDS",
    "SEF", "SH", "SMDD", "SOXS", "SPDN", "SPXU", "SPXS", "SQQQ", "SRS", "SSG", "SVIX", "TSDD",
    "TSLQ", "TSLS", "TZA", "UVIX", "WEBS", "YANG", "YCS", "ZSL",
];

#[derive(Debug, Serialize)]
pub(super) struct TradeSentiment {
    date_range: DateRange,
    daily: Vec<TradeSentimentDay>,
    totals: TradeSentimentTotals,
}

#[derive(Debug, Serialize)]
struct TradeSentimentDay {
    date: String,
    bear: TradeSentimentSide,
    bull: TradeSentimentSide,
    ratio: Option<f64>,
    signal: TradeSentimentSignal,
}

#[derive(Debug, Serialize)]
struct TradeSentimentTotals {
    bear: TradeSentimentSide,
    bull: TradeSentimentSide,
    ratio: Option<f64>,
    signal: TradeSentimentSignal,
}

#[derive(Clone, Debug, Default, Serialize)]
struct TradeSentimentSide {
    trades: usize,
    dollars: f64,
    top_tickers: Vec<String>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub(super) enum TradeSentimentSignal {
    ExtremeBear,
    ModerateBear,
    Neutral,
    ModerateBull,
    ExtremeBull,
}

#[derive(Default)]
struct SentimentAccumulator {
    trades: usize,
    dollars: f64,
    ticker_dollars: HashMap<String, f64>,
}

#[derive(Default)]
struct SentimentDayAccumulator {
    bear: SentimentAccumulator,
    bull: SentimentAccumulator,
}

pub(super) fn summarize_trade_sentiment(
    trades: &[volumeleaders_client::Trade],
    start: &str,
    end: &str,
) -> TradeSentiment {
    let mut days = HashMap::<String, SentimentDayAccumulator>::new();
    let mut totals = SentimentDayAccumulator::default();
    for trade in trades {
        let Some(side) = classify_trade_sentiment_side(trade) else {
            continue;
        };
        let day = trade_day(trade);
        if day == "unknown" {
            continue;
        }
        days.entry(day).or_default().add(side, trade);
        totals.add(side, trade);
    }
    let mut day_keys: Vec<String> = days.keys().cloned().collect();
    day_keys.sort();
    let daily = day_keys
        .into_iter()
        .filter_map(|day| days.remove(&day).map(|acc| acc.summary(day)))
        .collect();
    TradeSentiment {
        date_range: DateRange {
            start: start.to_string(),
            end: end.to_string(),
        },
        daily,
        totals: totals.summary_totals(),
    }
}

impl SentimentDayAccumulator {
    fn add(&mut self, side: SentimentSide, trade: &volumeleaders_client::Trade) {
        match side {
            SentimentSide::Bear => self.bear.add(trade),
            SentimentSide::Bull => self.bull.add(trade),
        }
    }

    fn summary(self, date: String) -> TradeSentimentDay {
        let bear_dollars = self.bear.dollars;
        let bull_dollars = self.bull.dollars;
        let ratio = sentiment_ratio(bull_dollars, bear_dollars);
        TradeSentimentDay {
            date,
            bear: self.bear.summary(),
            bull: self.bull.summary(),
            ratio,
            signal: sentiment_signal(ratio, bull_dollars, bear_dollars),
        }
    }

    fn summary_totals(self) -> TradeSentimentTotals {
        let bear_dollars = self.bear.dollars;
        let bull_dollars = self.bull.dollars;
        let ratio = sentiment_ratio(bull_dollars, bear_dollars);
        TradeSentimentTotals {
            bear: self.bear.summary(),
            bull: self.bull.summary(),
            ratio,
            signal: sentiment_signal(ratio, bull_dollars, bear_dollars),
        }
    }
}

impl SentimentAccumulator {
    fn add(&mut self, trade: &volumeleaders_client::Trade) {
        self.trades += 1;
        let dollars = trade.dollars.and_then(|d| d.to_f64()).unwrap_or(0.0);
        self.dollars += dollars;
        let ticker = trade.ticker.as_deref().unwrap_or("unknown").to_string();
        *self.ticker_dollars.entry(ticker).or_default() += dollars;
    }

    fn summary(self) -> TradeSentimentSide {
        TradeSentimentSide {
            trades: self.trades,
            dollars: self.dollars,
            top_tickers: top_sentiment_tickers(self.ticker_dollars, 3),
        }
    }
}

#[derive(Clone, Copy)]
pub(super) enum SentimentSide {
    Bear,
    Bull,
}

pub(super) fn classify_trade_sentiment_side(
    trade: &volumeleaders_client::Trade,
) -> Option<SentimentSide> {
    for field in [&trade.sector, &trade.name, &trade.industry]
        .into_iter()
        .filter_map(Option::as_deref)
    {
        let lower = field.to_ascii_lowercase();
        if lower.contains("bear") {
            return Some(SentimentSide::Bear);
        }
        if lower.contains("bull") {
            return Some(SentimentSide::Bull);
        }
    }
    leveraged_etf_direction(trade.ticker.as_deref().unwrap_or_default())
}

fn leveraged_etf_direction(ticker: &str) -> Option<SentimentSide> {
    let ticker = ticker.trim().to_ascii_uppercase();
    if BEAR_TICKERS.contains(&ticker.as_str()) {
        Some(SentimentSide::Bear)
    } else if BULL_TICKERS.contains(&ticker.as_str()) {
        Some(SentimentSide::Bull)
    } else {
        None
    }
}

fn sentiment_ratio(bull_dollars: f64, bear_dollars: f64) -> Option<f64> {
    if bear_dollars == 0.0 {
        None
    } else {
        Some(bull_dollars / bear_dollars)
    }
}

pub(super) fn sentiment_signal(
    ratio: Option<f64>,
    bull_dollars: f64,
    bear_dollars: f64,
) -> TradeSentimentSignal {
    match ratio {
        None => {
            if bull_dollars > 0.0 {
                TradeSentimentSignal::ExtremeBull
            } else if bear_dollars > 0.0 {
                TradeSentimentSignal::ExtremeBear
            } else {
                TradeSentimentSignal::Neutral
            }
        }
        Some(value) if value < 0.2 => TradeSentimentSignal::ExtremeBear,
        Some(value) if value < 0.5 => TradeSentimentSignal::ModerateBear,
        Some(value) if value <= 2.0 => TradeSentimentSignal::Neutral,
        Some(value) if value <= 5.0 => TradeSentimentSignal::ModerateBull,
        Some(_) => TradeSentimentSignal::ExtremeBull,
    }
}

fn top_sentiment_tickers(ticker_dollars: HashMap<String, f64>, limit: usize) -> Vec<String> {
    let mut totals: Vec<(String, f64)> = ticker_dollars.into_iter().collect();
    totals.sort_by(|(ticker_a, dollars_a), (ticker_b, dollars_b)| {
        dollars_b
            .total_cmp(dollars_a)
            .then_with(|| ticker_a.cmp(ticker_b))
    });
    totals
        .into_iter()
        .take(limit)
        .map(|(ticker, _)| ticker)
        .collect()
}