fix(core): version the broker summary cache bucket

The cache stores normalized summaries, so one written before buy_value and
sell_value existed read back without them: bandar gave null readings (the
table said "No trades.") for a week, and an investor-filtered entry lacked
its totals. Summaries now cache under ipot-broker-v2, so an old-shape entry
is never served; offline it is a cache miss, online it is fetched again. A
CLI test seeds an old entry and checks both, in JSON and table output.

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 06:40:15 +00:00
commit b137dbf540
2 changed files with 73 additions and 2 deletions

View file

@ -2933,3 +2933,69 @@ fn bandar_reads_one_investor_side_against_its_own_trading() {
"{table}" "{table}"
); );
} }
#[test]
fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() {
// A summary cached before buy_value/sell_value existed: fresh for a
// week, with trades, but no denominators for a reading.
let root = test_env_dir("bandar-legacy-cache");
let bucket = root.join("cache").join("idx").join("ipot-broker");
fs::create_dir_all(&bucket).unwrap();
let flow = |code: &str, value: i64| {
serde_json::json!({"rank": 1, "code": code, "name": null, "kind": null,
"lots": value / 100, "value": value, "average_price": 1})
};
let legacy = serde_json::json!({
"fetched_at": chrono::Utc::now().to_rfc3339(),
"ttl_secs": 7 * 24 * 3600,
"schema_version": 1,
"data": {
"symbol": "BBCA.JK", "from": "2026-10-16", "to": "2026-10-16",
"investor": "all", "board": "all",
"buyers": [flow("AA", 60_000)], "sellers": [flow("BB", 40_000)],
"total_value": 100_000, "total_lots": 1_000, "average_price": 100,
"foreign_net_value": null, "rounded": false
}
});
fs::write(
bucket.join("BBCA-2026-10-16-2026-10-16-all-all.json"),
legacy.to_string(),
)
.unwrap();
// Offline, the old entry is not served as a complete summary.
run_error_json(
mock_bin_with_root(&root).args([
"-o",
"json",
"--offline",
"stocks",
"bandar",
"BBCA",
"--date",
"2026-10-16",
]),
"CACHEMISS",
);
// Online, the summary is fetched again and read in full.
let reports = run_success_json(mock_bin_with_root(&root).args([
"-o",
"json",
"stocks",
"bandar",
"BBCA",
"--date",
"2026-10-16",
]));
assert_eq!(reports[0]["imbalance_bps"], -2425);
let table = run_success_stdout(mock_bin_with_root(&root).args([
"--no-color",
"stocks",
"bandar",
"BBCA",
"--date",
"2026-10-16",
]));
assert!(!table.contains("No trades."), "{table}");
assert!(table.contains("big distribution"), "{table}");
}

View file

@ -15,6 +15,11 @@ use crate::error::IdxError;
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary}; use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary};
const SOURCE: &str = "ipot"; const SOURCE: &str = "ipot";
/// Cache bucket for normalized summaries, versioned with their shape: a
/// summary cached before `buy_value`/`sell_value` existed would read back
/// without them (and an investor-filtered one without its totals), so a new
/// shape gets a new bucket rather than serving such entries.
const CACHE_BUCKET: &str = "ipot-broker-v2";
/// Longest range one summary may cover. /// Longest range one summary may cover.
pub const MAX_RANGE_DAYS: i64 = 366; pub const MAX_RANGE_DAYS: i64 = 366;
/// Calendar days searched back for the latest session with trades. /// Calendar days searched back for the latest session with trades.
@ -98,7 +103,7 @@ impl Broker<'_> {
cached_fetch( cached_fetch(
&self.idx.cache, &self.idx.cache,
CacheSpec { CacheSpec {
bucket: "ipot-broker", bucket: CACHE_BUCKET,
key: &key, key: &key,
ttl_secs: if query.to < today { ttl_secs: if query.to < today {
TTL_CLOSED_SECS TTL_CLOSED_SECS
@ -146,7 +151,7 @@ impl Broker<'_> {
} }
if policy == CachePolicy::Offline { if policy == CachePolicy::Offline {
return Err(IdxError::CacheMiss(format!( return Err(IdxError::CacheMiss(format!(
"ipot-broker/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)" "{CACHE_BUCKET}/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)"
))); )));
} }
Err(IdxError::NoMarketData(format!( Err(IdxError::NoMarketData(format!(