Merge pull request #79 from 0xrsydn/fix/ownership-xlsx-controller-column

fix(ownership): accept the register's new CONTROLLER_OR_AFFILIATE column
This commit is contained in:
dogfiles 2026-11-04 20:24:51 +07:00 • committed by GitHub
commit 1e19ae1bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 107 additions and 4 deletions

View file

@ -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

View file

@ -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");

View file

@ -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<HashMap<&'static str, String>> {
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)
}

Binary file not shown.

View file

@ -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)