diff --git a/Cargo.lock b/Cargo.lock index 8aa291f..9c0c2af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "idx-cli" -version = "0.2.2" +version = "0.2.3" dependencies = [ "assert_cmd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 26bdb22..976eb68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "idx-cli" -version = "0.2.2" +version = "0.2.3" edition = "2024" rust-version = "1.85" description = "CLI tool for Indonesian stock market (IDX) analysis" diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 790f851..e777f0d 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -186,13 +186,7 @@ fn normalize_percentish(value: Option) -> Option { } fn sanitize_current_ratio(value: Option) -> Option { - value.and_then(|number| { - if !number.is_finite() || number < 0.01 { - None - } else { - Some(number) - } - }) + value.filter(|number| number.is_finite() && *number >= 0.01) } fn round_price(value: f64) -> i64 { diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 217ac3c..65d364a 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -983,10 +983,10 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) { }); } "high-volume" => { - quotes.sort_by(|a, b| b.volume.cmp(&a.volume)); + quotes.sort_by_key(|quote| std::cmp::Reverse(quote.volume)); } "large-cap" => { - quotes.sort_by(|a, b| b.market_cap.unwrap_or(0).cmp(&a.market_cap.unwrap_or(0))); + quotes.sort_by_key(|quote| std::cmp::Reverse(quote.market_cap.unwrap_or(0))); } _ => {} } diff --git a/src/ownership/db.rs b/src/ownership/db.rs index a35073d..af4b936 100644 --- a/src/ownership/db.rs +++ b/src/ownership/db.rs @@ -196,10 +196,7 @@ fn insert_ksei_holdings_rows( ) .map_err(|e| IdxError::DatabaseError(e.to_string())); - match changed { - Ok(n) => inserted += n, - Err(err) => return Err(err), - } + inserted += changed?; } Ok(inserted) diff --git a/src/ownership/graph.rs b/src/ownership/graph.rs index 5c18e48..981e5cc 100644 --- a/src/ownership/graph.rs +++ b/src/ownership/graph.rs @@ -122,7 +122,7 @@ pub fn format_graph_text(nodes: &[GraphNode], edges: &[GraphEdge]) -> String { out.push_str(&format!("nodes: {} edges: {}\n", nodes.len(), edges.len())); for (ticker_id, mut rels) in ticker_to_entities { - rels.sort_by(|a, b| b.percentage_bps.cmp(&a.percentage_bps)); + rels.sort_by_key(|edge| std::cmp::Reverse(edge.percentage_bps)); let ticker_label = labels.get(ticker_id).map(|v| v.0).unwrap_or(ticker_id); out.push_str(&format!("\n{ticker_label} [TICKER]\n")); diff --git a/src/ownership/parser.rs b/src/ownership/parser.rs index 4239247..6e117f9 100644 --- a/src/ownership/parser.rs +++ b/src/ownership/parser.rs @@ -17,6 +17,7 @@ const HEADER_LABELS: &[&str] = &[ "ISSUERNAME", "INVESTORNAME", "INVESTORTYPE", + "INVESTORCLASSIFICATION", "LOCALFOREIGN", "NATIONALITY", "DOMICILE", @@ -31,14 +32,15 @@ const HOLDER_REGISTER_SCHEMA_MARKERS: &[&str] = &[ "TEXT=\"SHARE_CODE\"", "TEXT=\"INVESTOR_NAME\"", "TEXT=\"INVESTOR_TYPE\"", + "TEXT=\"INVESTOR_CLASSIFICATION\"", "TEXT=\"LOCAL_FOREIGN\"", "TEXT=\"TOTAL_HOLDING_SHARES\"", "TEXT=\"PERCENTAGE\"", ]; const ANNOUNCEMENT_WRAPPER_SCHEMA_MARKERS: &[&str] = - &["TEXT=\"PENGUMUMAN\"", "PT BURSA EFEK INDONESIA (BEI)"]; + &["TEXT=\"PENGUMUMAN\"", "PT_BURSA_EFEK_INDONESIA_(BEI)"]; const ABOVE_FIVE_SCHEMA_MARKERS: &[&str] = - &["TEXT=\"INVS\"", "REKENING TAMPUNGAN KSEI", "CLOSED MEMBER-"]; + &["TEXT=\"INVS\"", "REKENING_TAMPUNGAN_KSEI", "CLOSED_MEMBER-"]; const INVESTOR_TYPE_SCHEMA_MARKERS: &[&str] = &[ "TEXT=\"STOCK_CODE\"", "TEXT=\"NUMBER_OF_SHARES\"", @@ -103,7 +105,7 @@ pub fn extract_pdf_stext(path: &Path) -> Result { /// Classify a PDF schema from mutool stext XML before the row parser runs. pub fn classify_stext_xml(xml: &str) -> OwnershipPdfSchema { - let normalized = xml.to_ascii_uppercase(); + let normalized = normalize_stext_for_classification(xml); if count_schema_markers(&normalized, HOLDER_REGISTER_SCHEMA_MARKERS) >= 5 { return OwnershipPdfSchema::HolderRegister; @@ -121,6 +123,41 @@ pub fn classify_stext_xml(xml: &str) -> OwnershipPdfSchema { OwnershipPdfSchema::Unknown } +/// Normalize stext XML for schema classification. +/// +/// Newer KSEI PDFs use spaces in header text (e.g. `SHARE CODE` instead of +/// `SHARE_CODE`, `INVESTOR CLASSIFICATION` instead of `INVESTOR_TYPE`). The +/// schema markers use the underscored form, so we replace spaces with +/// underscores inside `text="..."` attribute values to match both layouts. +fn normalize_stext_for_classification(xml: &str) -> String { + let upper = xml.to_ascii_uppercase(); + let mut result = String::with_capacity(upper.len()); + let bytes = upper.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if bytes[i..].starts_with(b"TEXT=\"") { + result.push_str("TEXT=\""); + i += 6; // skip past TEXT=" + // Collect everything until the closing quote. + while i < bytes.len() && bytes[i] != b'"' { + let ch = bytes[i] as char; + result.push(if ch == ' ' { '_' } else { ch }); + i += 1; + } + if i < bytes.len() { + result.push('"'); + i += 1; // skip closing quote + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + + result +} + /// Parse a KSEI ownership PDF into raw rows. /// Shells out to `mutool` for XML extraction, classifies the schema, /// and only parses the supported holder-register layout. @@ -684,6 +721,12 @@ mod tests { ); } + #[test] + fn classify_stext_xml_detects_spaced_holder_register_schema() { + let xml = include_str!("../../tests/fixtures/ksei_above1_spaced_stext_excerpt.xml"); + assert_eq!(classify_stext_xml(xml), OwnershipPdfSchema::HolderRegister); + } + #[test] fn classify_stext_xml_detects_legacy_above5_schema() { let xml = include_str!("../../tests/fixtures/ksei_above5_stext_excerpt.xml"); diff --git a/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml b/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml new file mode 100644 index 0000000..e75c5f7 --- /dev/null +++ b/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +