mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
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
This commit is contained in:
parent
018d9ba936
commit
1cd8a4b191
5 changed files with 107 additions and 4 deletions
|
|
@ -22,6 +22,12 @@ history lives in git.
|
||||||
RSS per ticker, and IDX disclosures and exchange news through a
|
RSS per ticker, and IDX disclosures and exchange news through a
|
||||||
maintainer-published feed
|
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
|
## Workflow
|
||||||
- `watchlist`: named symbol sets usable by other commands
|
- `watchlist`: named symbol sets usable by other commands
|
||||||
- `alerts`: price alerts with a daemon
|
- `alerts`: price alerts with a daemon
|
||||||
|
|
|
||||||
|
|
@ -1539,6 +1539,55 @@ fn ownership_import_file_xlsx_supports_ticker_and_releases() {
|
||||||
assert_eq!(releases[0]["row_count"].as_u64(), Some(17));
|
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]
|
#[test]
|
||||||
fn ownership_import_file_rejects_above5_xlsx() {
|
fn ownership_import_file_rejects_above5_xlsx() {
|
||||||
let root = test_env_dir("ownership-import-xlsx-above5");
|
let root = test_env_dir("ownership-import-xlsx-above5");
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
//! Since June 2026 IDX publishes the monthly above-1% holder register as an
|
//! 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
|
//! XLSX file on the "Data Kepemilikan Saham" page instead of a PDF
|
||||||
//! announcement. The workbook has one sheet: a disclaimer block, a `[PUBLIC]`
|
//! announcement. The workbook has one sheet: a disclaimer block, a `[PUBLIC]`
|
||||||
//! marker, then a fixed 12-column table. Only that exact header is accepted,
|
//! marker, then a 12-column table (13 since October 2026, see
|
||||||
//! so the daily above-5% workbook and the investor-type / classification
|
//! `IGNORED_HEADER`). Only those columns are accepted, so the daily above-5%
|
||||||
//! workbooks published on the same page are rejected instead of mis-imported.
|
//! workbook and the investor-type / classification workbooks published on the
|
||||||
|
//! same page are rejected instead of mis-imported.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
@ -35,6 +36,12 @@ const EXPECTED_HEADER: [&str; 12] = [
|
||||||
"PERCENTAGE",
|
"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.
|
/// Rows above this line are disclaimer text; the header must appear before it.
|
||||||
const MAX_HEADER_SEARCH_ROWS: usize = 50;
|
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" {
|
if label == "INVESTOR_TYPE" {
|
||||||
label = "INVESTOR_CLASSIFICATION".to_string();
|
label = "INVESTOR_CLASSIFICATION".to_string();
|
||||||
}
|
}
|
||||||
|
if IGNORED_HEADER.contains(&label.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let expected = EXPECTED_HEADER.iter().find(|name| **name == label)?;
|
let expected = EXPECTED_HEADER.iter().find(|name| **name == label)?;
|
||||||
found.insert(expected, column.clone());
|
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 {
|
fn header_row(labels: &[&str]) -> SheetRow {
|
||||||
sheet_row(1, labels)
|
sheet_row(1, labels)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx
vendored
Normal file
BIN
crates/idx-core/tests/fixtures/ksei_above1_20261030_excerpt.xlsx
vendored
Normal file
Binary file not shown.
|
|
@ -197,7 +197,8 @@ payload, `%PDF`, or `PK` zip magic). `IDX_CURL_IMPERSONATE_BIN` pins a single pr
|
||||||
|
|
||||||
### Parser Pipeline
|
### 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)
|
→ KseiHoldingDraft (Excel serial dates, numeric shares, percent → bps)
|
||||||
→ SQLite INSERT (within transaction)
|
→ SQLite INSERT (within transaction)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue