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

@ -17,13 +17,13 @@ use crate::output::table::format_idr;
use crate::ownership::types::{
ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource,
};
use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot};
use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx};
use crate::runtime;
#[derive(Debug, Args)]
#[command(
about = "Ownership intelligence (KSEI + Bing)",
long_about = "Ownership intelligence (KSEI + Bing).\n\nPreferred bootstrap path:\n 1. Run `idx ownership sync` to install or refresh a maintained SQLite snapshot.\n 2. If no snapshot manifest is available, run `idx ownership discover` and then `idx ownership import --url <pdf-url>`.\n 3. Local `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows.",
long_about = "Ownership intelligence (KSEI + Bing).\n\nPreferred bootstrap path:\n 1. Run `idx ownership sync` to install or refresh a maintained SQLite snapshot.\n 2. If no snapshot manifest is available, run `idx ownership discover` and then `idx ownership import --url <pdf-url>`.\n 3. Local `.xlsx`, `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows.",
after_help = "Examples:\n idx ownership sync\n idx ownership sync --manifest /path/to/ownership-snapshot-manifest.json\n idx ownership discover --limit 1\n idx ownership import --url <pdf-url>\n idx ownership releases"
)]
pub struct OwnershipCmd {
@ -41,7 +41,7 @@ pub enum OwnershipCommand {
#[command(
about = "Import ownership data directly from source files",
long_about = "Import ownership data directly from source files.\n\nPrefer `idx ownership sync` for normal bootstrap/update flows. Use `import` when you need a direct PDF import from IDX discovery output, a local PDF, or a local KSEI archive fallback file.",
after_help = "Examples:\n idx ownership import --url <pdf-url-from-discover>\n idx ownership import --file /path/to/ksei.pdf\n idx ownership import --file /path/to/BalanceposEfek20260227.zip"
after_help = "Examples:\n idx ownership import --url <pdf-url-from-discover>\n idx ownership import --file /path/to/peng-2026-08-00017-satu-persen.xlsx\n idx ownership import --file /path/to/ksei.pdf\n idx ownership import --file /path/to/BalanceposEfek20260227.zip"
)]
Import(ImportArgs),
#[command(
@ -87,7 +87,7 @@ pub struct ImportArgs {
/// Direct URL to a remote ownership PDF, typically from `ownership discover`.
#[arg(long)]
pub url: Option<String>,
/// Path to a local ownership file: PDF (primary), ZIP/TXT archive (fallback).
/// Path to a local ownership file: above-1% XLSX or PDF (primary), ZIP/TXT archive (fallback).
#[arg(long)]
pub file: Option<PathBuf>,
/// Fetch Bing institutional data for these symbols.
@ -836,6 +836,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
drafts
}
ImportInputFormat::Archive => archive::parse_balancepos_file(&import_input.import_path)?,
ImportInputFormat::Xlsx => xlsx::parse_above1_xlsx_file(&import_input.import_path)?,
};
let mut holdings = Vec::with_capacity(drafts.len());
@ -977,6 +978,7 @@ struct OwnershipImportResult {
enum ImportInputFormat {
Pdf,
Archive,
Xlsx,
}
fn resolve_import_input(args: &ImportArgs) -> Result<Option<ResolvedImportInput>, IdxError> {
@ -1018,11 +1020,12 @@ fn detect_local_import_format(path: &Path) -> Result<ImportInputFormat, IdxError
.as_deref()
{
Some("pdf") => Ok(ImportInputFormat::Pdf),
Some("xlsx") if xlsx::supports_xlsx_file(path) => Ok(ImportInputFormat::Xlsx),
Some("zip") | Some("txt") if archive::supports_local_archive_file(path) => {
Ok(ImportInputFormat::Archive)
}
_ => Err(IdxError::InvalidInput(format!(
"unsupported local ownership file {}; expected a .pdf, .zip, or .txt input",
"unsupported local ownership file {}; expected a .xlsx, .pdf, .zip, or .txt input",
path.display()
))),
}