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:
hermes 2026-10-18 11:48:00 +00:00
commit d82f7c6be9
6 changed files with 137 additions and 19 deletions

View file

@ -38,11 +38,13 @@ fn whole(value: Option<f64>) -> Option<i64> {
/// 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<Vec<StockDay>, 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<StockDay> = response
.data
.into_iter()
.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_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("<html>Just a moment...</html>", date(2026, 10, 16)),
Err(IdxError::ParseError(_))

View file

@ -151,7 +151,11 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
if html.to_ascii_lowercase().contains("invalid stock code") {
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(
"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
})
};
// 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() {
let row = cells(row);
if row.len() < 9 {
if row.is_empty() {
continue;
}
let rank = row[4].text.parse::<u32>().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::<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();
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 = "<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)]
);
}
}
}