fix(screen): debug the msn api, remove probelamtic msn api, filtering via client side

This commit is contained in:
Rasyidan Akbar F. 2026-03-18 11:09:51 +07:00
commit 7d32e9d69d
3 changed files with 171 additions and 15 deletions

View file

@ -394,12 +394,12 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
.as_ref()
.ok_or_else(|| IdxError::ParseError("no screener data".into()))?;
// Build Quote directly from screener MsnQuote data; skip stocks with no price
// Build Quote directly from screener MsnQuote data; default price to 0 if missing
// (do not route through parse_quote which errors on missing price)
let results: Vec<Quote> = quotes
.iter()
.filter_map(|q| {
let raw_price = q.price?; // skip if no price
.map(|q| {
let raw_price = q.price.unwrap_or(0.0);
let price = round_price(raw_price);
let prev_close = q.price_previous_close.map(round_price);
let change = prev_close
@ -425,7 +425,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
}
_ => (None, None),
};
Some(Quote {
Quote {
symbol: normalized_symbol(&ticker, &ticker),
price,
change,
@ -438,7 +438,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
range_signal,
prev_close,
avg_volume: round_u64(q.average_volume),
})
}
})
.collect();

View file

@ -23,17 +23,17 @@ pub enum Shell {
long_about = "idx-cli is a Rust command-line tool for Indonesian stock market (IDX) analysis.\nIt supports quote lookup, historical data, local caching, and output formats for both humans and AI agents."
)]
pub struct Cli {
#[arg(short, long, value_enum, global = true)]
#[arg(short, long, value_enum, global = true, help = "Output format")]
pub output: Option<OutputFormat>,
#[arg(long, global = true)]
#[arg(long, global = true, help = "Disable colored output")]
pub no_color: bool,
#[arg(short, long, global = true)]
#[arg(short, long, global = true, help = "Suppress non-essential output")]
pub quiet: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
#[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")]
pub verbose: u8,
#[arg(long, global = true)]
#[arg(long, global = true, help = "Run without network requests")]
pub offline: bool,
#[arg(long, global = true)]
#[arg(long, global = true, help = "Bypass local cache")]
pub no_cache: bool,
#[command(subcommand)]
pub command: Commands,

View file

@ -1,3 +1,5 @@
use std::cmp::Ordering;
use clap::{Args, Subcommand};
use serde::{Serialize, de::DeserializeOwned};
@ -427,9 +429,15 @@ pub fn handle(
let msn = MsnProvider::new(false);
let filter_key = screener_filter_key(filter);
let region_key = screener_region_key(region);
let quotes: Vec<Quote> = fetch_msn_only("screen", config.provider, || {
msn.screener(filter_key, region_key, *limit)
// For filters that fall back to topperfs, fetch all stocks so
// client-side sorting picks the correct top N.
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
let mut quotes: Vec<Quote> = fetch_msn_only("screen", config.provider, || {
msn.screener(filter_key, region_key, fetch_limit)
})?;
sort_screener_quotes(&mut quotes, filter);
quotes.truncate(*limit);
render_screener(&quotes, &config.output, config.no_color)
}
StocksSubcommand::Compare { symbols } => {
@ -644,8 +652,8 @@ fn screener_filter_key(filter: &str) -> &'static str {
"low-pe" => "st_list_lowpe",
"52w-high" => "st_list_52wkhi",
"52w-low" => "st_list_52wklow",
"high-volume" => "st_list_highvol",
"large-cap" => "st_list_largecap",
"high-volume" => "st_list_topperfs",
"large-cap" => "st_list_topperfs",
_ => "st_list_topperfs",
}
}
@ -661,6 +669,46 @@ fn screener_region_key(region: &str) -> &'static str {
}
}
fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) {
match filter {
"top-performers" => {
quotes.sort_by(|a, b| {
b.change_pct
.partial_cmp(&a.change_pct)
.unwrap_or(Ordering::Equal)
});
}
"worst-performers" => {
quotes.sort_by(|a, b| {
a.change_pct
.partial_cmp(&b.change_pct)
.unwrap_or(Ordering::Equal)
});
}
"52w-high" => {
quotes.sort_by(|a, b| {
let pa = a.week52_position.unwrap_or(f64::MIN);
let pb = b.week52_position.unwrap_or(f64::MIN);
pb.partial_cmp(&pa).unwrap_or(Ordering::Equal)
});
}
"52w-low" => {
quotes.sort_by(|a, b| {
let pa = a.week52_position.unwrap_or(f64::MAX);
let pb = b.week52_position.unwrap_or(f64::MAX);
pa.partial_cmp(&pb).unwrap_or(Ordering::Equal)
});
}
"high-volume" => {
quotes.sort_by(|a, b| b.volume.cmp(&a.volume));
}
"large-cap" => {
quotes.sort_by(|a, b| b.market_cap.unwrap_or(0).cmp(&a.market_cap.unwrap_or(0)));
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use chrono::{Days, NaiveDate};
@ -696,4 +744,112 @@ mod tests {
assert!(report.volume.ratio20.is_some());
assert_eq!(report.signals.trend, Signal::Neutral);
}
#[test]
fn sort_screener_top_performers_descending() {
use crate::api::types::Quote;
let mut quotes = vec![
Quote {
symbol: "A".into(),
price: 100,
change: 1,
change_pct: 1.0,
volume: 100,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
Quote {
symbol: "B".into(),
price: 200,
change: 10,
change_pct: 5.0,
volume: 200,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
Quote {
symbol: "C".into(),
price: 150,
change: 5,
change_pct: 3.0,
volume: 150,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
];
super::sort_screener_quotes(&mut quotes, "top-performers");
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
assert_eq!(pcts, vec![5.0, 3.0, 1.0]);
}
#[test]
fn sort_screener_worst_performers_ascending() {
use crate::api::types::Quote;
let mut quotes = vec![
Quote {
symbol: "A".into(),
price: 100,
change: 1,
change_pct: 1.0,
volume: 100,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
Quote {
symbol: "B".into(),
price: 200,
change: -10,
change_pct: -5.0,
volume: 200,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
Quote {
symbol: "C".into(),
price: 150,
change: -3,
change_pct: -2.0,
volume: 150,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
},
];
super::sort_screener_quotes(&mut quotes, "worst-performers");
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
}
}