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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
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
|
||||
```
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue