From 1cd8a4b191beaae1a7857ff3279bcb5b4dad08f7 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 3 Nov 2026 15:44:50 +0700 Subject: [PATCH] fix(ownership): accept the register's new CONTROLLER_OR_AFFILIATE column IDX's above-1% register as of 30 Oct 2026 (peng-2026-10-00029) inserts a CONTROLLER_OR_AFFILIATE column after INVESTOR_CLASSIFICATION. The header check rejected any column it did not know, so `ownership import` failed with PARSEERROR and `releases`/`ticker` had nothing to show. Accept that one column by name and skip it; any other unknown column is still rejected, so the above-5% and investor-type workbooks stay out. Storing the flag is a follow-up (ROADMAP). The fixture is an 11-row excerpt of the real October workbook (disclaimer rows, header, BBCA and ZYRX). The full file imports 7,149 rows across 961 tickers; live ownership smoke: 4 passed, 0 failed, no 403. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- ROADMAP.md | 6 ++ crates/idx-cli/tests/cli.rs | 49 ++++++++++++++++ crates/idx-core/src/ownership/xlsx.rs | 53 +++++++++++++++++- .../ksei_above1_20261030_excerpt.xlsx | Bin 0 -> 15163 bytes docs/ARCHITECTURE.md | 3 +- 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx diff --git a/ROADMAP.md b/ROADMAP.md index 56cf036..b064e35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,12 @@ history lives in git. RSS per ticker, and IDX disclosures and exchange news through a maintainer-published feed +## Ownership +- Store the register's `CONTROLLER_OR_AFFILIATE` flag (from the 30 Oct 2026 + report; about 1,200 controller and 950 affiliate rows) and show it in + `ticker`, `entity` and `changes`, so a controller's moves stand apart. + Needs a DB column, the snapshot format, and blank for older reports. + ## Workflow - `watchlist`: named symbol sets usable by other commands - `alerts`: price alerts with a daemon diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index abdade7..a74683e 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -1539,6 +1539,55 @@ fn ownership_import_file_xlsx_supports_ticker_and_releases() { assert_eq!(releases[0]["row_count"].as_u64(), Some(17)); } +/// IDX added a CONTROLLER_OR_AFFILIATE column to the register from October +/// 2026; it imports alongside an earlier 12-column month. +#[test] +fn ownership_import_file_xlsx_with_controller_column() { + let root = test_env_dir("ownership-import-xlsx-controller"); + let db_path = root.join("ownership.db"); + bin_with_root(&root) + .args([ + "config", + "set", + "ownership.db_path", + db_path.to_str().unwrap(), + ]) + .assert() + .success(); + + for (name, bytes) in [ + ( + "peng-2026-08-00017-satu-persen.xlsx", + &include_bytes!("../../idx-core/tests/fixtures/ksei_above1_20260831_excerpt.xlsx")[..], + ), + ( + "peng-2026-10-00029-satu-persen.xlsx", + &include_bytes!("../../idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx")[..], + ), + ] { + let path = root.join(name); + fs::write(&path, bytes).expect("write xlsx fixture"); + run_success_json(bin_with_root(&root).args([ + "-o", + "json", + "ownership", + "import", + "--file", + path.to_str().unwrap(), + ])); + } + + let ticker = + run_success_json(bin_with_root(&root).args(["-o", "json", "ownership", "ticker", "ZYRX"])); + assert_eq!(ticker["ksei_as_of"], "2026-10-30"); + assert_eq!(ticker["holders"].as_array().unwrap().len(), 7); + let releases = + run_success_json(bin_with_root(&root).args(["-o", "json", "ownership", "releases"])); + assert_eq!(releases.as_array().unwrap().len(), 2); + assert_eq!(releases[0]["as_of_date"], "2026-10-30"); + assert_eq!(releases[0]["row_count"].as_u64(), Some(11)); +} + #[test] fn ownership_import_file_rejects_above5_xlsx() { let root = test_env_dir("ownership-import-xlsx-above5"); diff --git a/crates/idx-core/src/ownership/xlsx.rs b/crates/idx-core/src/ownership/xlsx.rs index 58751f8..f144c7d 100644 --- a/crates/idx-core/src/ownership/xlsx.rs +++ b/crates/idx-core/src/ownership/xlsx.rs @@ -3,9 +3,10 @@ //! Since June 2026 IDX publishes the monthly above-1% holder register as an //! XLSX file on the "Data Kepemilikan Saham" page instead of a PDF //! announcement. The workbook has one sheet: a disclaimer block, a `[PUBLIC]` -//! marker, then a fixed 12-column table. Only that exact header is accepted, -//! so the daily above-5% workbook and the investor-type / classification -//! workbooks published on the same page are rejected instead of mis-imported. +//! marker, then a 12-column table (13 since October 2026, see +//! `IGNORED_HEADER`). Only those columns are accepted, so the daily above-5% +//! workbook and the investor-type / classification workbooks published on the +//! same page are rejected instead of mis-imported. use std::collections::HashMap; use std::fs; @@ -35,6 +36,12 @@ const EXPECTED_HEADER: [&str; 12] = [ "PERCENTAGE", ]; +/// Columns IDX has added to the register that the import does not store yet. +/// The October 2026 register (as of 30 Oct) inserted CONTROLLER_OR_AFFILIATE +/// (`CONTROLLER`, `AFFILIATE OF THE CONTROLLER`, or blank) after +/// INVESTOR_CLASSIFICATION. Any other unknown column still fails the header. +const IGNORED_HEADER: [&str; 1] = ["CONTROLLER_OR_AFFILIATE"]; + /// Rows above this line are disclaimer text; the header must appear before it. const MAX_HEADER_SEARCH_ROWS: usize = 50; @@ -400,6 +407,9 @@ fn header_columns(row: &SheetRow) -> Option> { if label == "INVESTOR_TYPE" { label = "INVESTOR_CLASSIFICATION".to_string(); } + if IGNORED_HEADER.contains(&label.as_str()) { + continue; + } let expected = EXPECTED_HEADER.iter().find(|name| **name == label)?; found.insert(expected, column.clone()); } @@ -505,6 +515,43 @@ mod tests { ); } + #[test] + fn parses_october_2026_register_with_controller_column() { + let drafts = parse_above1_xlsx_bytes(include_bytes!( + "../../tests/fixtures/ksei_above1_20261030_excerpt.xlsx" + )) + .expect("October 2026 register with CONTROLLER_OR_AFFILIATE parses"); + assert_eq!(drafts.len(), 11); + assert!( + drafts + .iter() + .all(|d| d.report_date == NaiveDate::from_ymd_opt(2026, 10, 30).unwrap()) + ); + + // The new column sits at F, so every later column moved one to the + // right; fields are still read by name. + let timothy = drafts + .iter() + .find(|d| d.ticker_code == "ZYRX" && d.raw_investor_name == "TIMOTHY SIDDIK SHU") + .expect("ZYRX controller present"); + assert_eq!(timothy.total_shares, 752_076_700); + assert_eq!(timothy.percentage_bps, 5641); + assert_eq!(timothy.nationality.as_deref(), Some("INDONESIAN")); + assert!(timothy.locality.is_some()); + } + + #[test] + fn controller_column_is_optional_but_other_extras_are_not() { + let mut labels = EXPECTED_HEADER.to_vec(); + labels.insert(5, "CONTROLLER_OR_AFFILIATE"); + let columns = header_columns(&header_row(&labels)).expect("13-column header accepted"); + assert_eq!(columns["LOCAL_FOREIGN"], "G"); + assert_eq!(columns["PERCENTAGE"], "M"); + + labels[5] = "PENGENDALI"; + assert!(header_columns(&header_row(&labels)).is_none()); + } + fn header_row(labels: &[&str]) -> SheetRow { sheet_row(1, labels) } diff --git a/crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx b/crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..53ef3cebbe794276f2be6f29f7589e08eb7e04ad GIT binary patch literal 15163 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

-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 literal 0 HcmV?d00001 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae55edd..e9f3215 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -197,7 +197,8 @@ payload, `%PDF`, or `PK` zip magic). `IDX_CURL_IMPERSONATE_BIN` pins a single pr ### Parser Pipeline ``` -KSEI XLSX → zip + quick-xml (sharedStrings + sheet1) → exact 12-column header check +KSEI XLSX → zip + quick-xml (sharedStrings + sheet1) → header check by name (12 columns; + October 2026 added CONTROLLER_OR_AFFILIATE, accepted but not stored yet) → KseiHoldingDraft (Excel serial dates, numeric shares, percent → bps) → SQLite INSERT (within transaction)