diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 42b9afd..39ca8cc 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1109,10 +1109,11 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ) .bold() ); + // Brokers on either side mean trades, even when a total is unavailable. if report - .buy_value - .zip(report.sell_value) - .is_none_or(|(buy, sell)| buy <= 0 && sell <= 0) + .tiers + .iter() + .all(|tier| tier.buy_value == Some(0) && tier.sell_value == Some(0)) { println!("No trades."); return Ok(()); diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs index e793ab1..10439f2 100644 --- a/crates/idx-core/src/analysis/bandar.rs +++ b/crates/idx-core/src/analysis/bandar.rs @@ -193,7 +193,13 @@ pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { i128::from(bought), i128::from(sold), ); - i64::try_from((buy * sold - sell * bought) * 10_000 / (bought * sold)).ok() + // Checked: past i128 there is no reading, never a wrapped one. + buy.checked_mul(sold) + .zip(sell.checked_mul(bought)) + .and_then(|(buys, sells)| buys.checked_sub(sells)) + .and_then(|diff| diff.checked_mul(10_000)) + .zip(bought.checked_mul(sold)) + .and_then(|(diff, denominator)| i64::try_from(diff / denominator).ok()) } _ => None, }; @@ -440,6 +446,18 @@ mod tests { assert_eq!(report.tiers[2].buy_value, Some(0)); } + #[test] + fn test_bandar_tier_past_i128_has_no_reading_instead_of_a_wrapped_one() { + let reading = |total: i64| { + let buyers = vec![flow("AA", Some(total / 10 * 6), 1)]; + let sellers = vec![flow("BB", Some(total / 10 * 4), 1)]; + bandar_tier(&summary(buyers, sellers, total), 1).imbalance_bps + }; + // A quadrillion rupiah, far past any session, is still exact. + assert_eq!(reading(1_000_000_000_000_000), Some(2000)); + assert_eq!(reading(1_000_000_000_000_000_000), None); + } + #[test] fn test_check_top_allows_only_report_tiers() { assert_eq!(check_top(5).unwrap(), 5); diff --git a/crates/idx-core/src/api/idx/parse.rs b/crates/idx-core/src/api/idx/parse.rs index cfcd6e4..21f9005 100644 --- a/crates/idx-core/src/api/idx/parse.rs +++ b/crates/idx-core/src/api/idx/parse.rs @@ -38,11 +38,13 @@ fn whole(value: Option) -> Option { /// The rows of one session. A day without trading (weekend, holiday) is /// empty; rows dated other than `date` are dropped, so another session's -/// figures never pass as this one's. +/// figures never pass as this one's. Rows for other days only are an error, +/// not an empty session, so they are never cached as one. pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, IdxError> { let response: StockSummaryResponse = serde_json::from_str(raw) .map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?; - Ok(response + let answered = response.data.len(); + let rows: Vec = response .data .into_iter() .filter(|row| { @@ -66,7 +68,13 @@ pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, non_regular_volume: whole(row.non_regular_volume), non_regular_value: whole(row.non_regular_value), }) - .collect()) + .collect(); + if rows.is_empty() && answered > 0 { + return Err(IdxError::ParseError(format!( + "idx stock summary: asked for {date}, got {answered} rows for other days" + ))); + } + Ok(rows) } #[cfg(test)] @@ -101,11 +109,10 @@ mod tests { .unwrap() .is_empty() ); - assert!( - parse_stock_summary(DAY, date(2026, 10, 15)) - .unwrap() - .is_empty() - ); + assert!(matches!( + parse_stock_summary(DAY, date(2026, 10, 15)), + Err(IdxError::ParseError(_)) + )); assert!(matches!( parse_stock_summary("Just a moment...", date(2026, 10, 16)), Err(IdxError::ParseError(_)) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 9dd822a..c996ff5 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -151,7 +151,11 @@ pub fn parse_broker_summary(html: &str) -> Result { if html.to_ascii_lowercase().contains("invalid stock code") { return Ok(IpotPage::Invalid); } - let (Some(body_start), Some(body_end)) = (html.find(""), html.find("")) else { + let Some((body_start, body_end)) = html.find("").and_then(|start| { + html[start..] + .find("") + .map(|len| (start, start + len)) + }) else { return Err(IdxError::ParseError( "ipot broker summary: no table in the page".to_string(), )); @@ -168,17 +172,37 @@ pub fn parse_broker_summary(html: &str) -> Result { value }) }; + // A skipped row would shift every later broker up a rank, so a row of + // another shape, a rank out of order, or a broker after a side's blank + // rows rejects the page. + let (mut buyers_ended, mut sellers_ended) = (false, false); for (index, row) in html[body_start..body_end].split("").enumerate() { let row = cells(row); - if row.len() < 9 { + if row.is_empty() { continue; } - let rank = row[4].text.parse::().unwrap_or(index as u32 + 1); - for (side, at) in [(&mut raw.buyers, 0), (&mut raw.sellers, 5)] { + let rank = index as u32 + 1; + if row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { + return Err(IdxError::ParseError(format!( + "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", + row.len(), + row.get(4).map(|cell| cell.text.as_str()) + ))); + } + for (side, at, ended) in [ + (&mut raw.buyers, 0, &mut buyers_ended), + (&mut raw.sellers, 5, &mut sellers_ended), + ] { let code = row[at].text.to_ascii_uppercase(); if code.is_empty() { + *ended = true; continue; } + if *ended { + return Err(IdxError::ParseError(format!( + "ipot broker summary: broker {code:?} after a blank row" + ))); + } if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { return Err(IdxError::ParseError(format!( "ipot broker summary: unexpected broker code {code:?}" @@ -314,4 +338,23 @@ mod tests { Err(IdxError::ParseError(_)) )); } + + #[test] + fn test_parse_broker_summary_rejects_rows_that_would_shift_ranks() { + let first_cell = "MG"; + let short_row = DAY.replacen(first_cell, "", 1); + let skipped_rank = DAY.replacen( + "1", + "2", + 1, + ); + let gap = DAY.replacen(">XL<", "><", 1); + for page in [short_row, skipped_rank, gap, "".to_string()] { + assert!( + matches!(parse_broker_summary(&page), Err(IdxError::ParseError(_))), + "{}", + &page[..page.len().min(80)] + ); + } + } } diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 7392266..f90bc56 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -114,7 +114,11 @@ impl Broker<'_> { source: SOURCE, }, policy, - || provider.broker_summary(&code, query), + || { + provider + .broker_summary(&code, query) + .and_then(|raw| check_echoed_dates(query, raw)) + }, |raw| Ok(summary_from_raw(&resolved, query, raw)), ) } @@ -207,6 +211,25 @@ pub(crate) fn summary_from_raw( summary } +/// The page echoes the dates it covers; another range's figures must not be +/// cached or reported as the requested one's. +fn check_echoed_dates( + query: &BrokerQuery, + raw: BrokerSummaryRaw, +) -> Result { + if raw.from == Some(query.from) && raw.to == Some(query.to) { + return Ok(raw); + } + let echoed = |date: Option| date.map_or("none".to_string(), |date| date.to_string()); + Err(IdxError::ParseError(format!( + "ipot broker summary: asked for {} to {}, the page covers {} to {}", + query.from, + query.to, + echoed(raw.from), + echoed(raw.to) + ))) +} + fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> { if query.from > query.to { return Err(IdxError::InvalidInput(format!( @@ -381,4 +404,29 @@ mod tests { ); } } + + #[test] + fn test_check_echoed_dates_rejects_another_sessions_page() { + let friday = date(2026, 10, 16); + let page = || BrokerSummaryRaw { + from: Some(friday), + to: Some(friday), + ..BrokerSummaryRaw::default() + }; + assert!(check_echoed_dates(&query(friday, friday), page()).is_ok()); + let thursday = date(2026, 10, 15); + for (asked, raw) in [ + (query(thursday, thursday), page()), + (query(thursday, friday), page()), + (query(friday, friday), BrokerSummaryRaw::default()), + ] { + assert!( + matches!( + check_echoed_dates(&asked, raw), + Err(IdxError::ParseError(_)) + ), + "{asked:?}" + ); + } + } } diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs index eec2d31..ff31688 100644 --- a/crates/idx-core/src/flow/mod.rs +++ b/crates/idx-core/src/flow/mod.rs @@ -104,6 +104,9 @@ impl Flow<'_> { Err(IdxError::CacheMiss(_)) if skip_misses => continue, Err(err) => return Err(err), }; + // Every consulted session counts toward freshness, empty ones too. + fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); + stale |= fetch.stale; match fetch.day { Day::NoSession => continue, Day::Absent => {} @@ -116,8 +119,6 @@ impl Flow<'_> { } } sessions += 1; - fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); - stale |= fetch.stale; } if sessions == 0 { return Err(if policy == CachePolicy::Offline {