use serde::Serialize;
use tracing::instrument;
use crate::cli::SymbolsArgs;
use marketsurge_client::market_data::MdMarketDataItem;
use crate::common::command::{api_call, run_command, zip_symbols};
#[derive(Debug, Clone, Serialize)]
pub struct MarketDataRecord {
pub symbol: String,
pub company_name: Option<String>,
pub instrument_type: Option<String>,
pub ipo_date: Option<String>,
pub comp_rating: Option<i64>,
pub rs_rating: Option<i64>,
pub eps_rating: Option<i64>,
pub smr_rating: Option<String>,
pub ad_rating: Option<String>,
pub market_cap: Option<String>,
pub avg_dollar_volume_50d: Option<String>,
pub up_down_volume_ratio: Option<String>,
pub short_interest_pct_float: Option<String>,
pub short_interest_days_to_cover: Option<String>,
pub industry_name: Option<String>,
pub industry_sector: Option<String>,
pub industry_stocks_in_group: Option<i64>,
pub funds_pct_float_held: Option<String>,
pub eps_due_date: Option<String>,
pub eps_due_date_status: Option<String>,
pub debt_pct: Option<String>,
pub rd_pct_last_qtr: Option<String>,
pub decode_error: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct SymbologyFields {
company_name: Option<String>,
instrument_type: Option<String>,
ipo_date: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct RatingFields {
comp_rating: Option<i64>,
rs_rating: Option<i64>,
eps_rating: Option<i64>,
smr_rating: Option<String>,
ad_rating: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct PricingFields {
market_cap: Option<String>,
avg_dollar_volume_50d: Option<String>,
up_down_volume_ratio: Option<String>,
short_interest_pct_float: Option<String>,
short_interest_days_to_cover: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct IndustryFields {
industry_name: Option<String>,
industry_sector: Option<String>,
industry_stocks_in_group: Option<i64>,
}
#[derive(Debug, Clone, Default)]
struct OwnershipFields {
funds_pct_float_held: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct FinancialFields {
eps_due_date: Option<String>,
eps_due_date_status: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct FundamentalFields {
debt_pct: Option<String>,
rd_pct_last_qtr: Option<String>,
}
fn symbology_fields(item: &MdMarketDataItem) -> SymbologyFields {
let symbology = item.symbology.as_ref();
let company = symbology.and_then(|s| s.company.as_ref());
let instrument = symbology.and_then(|s| s.instrument.as_ref());
SymbologyFields {
company_name: company.and_then(|c| c.company_name.clone()),
instrument_type: instrument.and_then(|i| i.sub_type.clone()),
ipo_date: instrument
.and_then(|i| i.ipo_date.as_ref())
.and_then(|d| d.value.clone()),
}
}
fn rating_fields(item: &MdMarketDataItem) -> RatingFields {
let ratings = item.ratings.as_ref();
RatingFields {
comp_rating: ratings
.and_then(|r| r.comp_rating.first())
.and_then(|r| r.value),
rs_rating: ratings
.and_then(|r| r.rs_rating.first())
.and_then(|r| r.value),
eps_rating: ratings
.and_then(|r| r.eps_rating.first())
.and_then(|r| r.value),
smr_rating: ratings
.and_then(|r| r.smr_rating.first())
.and_then(|r| r.letter_value.clone()),
ad_rating: ratings
.and_then(|r| r.ad_rating.first())
.and_then(|r| r.letter_value.clone()),
}
}
fn pricing_fields(item: &MdMarketDataItem) -> PricingFields {
let eod = item
.pricing_statistics
.as_ref()
.and_then(|p| p.end_of_day_statistics.as_ref());
PricingFields {
market_cap: eod
.and_then(|e| e.market_capitalization.as_ref())
.and_then(|v| v.formatted_value.clone()),
avg_dollar_volume_50d: eod
.and_then(|e| e.avg_dollar_volume_50_day.as_ref())
.and_then(|v| v.formatted_value.clone()),
up_down_volume_ratio: eod
.and_then(|e| e.up_down_volume_ratio.as_ref())
.and_then(|v| v.formatted_value.clone()),
short_interest_pct_float: eod
.and_then(|e| e.short_interest.as_ref())
.and_then(|si| si.percent_of_float.as_ref())
.and_then(|v| v.formatted_value.clone()),
short_interest_days_to_cover: eod
.and_then(|e| e.short_interest.as_ref())
.and_then(|si| si.days_to_cover.as_ref())
.and_then(|v| v.formatted_value.clone()),
}
}
fn industry_fields(item: &MdMarketDataItem) -> IndustryFields {
let industry = item.industry.as_ref();
IndustryFields {
industry_name: industry.and_then(|i| i.name.clone()),
industry_sector: industry.and_then(|i| i.sector.clone()),
industry_stocks_in_group: industry.and_then(|i| i.number_of_stocks_in_group),
}
}
fn ownership_fields(item: &MdMarketDataItem) -> OwnershipFields {
OwnershipFields {
funds_pct_float_held: item
.ownership
.as_ref()
.and_then(|o| o.funds_float_percent_held.as_ref())
.and_then(|v| v.formatted_value.clone()),
}
}
fn financial_fields(item: &MdMarketDataItem) -> FinancialFields {
let financials = item.financials.as_ref();
FinancialFields {
eps_due_date: financials
.and_then(|f| f.eps_due_date.as_ref())
.and_then(|d| d.formatted_value.clone()),
eps_due_date_status: financials.and_then(|f| f.eps_due_date_status.clone()),
}
}
fn fundamental_fields(item: &MdMarketDataItem) -> FundamentalFields {
let fundamentals = item.fundamentals.as_ref();
FundamentalFields {
debt_pct: fundamentals
.and_then(|f| f.debt_percent.as_ref())
.and_then(|v| v.formatted_value.clone()),
rd_pct_last_qtr: fundamentals
.and_then(|f| f.research_and_development_percent_last_qtr.as_ref())
.and_then(|v| v.formatted_value.clone()),
}
}
fn flatten_market_data(
symbols: &[&str],
market_data: &[MdMarketDataItem],
) -> Vec<MarketDataRecord> {
let mut records = Vec::new();
for (symbol, item) in zip_symbols(symbols, market_data) {
let symbology = symbology_fields(item);
let ratings = rating_fields(item);
let pricing = pricing_fields(item);
let industry = industry_fields(item);
let ownership = ownership_fields(item);
let financials = financial_fields(item);
let fundamentals = fundamental_fields(item);
records.push(MarketDataRecord {
symbol: symbol.to_string(),
company_name: symbology.company_name,
instrument_type: symbology.instrument_type,
ipo_date: symbology.ipo_date,
comp_rating: ratings.comp_rating,
rs_rating: ratings.rs_rating,
eps_rating: ratings.eps_rating,
smr_rating: ratings.smr_rating,
ad_rating: ratings.ad_rating,
market_cap: pricing.market_cap,
avg_dollar_volume_50d: pricing.avg_dollar_volume_50d,
up_down_volume_ratio: pricing.up_down_volume_ratio,
short_interest_pct_float: pricing.short_interest_pct_float,
short_interest_days_to_cover: pricing.short_interest_days_to_cover,
industry_name: industry.industry_name,
industry_sector: industry.industry_sector,
industry_stocks_in_group: industry.industry_stocks_in_group,
funds_pct_float_held: ownership.funds_pct_float_held,
eps_due_date: financials.eps_due_date,
eps_due_date_status: financials.eps_due_date_status,
debt_pct: fundamentals.debt_pct,
rd_pct_last_qtr: fundamentals.rd_pct_last_qtr,
decode_error: item.decode_error.clone(),
});
}
records
}
fn all_rows_failed_to_decode(records: &[MarketDataRecord]) -> bool {
!records.is_empty() && records.iter().all(|record| record.decode_error.is_some())
}
#[instrument(skip_all)]
#[cfg(not(coverage))]
pub async fn handle(args: &SymbolsArgs, fields: &[String]) -> i32 {
run_command(&args.symbols, fields, |client, symbol_refs| async move {
let response = api_call(client.other_market_data(
&symbol_refs,
"CHARTING",
"P12Q_AGO",
"P24Q_AGO",
"P4Q_FUTURE",
))
.await?;
let records = flatten_market_data(&symbol_refs, &response.market_data);
if all_rows_failed_to_decode(&records) {
for record in &records {
if let Some(error) = &record.decode_error {
eprintln!(
"market-data decode error for {}: {error}",
record.symbol.as_str()
);
}
}
return Err(1);
}
Ok(records)
})
.await
}
#[cfg(test)]
mod tests {
use marketsurge_client::market_data::{
MdCompany, MdEndOfDayStatistics, MdFinancials, MdFormattedString, MdFundamentals,
MdIndustry, MdInstrument, MdMarketDataItem, MdOwnership, MdPricingStatistics, MdRating,
MdRatings, MdScaledFloat, MdShortInterest, MdSymbology,
};
use marketsurge_client::types::{DateValue, FormattedFloat};
use super::*;
fn empty_item() -> MdMarketDataItem {
MdMarketDataItem {
id: None,
origin_request: None,
ratings: None,
pricing_statistics: None,
corporate_actions: None,
symbology: None,
pattern_info: None,
financials: None,
industry: None,
ownership: None,
fundamentals: None,
decode_error: None,
}
}
#[test]
fn flatten_happy_path() {
let item = MdMarketDataItem {
symbology: Some(MdSymbology {
company: Some(MdCompany {
company_name: Some("Apple Inc".to_string()),
address: None,
address2: None,
phone: None,
business_description: None,
url: None,
city: None,
country: None,
state_province: None,
}),
instrument: Some(MdInstrument {
sub_type: Some("COMMON_STOCK".to_string()),
ipo_date: Some(DateValue {
value: Some("1980-12-12".to_string()),
}),
ipo_price: None,
}),
}),
ratings: Some(MdRatings {
comp_rating: vec![MdRating {
value: Some(95),
period_offset: None,
period: None,
letter_value: None,
}],
rs_rating: vec![MdRating {
value: Some(92),
period_offset: None,
period: None,
letter_value: None,
}],
eps_rating: vec![MdRating {
value: Some(88),
period_offset: None,
period: None,
letter_value: None,
}],
smr_rating: vec![MdRating {
value: None,
period_offset: None,
period: None,
letter_value: Some("A".to_string()),
}],
ad_rating: vec![MdRating {
value: None,
period_offset: None,
period: None,
letter_value: Some("B+".to_string()),
}],
}),
industry: Some(MdIndustry {
name: Some("Computers-Hardware".to_string()),
sector: Some("Technology".to_string()),
ind_code: None,
group_ranks: vec![],
group_rs: vec![],
number_of_stocks_in_group: Some(25),
}),
..empty_item()
};
let records = flatten_market_data(&["AAPL"], &[item]);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.symbol, "AAPL");
assert_eq!(r.company_name.as_deref(), Some("Apple Inc"));
assert_eq!(r.instrument_type.as_deref(), Some("COMMON_STOCK"));
assert_eq!(r.ipo_date.as_deref(), Some("1980-12-12"));
assert_eq!(r.comp_rating, Some(95));
assert_eq!(r.rs_rating, Some(92));
assert_eq!(r.eps_rating, Some(88));
assert_eq!(r.smr_rating.as_deref(), Some("A"));
assert_eq!(r.ad_rating.as_deref(), Some("B+"));
assert_eq!(r.industry_name.as_deref(), Some("Computers-Hardware"));
assert_eq!(r.industry_sector.as_deref(), Some("Technology"));
assert_eq!(r.industry_stocks_in_group, Some(25));
assert!(r.decode_error.is_none());
}
#[test]
fn flatten_partial_data() {
let item = MdMarketDataItem {
symbology: Some(MdSymbology {
company: Some(MdCompany {
company_name: Some("Test Corp".to_string()),
address: None,
address2: None,
phone: None,
business_description: None,
url: None,
city: None,
country: None,
state_province: None,
}),
instrument: None,
}),
..empty_item()
};
let records = flatten_market_data(&["TST"], &[item]);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.symbol, "TST");
assert_eq!(r.company_name.as_deref(), Some("Test Corp"));
assert!(r.instrument_type.is_none());
assert!(r.ipo_date.is_none());
assert!(r.comp_rating.is_none());
assert!(r.rs_rating.is_none());
assert!(r.eps_rating.is_none());
assert!(r.smr_rating.is_none());
assert!(r.ad_rating.is_none());
assert!(r.market_cap.is_none());
assert!(r.industry_name.is_none());
assert!(r.funds_pct_float_held.is_none());
assert!(r.eps_due_date.is_none());
assert!(r.debt_pct.is_none());
assert!(r.decode_error.is_none());
}
#[test]
fn flatten_includes_per_symbol_decode_error() {
let item = MdMarketDataItem {
decode_error: Some("invalid type: map, expected a string".to_string()),
..empty_item()
};
let records = flatten_market_data(&["APLD"], &[item]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].symbol, "APLD");
assert_eq!(
records[0].decode_error.as_deref(),
Some("invalid type: map, expected a string")
);
}
#[test]
fn all_rows_failed_to_decode_requires_non_empty_all_error_rows() {
assert!(!all_rows_failed_to_decode(&[]));
let good_item = MdMarketDataItem {
symbology: Some(MdSymbology {
company: Some(MdCompany {
company_name: Some("Apple Inc".to_string()),
address: None,
address2: None,
phone: None,
business_description: None,
url: None,
city: None,
country: None,
state_province: None,
}),
instrument: None,
}),
..empty_item()
};
let bad_item = MdMarketDataItem {
decode_error: Some("invalid length 1, expected struct MdInstrument".to_string()),
..empty_item()
};
let mixed_records = flatten_market_data(&["AAPL", "NOW"], &[good_item, bad_item.clone()]);
assert!(!all_rows_failed_to_decode(&mixed_records));
let failed_records = flatten_market_data(&["NOW"], &[bad_item]);
assert!(all_rows_failed_to_decode(&failed_records));
}
#[test]
fn flatten_includes_nested_statistics_and_fundamentals() {
let item = MdMarketDataItem {
pricing_statistics: Some(MdPricingStatistics {
end_of_day_statistics: Some(MdEndOfDayStatistics {
historical_price_statistics: vec![],
pricing_start_date: None,
pricing_end_date: None,
volume_moving_averages: vec![],
avg_dollar_volume_50_day: Some(FormattedFloat {
value: Some(25_000_000.0),
formatted_value: Some("25.0M".to_string()),
}),
market_capitalization: Some(FormattedFloat {
value: Some(1_500_000_000.0),
formatted_value: Some("1.5B".to_string()),
}),
average_true_range_percent: vec![],
ant_events: vec![],
up_down_volume_ratio: Some(MdScaledFloat {
value: Some(1.4),
scaling_factor: None,
formatted_value: Some("1.4".to_string()),
}),
alpha: None,
beta: None,
short_interest: Some(MdShortInterest {
days_to_cover: Some(FormattedFloat {
value: Some(2.5),
formatted_value: Some("2.5".to_string()),
}),
days_to_cover_percent_change: None,
percent_of_float: Some(MdScaledFloat {
value: Some(7.5),
scaling_factor: None,
formatted_value: Some("7.5%".to_string()),
}),
volume: None,
}),
blue_dot_daily_events: vec![],
blue_dot_weekly_events: vec![],
}),
intraday_statistics: None,
}),
financials: Some(MdFinancials {
eps_due_date: Some(MdFormattedString {
value: Some("2026-01-30".to_string()),
formatted_value: Some("Jan 30, 2026".to_string()),
}),
eps_due_date_status: Some("CONFIRMED".to_string()),
eps_last_reported_date: None,
consensus_financials: None,
cash_flow_per_share_last_year: None,
profit_margin_values: vec![],
estimates: None,
}),
ownership: Some(MdOwnership {
funds_float_percent_held: Some(MdScaledFloat {
value: Some(62.5),
scaling_factor: None,
formatted_value: Some("62.5%".to_string()),
}),
}),
fundamentals: Some(MdFundamentals {
research_and_development_percent_last_qtr: Some(MdScaledFloat {
value: Some(4.2),
scaling_factor: None,
formatted_value: Some("4.2%".to_string()),
}),
new_ceo_date: None,
debt_percent: Some(FormattedFloat {
value: Some(12.3),
formatted_value: Some("12.3%".to_string()),
}),
}),
..empty_item()
};
let records = flatten_market_data(&["APLD", "EXTRA"], &[item]);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.symbol, "APLD");
assert_eq!(r.market_cap.as_deref(), Some("1.5B"));
assert_eq!(r.avg_dollar_volume_50d.as_deref(), Some("25.0M"));
assert_eq!(r.up_down_volume_ratio.as_deref(), Some("1.4"));
assert_eq!(r.short_interest_pct_float.as_deref(), Some("7.5%"));
assert_eq!(r.short_interest_days_to_cover.as_deref(), Some("2.5"));
assert_eq!(r.funds_pct_float_held.as_deref(), Some("62.5%"));
assert_eq!(r.eps_due_date.as_deref(), Some("Jan 30, 2026"));
assert_eq!(r.eps_due_date_status.as_deref(), Some("CONFIRMED"));
assert_eq!(r.debt_pct.as_deref(), Some("12.3%"));
assert_eq!(r.rd_pct_last_qtr.as_deref(), Some("4.2%"));
}
#[test]
fn flatten_empty_vec() {
let records = flatten_market_data(&[], &[]);
assert!(records.is_empty());
}
}