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 0000000..53ef3ce Binary files /dev/null and b/crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx differ 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)