mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(core): reject broker and trading pages that would misreport a session
From the 0.4.0 release review (Codex): - IPOT pages with a short row, a rank out of order or a broker after a blank row are parse errors; skipping a row shifted later brokers up a rank and could flip a bandar reading (-2425 bps to -66 bps). - A tbody end before its start is a parse error, not a panic. - An IPOT page that echoes other dates than requested is rejected before it is cached, so one session's figures never pass as another's. - An IDX trading summary with rows for other days only is an error, not an empty session cached for 30 days. - Empty sessions count toward a flow's fetched_at and stale. - The bandar imbalance uses checked i128 arithmetic: past i128 there is no reading instead of a wrapped one. - The bandar table prints "No trades." only when no broker traded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
8b507d78e2
commit
d82f7c6be9
6 changed files with 137 additions and 19 deletions
|
|
@ -1109,10 +1109,11 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(),
|
||||||
)
|
)
|
||||||
.bold()
|
.bold()
|
||||||
);
|
);
|
||||||
|
// Brokers on either side mean trades, even when a total is unavailable.
|
||||||
if report
|
if report
|
||||||
.buy_value
|
.tiers
|
||||||
.zip(report.sell_value)
|
.iter()
|
||||||
.is_none_or(|(buy, sell)| buy <= 0 && sell <= 0)
|
.all(|tier| tier.buy_value == Some(0) && tier.sell_value == Some(0))
|
||||||
{
|
{
|
||||||
println!("No trades.");
|
println!("No trades.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,13 @@ pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier {
|
||||||
i128::from(bought),
|
i128::from(bought),
|
||||||
i128::from(sold),
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
@ -440,6 +446,18 @@ mod tests {
|
||||||
assert_eq!(report.tiers[2].buy_value, Some(0));
|
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]
|
#[test]
|
||||||
fn test_check_top_allows_only_report_tiers() {
|
fn test_check_top_allows_only_report_tiers() {
|
||||||
assert_eq!(check_top(5).unwrap(), 5);
|
assert_eq!(check_top(5).unwrap(), 5);
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,13 @@ fn whole(value: Option<f64>) -> Option<i64> {
|
||||||
|
|
||||||
/// The rows of one session. A day without trading (weekend, holiday) is
|
/// The rows of one session. A day without trading (weekend, holiday) is
|
||||||
/// empty; rows dated other than `date` are dropped, so another session's
|
/// 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<Vec<StockDay>, IdxError> {
|
pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result<Vec<StockDay>, IdxError> {
|
||||||
let response: StockSummaryResponse = serde_json::from_str(raw)
|
let response: StockSummaryResponse = serde_json::from_str(raw)
|
||||||
.map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?;
|
.map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?;
|
||||||
Ok(response
|
let answered = response.data.len();
|
||||||
|
let rows: Vec<StockDay> = response
|
||||||
.data
|
.data
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|row| {
|
.filter(|row| {
|
||||||
|
|
@ -66,7 +68,13 @@ pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result<Vec<StockDay>,
|
||||||
non_regular_volume: whole(row.non_regular_volume),
|
non_regular_volume: whole(row.non_regular_volume),
|
||||||
non_regular_value: whole(row.non_regular_value),
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -101,11 +109,10 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(matches!(
|
||||||
parse_stock_summary(DAY, date(2026, 10, 15))
|
parse_stock_summary(DAY, date(2026, 10, 15)),
|
||||||
.unwrap()
|
Err(IdxError::ParseError(_))
|
||||||
.is_empty()
|
));
|
||||||
);
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse_stock_summary("<html>Just a moment...</html>", date(2026, 10, 16)),
|
parse_stock_summary("<html>Just a moment...</html>", date(2026, 10, 16)),
|
||||||
Err(IdxError::ParseError(_))
|
Err(IdxError::ParseError(_))
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,11 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
if html.to_ascii_lowercase().contains("invalid stock code") {
|
if html.to_ascii_lowercase().contains("invalid stock code") {
|
||||||
return Ok(IpotPage::Invalid);
|
return Ok(IpotPage::Invalid);
|
||||||
}
|
}
|
||||||
let (Some(body_start), Some(body_end)) = (html.find("<tbody>"), html.find("</tbody>")) else {
|
let Some((body_start, body_end)) = html.find("<tbody>").and_then(|start| {
|
||||||
|
html[start..]
|
||||||
|
.find("</tbody>")
|
||||||
|
.map(|len| (start, start + len))
|
||||||
|
}) else {
|
||||||
return Err(IdxError::ParseError(
|
return Err(IdxError::ParseError(
|
||||||
"ipot broker summary: no table in the page".to_string(),
|
"ipot broker summary: no table in the page".to_string(),
|
||||||
));
|
));
|
||||||
|
|
@ -168,17 +172,37 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
value
|
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("</tr>").enumerate() {
|
for (index, row) in html[body_start..body_end].split("</tr>").enumerate() {
|
||||||
let row = cells(row);
|
let row = cells(row);
|
||||||
if row.len() < 9 {
|
if row.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rank = row[4].text.parse::<u32>().unwrap_or(index as u32 + 1);
|
let rank = index as u32 + 1;
|
||||||
for (side, at) in [(&mut raw.buyers, 0), (&mut raw.sellers, 5)] {
|
if row.len() != 9 || row[4].text.parse::<u32>().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();
|
let code = row[at].text.to_ascii_uppercase();
|
||||||
if code.is_empty() {
|
if code.is_empty() {
|
||||||
|
*ended = true;
|
||||||
continue;
|
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()) {
|
if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||||
return Err(IdxError::ParseError(format!(
|
return Err(IdxError::ParseError(format!(
|
||||||
"ipot broker summary: unexpected broker code {code:?}"
|
"ipot broker summary: unexpected broker code {code:?}"
|
||||||
|
|
@ -314,4 +338,23 @@ mod tests {
|
||||||
Err(IdxError::ParseError(_))
|
Err(IdxError::ParseError(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_broker_summary_rejects_rows_that_would_shift_ranks() {
|
||||||
|
let first_cell = "<td align=\"center\"><span class=\"text-local\">MG</span></td>";
|
||||||
|
let short_row = DAY.replacen(first_cell, "", 1);
|
||||||
|
let skipped_rank = DAY.replacen(
|
||||||
|
"<td align=\"center\">1</td>",
|
||||||
|
"<td align=\"center\">2</td>",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let gap = DAY.replacen(">XL<", "><", 1);
|
||||||
|
for page in [short_row, skipped_rank, gap, "</tbody><tbody>".to_string()] {
|
||||||
|
assert!(
|
||||||
|
matches!(parse_broker_summary(&page), Err(IdxError::ParseError(_))),
|
||||||
|
"{}",
|
||||||
|
&page[..page.len().min(80)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,11 @@ impl Broker<'_> {
|
||||||
source: SOURCE,
|
source: SOURCE,
|
||||||
},
|
},
|
||||||
policy,
|
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)),
|
|raw| Ok(summary_from_raw(&resolved, query, raw)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +211,25 @@ pub(crate) fn summary_from_raw(
|
||||||
summary
|
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<BrokerSummaryRaw, IdxError> {
|
||||||
|
if raw.from == Some(query.from) && raw.to == Some(query.to) {
|
||||||
|
return Ok(raw);
|
||||||
|
}
|
||||||
|
let echoed = |date: Option<NaiveDate>| 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> {
|
fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> {
|
||||||
if query.from > query.to {
|
if query.from > query.to {
|
||||||
return Err(IdxError::InvalidInput(format!(
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ impl Flow<'_> {
|
||||||
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
|
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
|
||||||
Err(err) => return Err(err),
|
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 {
|
match fetch.day {
|
||||||
Day::NoSession => continue,
|
Day::NoSession => continue,
|
||||||
Day::Absent => {}
|
Day::Absent => {}
|
||||||
|
|
@ -116,8 +119,6 @@ impl Flow<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sessions += 1;
|
sessions += 1;
|
||||||
fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at)));
|
|
||||||
stale |= fetch.stale;
|
|
||||||
}
|
}
|
||||||
if sessions == 0 {
|
if sessions == 0 {
|
||||||
return Err(if policy == CachePolicy::Offline {
|
return Err(if policy == CachePolicy::Offline {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue