feat(ownership): import IDX above-1% XLSX holder register

Since June 2026 IDX publishes the monthly "Pemegang Saham di Atas 1%"
list as an XLSX workbook on the Data Kepemilikan Saham page instead of
a PDF announcement. Add a parser for it and accept `.xlsx` in
`ownership import --file`.

- src/ownership/xlsx.rs: zip + quick-xml reader (shared strings incl.
  rich text, sheet cells), exact 12-column header match so the daily
  above-5% and investor-type/classification workbooks are rejected,
  Excel serial dates, numeric shares, percent -> bps with float-noise
  rounding. Builds KseiHoldingDraft directly and reuses the entity
  normalizers, so investor types stay uppercase like the PDF path.
- Fixtures: trimmed excerpts of the real 2026-08-31 above-1% and
  2026-09-23 above-5% workbooks.
- Unit + CLI tests: import, ticker, releases, above-5% rejection.
- Docs: ARCHITECTURE parser pipeline, AGENTS ownership inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a5Cv5Z1NzdYKgKnTiAF4E
This commit is contained in:
hermes 2026-09-24 20:48:07 +00:00
commit 2c85acb829
9 changed files with 539 additions and 10 deletions

View file

@ -407,7 +407,7 @@ fn ownership_import_help_mentions_sync_preference_and_fallback_files() {
.success()
.stdout(predicate::str::contains("Prefer `idx ownership sync`"))
.stdout(predicate::str::contains(
"PDF (primary), ZIP/TXT archive (fallback)",
"above-1% XLSX or PDF (primary), ZIP/TXT archive (fallback)",
))
.stdout(predicate::str::contains(
"idx ownership import --file /path/to/BalanceposEfek20260227.zip",
@ -1524,6 +1524,77 @@ fn ownership_import_url_duplicate_release_is_skipped() {
.stdout(predicate::str::contains("Release already imported"));
}
#[test]
fn ownership_import_file_xlsx_supports_ticker_and_releases() {
let root = test_env_dir("ownership-import-xlsx");
let db_path = root.join("ownership.db");
let xlsx_path = root.join("peng-2026-08-00017-satu-persen.xlsx");
fs::write(
&xlsx_path,
include_bytes!("fixtures/ksei_above1_20260831_excerpt.xlsx"),
)
.expect("write xlsx fixture");
bin_with_root(&root)
.args([
"config",
"set",
"ownership.db_path",
db_path.to_str().unwrap(),
])
.assert()
.success();
bin_with_root(&root)
.args(["ownership", "import", "--file", xlsx_path.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("(as of 2026-08-31)"));
let output = bin_with_root(&root)
.args(["-o", "json", "ownership", "ticker", "BBCA"])
.output()
.expect("ownership ticker json output");
assert!(output.status.success());
let body = String::from_utf8_lossy(&output.stdout);
assert!(body.contains("DWIMURIA INVESTAMA ANDALAN"), "{body}");
let output = bin_with_root(&root)
.args(["-o", "json", "ownership", "releases"])
.output()
.expect("ownership releases json output");
let releases: Value = serde_json::from_slice(&output.stdout).expect("parse releases json");
assert_eq!(releases[0]["as_of_date"], "2026-08-31");
}
#[test]
fn ownership_import_file_rejects_above5_xlsx() {
let root = test_env_dir("ownership-import-xlsx-above5");
let db_path = root.join("ownership.db");
let xlsx_path = root.join("peng-2026-09-23-00080-lima-persen.xlsx");
fs::write(
&xlsx_path,
include_bytes!("fixtures/ksei_above5_20260923_excerpt.xlsx"),
)
.expect("write xlsx fixture");
bin_with_root(&root)
.args([
"config",
"set",
"ownership.db_path",
db_path.to_str().unwrap(),
])
.assert()
.success();
bin_with_root(&root)
.args(["ownership", "import", "--file", xlsx_path.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("above-1% holder register layout"));
}
#[test]
fn ownership_import_force_reimports_existing_release() {
let root = test_env_dir("ownership-import-force");