From 4cf86ce1ff40c8f2729638d1f9b94d1525dacf16 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 17 Oct 2026 12:18:20 +0000 Subject: [PATCH] fix(ownership): compare each date's own rows, even in a mixed-date file A KSEI file can carry rows from several report dates; its release is dated by the latest. Filtering on the release alone pulled January rows into a February comparison (Pullfrog review on #53). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/ownership/db.rs | 67 +++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/idx-core/src/ownership/db.rs b/crates/idx-core/src/ownership/db.rs index 7668c6b..0691565 100644 --- a/crates/idx-core/src/ownership/db.rs +++ b/crates/idx-core/src/ownership/db.rs @@ -909,11 +909,17 @@ pub fn query_bing_flow( /// The release used for a report date: the latest imported one, as /// `latest_ksei_release` picks, so a corrected file supersedes the original. +/// The latest import holding rows reported on `date`. A file can carry rows +/// from several report dates, so a release is matched by its rows rather +/// than by its `as_of_date` (the latest date in it). fn release_for_date(conn: &Connection, date: &str) -> Result, IdxError> { conn.query_row( - "SELECT sha256 FROM ownership_releases - WHERE as_of_date = ?1 - ORDER BY imported_at DESC, id DESC + "SELECT r.sha256 FROM ownership_releases r + WHERE EXISTS ( + SELECT 1 FROM ksei_holdings k + WHERE k.release_sha256 = r.sha256 AND k.report_date = ?1 + ) + ORDER BY r.imported_at DESC, r.id DESC LIMIT 1", params![date], |row| row.get::<_, String>(0), @@ -927,7 +933,7 @@ fn release_for_date(conn: &Connection, date: &str) -> Result, Idx /// Compare two KSEI releases by date and return ownership changes. /// Finds: new holders, exited holders, percentage increases/decreases. -/// Each date reads one release, and a holder's accounts in a ticker are +/// Each date reads its rows from one release, and a holder's accounts in a ticker are /// summed, so an investor with two accounts compares by its total stake. pub fn query_changes( conn: &Connection, @@ -957,7 +963,7 @@ pub fn query_changes( FROM ksei_holdings k JOIN tickers t ON t.id = k.ticker_id LEFT JOIN entities e ON e.id = k.entity_id - WHERE k.release_sha256 = ?1 + WHERE k.release_sha256 = ?1 AND k.report_date = ?3 GROUP BY t.code, holder_key ), to_snapshot AS ( @@ -972,7 +978,7 @@ pub fn query_changes( FROM ksei_holdings k JOIN tickers t ON t.id = k.ticker_id LEFT JOIN entities e ON e.id = k.entity_id - WHERE k.release_sha256 = ?2 + WHERE k.release_sha256 = ?2 AND k.report_date = ?4 GROUP BY t.code, holder_key ), keys AS ( @@ -999,14 +1005,17 @@ pub fn query_changes( .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let rows = stmt - .query_map(params![from_release, to_release], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - }) + .query_map( + params![from_release, to_release, from_date, to_date], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let mut out = Vec::new(); @@ -1544,6 +1553,36 @@ mod tests { ); } + #[test] + fn changes_keep_each_date_to_its_own_rows() { + // A file with a January ALPHA row and February BETA row is the + // February release; ALPHA was never held in February, so Feb -> Mar + // must not report it as exited. + let conn = setup(); + let bbca = upsert_ticker(&conn, "BBCA", None).unwrap(); + write_release( + &conn, + "mixed", + "2026-02-27", + 1, + bbca, + &[(None, "ALPHA", 1000), (None, "BETA", 200)], + ); + conn.execute( + "UPDATE ksei_holdings SET report_date = '2026-01-30' WHERE raw_investor_name = 'ALPHA'", + [], + ) + .unwrap(); + write_release(&conn, "mar", "2026-03-31", 2, bbca, &[(None, "BETA", 200)]); + + let rows = query_changes(&conn, "2026-02-27", "2026-03-31").unwrap(); + assert!(rows.is_empty(), "{rows:?}"); + + let rows = query_changes(&conn, "2026-01-30", "2026-03-31").unwrap(); + let alpha = rows.iter().find(|r| r.entity_name == "ALPHA").unwrap(); + assert_eq!(alpha.change_type, ChangeType::Exited); + } + #[test] fn same_second_imports_resolve_to_the_later_file_everywhere() { // Original and corrected file for one date imported in the same