use clap::Subcommand;
use serde::Serialize;
use tracing::instrument;
use marketsurge_client::industry::{IndustryGroupRsItem, IndustryOverviewItem};
use crate::cli::{IndustryArgs, SymbolsArgs};
use crate::common::command::{api_call, run_command, zip_symbols};
#[derive(Debug, Subcommand)]
pub enum IndustryCommand {
#[command(after_help = "Examples:\n marketsurge-agent industry rs AAPL MSFT")]
Rs(SymbolsArgs),
#[command(after_help = "Examples:\n marketsurge-agent industry overview AAPL MSFT")]
Overview(SymbolsArgs),
}
#[derive(Debug, Clone, Serialize)]
pub struct IndustryRsRecord {
pub symbol: String,
pub group_rs: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IndustryOverviewRecord {
pub ticker: String,
pub industry_id: String,
pub name: Option<String>,
pub sector: Option<String>,
pub ind_code: Option<i64>,
pub group_market_value_billions: Option<String>,
pub num_new_highs: Option<i64>,
pub num_new_lows: Option<i64>,
pub num_stocks: Option<i64>,
pub group_rank: Option<i64>,
pub pct_change_1d: Option<String>,
pub pct_change_ytd: Option<String>,
pub eps_rank: Option<i64>,
pub rs_rank: Option<i64>,
pub ad_rank: Option<i64>,
pub smr_rank: Option<i64>,
pub comp_rank: Option<i64>,
}
#[instrument(skip_all)]
#[cfg(not(coverage))]
pub async fn handle(args: &IndustryArgs, fields: &[String]) -> i32 {
match &args.command {
IndustryCommand::Rs(a) => execute_rs(a, fields).await,
IndustryCommand::Overview(a) => execute_overview(a, fields).await,
}
}
fn flatten_industry_rs(
symbols: &[&str],
market_data: &[IndustryGroupRsItem],
) -> Vec<IndustryRsRecord> {
zip_symbols(symbols, market_data)
.map(|(symbol, item)| {
let group_rs = item
.industry
.as_ref()
.and_then(|ind| ind.group_rs.first())
.and_then(|v| v.value);
IndustryRsRecord {
symbol: symbol.to_string(),
group_rs,
}
})
.collect()
}
#[instrument(skip_all)]
#[cfg(not(coverage))]
async fn execute_rs(args: &SymbolsArgs, fields: &[String]) -> i32 {
run_command(&args.symbols, fields, |client, symbol_refs| async move {
let response = api_call(client.industry_group_rs(&symbol_refs, None)).await?;
Ok(flatten_industry_rs(&symbol_refs, &response.market_data))
})
.await
}
fn flatten_industry_overview(
symbols: &[&str],
market_data: &[IndustryOverviewItem],
) -> Vec<IndustryOverviewRecord> {
zip_symbols(symbols, market_data)
.map(|(symbol, item)| {
let industry_id = item.id.clone().unwrap_or_default();
let industry = item.industry.as_ref();
let ratings = item.ratings.as_ref();
let rank = ratings.and_then(|r| r.industry.as_ref());
let group_rank = industry
.map(|ind| ind.group_ranks.as_slice())
.unwrap_or_default()
.first()
.and_then(|r| r.value);
let pct_change_1d = industry
.map(|ind| ind.price_percent_change_vs.as_slice())
.unwrap_or_default()
.iter()
.find(|v| v.subject.as_deref() == Some("VS_1D_AGO"))
.and_then(|v| v.formatted_value.clone());
let pct_change_ytd = industry
.map(|ind| ind.price_percent_change_vs.as_slice())
.unwrap_or_default()
.iter()
.find(|v| v.subject.as_deref() == Some("VS_YTD"))
.and_then(|v| v.formatted_value.clone());
IndustryOverviewRecord {
ticker: symbol.to_string(),
industry_id,
name: industry.and_then(|i| i.name.clone()),
sector: industry.and_then(|i| i.sector.clone()),
ind_code: industry.and_then(|i| i.ind_code),
group_market_value_billions: industry
.and_then(|i| i.group_market_value_in_billions.as_ref())
.and_then(|v| v.formatted_value.clone()),
num_new_highs: industry.and_then(|i| i.num_new_highs_in_group),
num_new_lows: industry.and_then(|i| i.num_new_lows_in_group),
num_stocks: industry.and_then(|i| i.number_of_stocks_in_group),
group_rank,
pct_change_1d,
pct_change_ytd,
eps_rank: rank.and_then(|r| r.eps_rank_in_industry_group),
rs_rank: rank.and_then(|r| r.rs_rank_in_industry_group),
ad_rank: rank.and_then(|r| r.ad_rank_in_industry_group),
smr_rank: rank.and_then(|r| r.smr_rank_in_industry_group),
comp_rank: rank.and_then(|r| r.comp_rank_in_industry_group),
}
})
.collect()
}
#[instrument(skip_all)]
#[cfg(not(coverage))]
async fn execute_overview(args: &SymbolsArgs, fields: &[String]) -> i32 {
run_command(&args.symbols, fields, |client, symbol_refs| async move {
let response = api_call(client.industry_overview(&symbol_refs, None)).await?;
Ok(flatten_industry_overview(
&symbol_refs,
&response.market_data,
))
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use marketsurge_client::industry::{
IndustryGroupRsIndustry, IndustryGroupRsValue, IndustryOverviewIndustry,
IndustryOverviewRatings, IndustryRankInGroup,
};
use marketsurge_client::market_data::{MdGroupRank, MdPercentChangeVs};
use marketsurge_client::types::FormattedFloat;
#[test]
fn flatten_industry_rs_happy_path() {
let items = vec![
IndustryGroupRsItem {
origin_request: None,
industry: Some(IndustryGroupRsIndustry {
group_rs: vec![IndustryGroupRsValue { value: Some(85) }],
}),
},
IndustryGroupRsItem {
origin_request: None,
industry: Some(IndustryGroupRsIndustry {
group_rs: vec![IndustryGroupRsValue { value: Some(42) }],
}),
},
];
let symbols = ["AAPL", "MSFT"];
let records = flatten_industry_rs(&symbols, &items);
assert_eq!(records.len(), 2);
assert_eq!(records[0].symbol, "AAPL");
assert_eq!(records[0].group_rs, Some(85));
assert_eq!(records[1].symbol, "MSFT");
assert_eq!(records[1].group_rs, Some(42));
}
#[test]
fn flatten_industry_rs_empty_market_data() {
let records = flatten_industry_rs(&["AAPL"], &[]);
assert!(records.is_empty());
}
#[test]
fn flatten_industry_rs_none_industry() {
let items = vec![IndustryGroupRsItem {
origin_request: None,
industry: None,
}];
let records = flatten_industry_rs(&["AAPL"], &items);
assert_eq!(records.len(), 1);
assert_eq!(records[0].symbol, "AAPL");
assert_eq!(records[0].group_rs, None);
}
#[test]
fn flatten_industry_overview_happy_path() {
let items = vec![IndustryOverviewItem {
id: Some("13-4698".to_string()),
industry: Some(IndustryOverviewIndustry {
name: Some("Elec-Semicondctor Fablss".to_string()),
ind_code: Some(7010),
news_code: Some("I/SEMF".to_string()),
sector: Some("CHIPS".to_string()),
group_market_value_in_billions: None,
num_new_highs_in_group: Some(1),
num_new_lows_in_group: Some(0),
number_of_stocks_in_group: Some(45),
group_ranks: vec![MdGroupRank {
value: Some(16),
period: Some("P6M".to_string()),
period_offset: Some("CURRENT".to_string()),
}],
price_percent_change_vs: vec![
MdPercentChangeVs {
value: None,
formatted_value: Some("-1.22%".to_string()),
subject: Some("VS_1D_AGO".to_string()),
period: None,
},
MdPercentChangeVs {
value: None,
formatted_value: Some("27.07%".to_string()),
subject: Some("VS_YTD".to_string()),
period: None,
},
],
}),
ratings: Some(IndustryOverviewRatings {
has_ratings_data: Some(true),
industry: Some(IndustryRankInGroup {
ad_rank_in_industry_group: Some(10),
comp_rank_in_industry_group: Some(1),
eps_rank_in_industry_group: Some(4),
number_of_stocks_in_group: Some(45),
rs_rank_in_industry_group: Some(2),
smr_rank_in_industry_group: Some(8),
}),
}),
}];
let records = flatten_industry_overview(&["AAPL"], &items);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.ticker, "AAPL");
assert_eq!(r.industry_id, "13-4698");
assert_eq!(r.name.as_deref(), Some("Elec-Semicondctor Fablss"));
assert_eq!(r.sector.as_deref(), Some("CHIPS"));
assert_eq!(r.ind_code, Some(7010));
assert_eq!(r.num_stocks, Some(45));
assert_eq!(r.group_rank, Some(16));
assert_eq!(r.pct_change_1d.as_deref(), Some("-1.22%"));
assert_eq!(r.pct_change_ytd.as_deref(), Some("27.07%"));
assert_eq!(r.eps_rank, Some(4));
assert_eq!(r.rs_rank, Some(2));
assert_eq!(r.comp_rank, Some(1));
}
#[test]
fn flatten_industry_overview_empty_market_data() {
let records = flatten_industry_overview(&[], &[]);
assert!(records.is_empty());
}
#[test]
fn flatten_industry_overview_none_fields() {
let items = vec![IndustryOverviewItem {
id: None,
industry: None,
ratings: None,
}];
let records = flatten_industry_overview(&["AAPL"], &items);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.ticker, "AAPL");
assert_eq!(r.industry_id, "");
assert!(r.name.is_none());
assert!(r.sector.is_none());
assert!(r.group_rank.is_none());
assert!(r.pct_change_1d.is_none());
assert!(r.pct_change_ytd.is_none());
assert!(r.eps_rank.is_none());
}
#[test]
fn flatten_industry_overview_subject_filter() {
let items = vec![IndustryOverviewItem {
id: Some("TEST".to_string()),
industry: Some(IndustryOverviewIndustry {
name: None,
ind_code: None,
news_code: None,
sector: None,
group_market_value_in_billions: None,
num_new_highs_in_group: None,
num_new_lows_in_group: None,
number_of_stocks_in_group: None,
group_ranks: vec![],
price_percent_change_vs: vec![MdPercentChangeVs {
value: None,
formatted_value: Some("15.00%".to_string()),
subject: Some("VS_YTD".to_string()),
period: None,
}],
}),
ratings: None,
}];
let records = flatten_industry_overview(&["TEST"], &items);
assert_eq!(records.len(), 1);
assert!(records[0].pct_change_1d.is_none());
assert_eq!(records[0].pct_change_ytd.as_deref(), Some("15.00%"));
}
#[test]
fn flatten_industry_overview_keeps_market_value_with_missing_rank_details() {
let items = vec![IndustryOverviewItem {
id: Some("13-4698".to_string()),
industry: Some(IndustryOverviewIndustry {
name: None,
ind_code: None,
news_code: None,
sector: None,
group_market_value_in_billions: Some(FormattedFloat {
value: Some(12.34),
formatted_value: Some("$12.34B".to_string()),
}),
num_new_highs_in_group: Some(3),
num_new_lows_in_group: Some(1),
number_of_stocks_in_group: Some(45),
group_ranks: vec![],
price_percent_change_vs: vec![],
}),
ratings: Some(IndustryOverviewRatings {
has_ratings_data: Some(false),
industry: None,
}),
}];
let records = flatten_industry_overview(&["AAPL", "EXTRA"], &items);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.ticker, "AAPL");
assert_eq!(r.industry_id, "13-4698");
assert_eq!(r.group_market_value_billions.as_deref(), Some("$12.34B"));
assert_eq!(r.num_new_highs, Some(3));
assert_eq!(r.num_new_lows, Some(1));
assert_eq!(r.num_stocks, Some(45));
assert!(r.group_rank.is_none());
assert!(r.eps_rank.is_none());
assert!(r.comp_rank.is_none());
}
}