diff --git a/crates/idx-core/src/api/ipot/client.rs b/crates/idx-core/src/api/ipot/client.rs new file mode 100644 index 0000000..6e8135c --- /dev/null +++ b/crates/idx-core/src/api/ipot/client.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use crate::error::IdxError; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +/// Tries per request. In some stretches of seconds the server answers its +/// own nginx 404 page to a third or more of requests and then recovers, so a +/// 404 is retried (an unknown ticker is a 200, not a 404). +const ATTEMPTS: u32 = 5; +const FIRST_PAUSE: Duration = Duration::from_millis(250); + +pub(super) struct IpotClient { + agent: ureq::Agent, +} + +impl IpotClient { + pub(super) fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + // A server that stalls before sending headers must not hold a + // call (and an MCP call slot) forever. + .timeout_global(Some(Duration::from_secs(20))) + .build() + .into(); + Self { agent } + } + + pub(super) fn fetch_html(&self, url: &str) -> Result { + let mut pause = FIRST_PAUSE; + let mut last = None; + for attempt in 1..=ATTEMPTS { + if attempt > 1 { + std::thread::sleep(pause); + pause *= 2; + } + let response = self + .agent + .get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html") + .call(); + match response { + Ok(ok) => { + return ok + .into_body() + .read_to_string() + .map_err(|e| IdxError::Http(format!("ipot broker summary: {e}"))); + } + Err(ureq::Error::StatusCode(code)) if code == 404 || code >= 500 => { + last = Some(code); + } + Err(ureq::Error::StatusCode(429)) => return Err(IdxError::RateLimited), + Err(err) => return Err(IdxError::Http(format!("ipot broker summary: {err}"))), + } + } + Err(IdxError::Http(format!( + "ipot broker summary: status {} after {ATTEMPTS} attempts", + last.unwrap_or_default() + ))) + } +} diff --git a/crates/idx-core/src/api/ipot/mod.rs b/crates/idx-core/src/api/ipot/mod.rs new file mode 100644 index 0000000..49f42dd --- /dev/null +++ b/crates/idx-core/src/api/ipot/mod.rs @@ -0,0 +1,101 @@ +//! Indo Premier Sekuritas (IPOT) broker summary: which brokers bought and +//! sold a stock. No login or key. +//! +//! `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php` +//! `?code=BBCA&start=MM/DD/YYYY&end=MM/DD/YYYY&fd=all|F|D&board=all|RG|TN|NG` +//! answers an HTML fragment with the top 10 buyers and the top 10 sellers, +//! ranked by lots, and totals for the range. Figures of a million and more +//! are abbreviated to one decimal (`1.6 M`). A range without trades gives +//! blank rows and zero totals; an unknown code gives `Invalid stock code!` +//! with HTTP 200. The endpoint's behavior was first mapped by fzn0x/idx +//! (MIT). + +mod client; +pub mod parse; + +use client::IpotClient; +pub use parse::{BrokerSummaryRaw, IpotPage, parse_broker_summary, parse_figure}; + +use super::BrokerSummaryProvider; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; +use crate::error::IdxError; + +const ENDPOINT: &str = "https://www.indopremier.com/module/saham/include/data-brokersummary.php"; + +/// The request URL for `code` (a bare IDX code such as `BBCA`). +pub fn broker_summary_url(code: &str, query: &BrokerQuery) -> String { + let investor = match query.investor { + BrokerInvestor::All => "all", + BrokerInvestor::Foreign => "F", + BrokerInvestor::Domestic => "D", + }; + let board = match query.board { + BrokerBoard::All => "all", + BrokerBoard::Regular => "RG", + BrokerBoard::Cash => "TN", + BrokerBoard::Negotiated => "NG", + }; + format!( + "{ENDPOINT}?code={code}&start={}&end={}&fd={investor}&board={board}", + query.from.format("%m/%d/%Y"), + query.to.format("%m/%d/%Y"), + ) +} + +/// A parsed page as a summary; an unknown code is `SymbolNotFound`. +pub(crate) fn summary_from_html(code: &str, html: &str) -> Result { + match parse_broker_summary(html)? { + IpotPage::Invalid => Err(IdxError::SymbolNotFound(code.to_string())), + IpotPage::Summary(raw) => Ok(raw), + } +} + +pub struct IpotProvider { + client: IpotClient, +} + +impl IpotProvider { + pub fn new() -> Self { + Self { + client: IpotClient::new(), + } + } +} + +impl Default for IpotProvider { + fn default() -> Self { + Self::new() + } +} + +impl BrokerSummaryProvider for IpotProvider { + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result { + let html = self.client.fetch_html(&broker_summary_url(code, query))?; + summary_from_html(code, &html) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::*; + + #[test] + fn test_broker_summary_url_maps_filters_to_source_codes() { + let query = BrokerQuery { + from: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(), + to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(), + investor: BrokerInvestor::Foreign, + board: BrokerBoard::Regular, + }; + assert_eq!( + broker_summary_url("BBCA", &query), + "https://www.indopremier.com/module/saham/include/data-brokersummary.php?code=BBCA&start=10/12/2026&end=10/16/2026&fd=F&board=RG" + ); + } +} diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs new file mode 100644 index 0000000..9dd822a --- /dev/null +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -0,0 +1,317 @@ +//! Pure parsers for IPOT's broker summary fragment, tested against the +//! recorded pages in `tests/fixtures/ipot_brokersummary_*.html`. +//! +//! The fragment is a 9-column table: buyer code, lots, value, average, rank, +//! seller code, lots, value, average. Each code sits in a +//! ``; the footer holds +//! `T. Val`, `F. NVal`, `T.Lot` and `Avg`, and the date inputs echo the +//! range the source applied. + +use chrono::NaiveDate; + +use crate::broker::types::{BrokerFlow, BrokerKind}; +use crate::error::IdxError; + +/// What one page said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpotPage { + /// `Invalid stock code!`: the source does not know the ticker. + Invalid, + Summary(BrokerSummaryRaw), +} + +/// One parsed summary page, before the symbol and query are attached. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BrokerSummaryRaw { + pub from: Option, + pub to: Option, + pub buyers: Vec, + pub sellers: Vec, + pub total_value: Option, + pub total_lots: Option, + pub average_price: Option, + pub foreign_net_value: Option, + pub rounded: bool, +} + +/// A figure as published: `1.6 M` -> `(1_600_000, true)`, `687,067` -> +/// `(687_067, false)`, `-2.3 T` -> `(-2_300_000_000_000, true)`. Scaled in +/// decimal, so `1.6 M` is exact. Empty or unparsable -> `None`. +pub fn parse_figure(text: &str) -> Option<(i64, bool)> { + let text = text.trim().replace(',', ""); + let (number, exponent) = match text.chars().last()?.to_ascii_uppercase() { + 'K' => (&text[..text.len() - 1], 3), + 'M' => (&text[..text.len() - 1], 6), + 'B' => (&text[..text.len() - 1], 9), + 'T' => (&text[..text.len() - 1], 12), + _ => (text.as_str(), 0), + }; + let number = number.trim(); + let (negative, digits) = match number.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, number), + }; + let (whole, fraction) = digits.split_once('.').unwrap_or((digits, "")); + if whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || !fraction.bytes().all(|b| b.is_ascii_digit()) + || (exponent > 0 && fraction.len() > exponent as usize) + { + return None; + } + let scale = 10_i64.checked_pow(exponent)?; + let mut value = whole.parse::().ok()?.checked_mul(scale)?; + if exponent == 0 { + // A plain decimal: round to whole units. + if fraction + .as_bytes() + .first() + .is_some_and(|digit| *digit >= b'5') + { + value = value.checked_add(1)?; + } + } else if !fraction.is_empty() { + let unit = 10_i64.checked_pow(exponent - fraction.len() as u32)?; + value = value.checked_add(fraction.parse::().ok()?.checked_mul(unit)?)?; + } + Some((if negative { -value } else { value }, exponent > 0)) +} + +struct Cell { + class: Option, + text: String, +} + +fn strip_tags(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(ch), + _ => {} + } + } + out.replace(" ", " ").trim().to_string() +} + +fn cells(row: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = row; + while let Some(start) = rest.find("') else { + break; + }; + let inner_start = open_end + 1; + let Some(close) = after[inner_start..].find("") else { + break; + }; + let inner = &after[inner_start..inner_start + close]; + let class = inner.find("class=\"").and_then(|at| { + let value = &inner[at + 7..]; + value.find('"').map(|end| value[..end].to_string()) + }); + out.push(Cell { + class, + text: strip_tags(inner), + }); + rest = &after[inner_start + close + 5..]; + } + out +} + +fn broker_kind(class: Option<&str>) -> Option { + match class?.strip_prefix("text-")? { + "local" => Some(BrokerKind::Local), + "foreign" => Some(BrokerKind::Foreign), + "bumn" => Some(BrokerKind::StateOwned), + _ => None, + } +} + +/// The `MM/DD/YYYY` value of the input with this id. +fn input_date(html: &str, id: &str) -> Option { + let at = html.find(&format!("id=\"{id}\""))?; + let tag_start = html[..at].rfind('<')?; + let tag_end = at + html[at..].find('>')?; + let tag = &html[tag_start..tag_end]; + let value = &tag[tag.find("value=\"")? + 7..]; + NaiveDate::parse_from_str(&value[..value.find('"')?], "%m/%d/%Y").ok() +} + +/// Text after `label` up to the next tag, e.g. `6.6 T` for `T. Val :`. +fn footer<'a>(html: &'a str, label: &str) -> Option<&'a str> { + let rest = &html[html.find(label)? + label.len()..]; + Some(rest[..rest.find('<')?].trim()) +} + +pub fn parse_broker_summary(html: &str) -> Result { + if html.to_ascii_lowercase().contains("invalid stock code") { + return Ok(IpotPage::Invalid); + } + let (Some(body_start), Some(body_end)) = (html.find(""), html.find("")) else { + return Err(IdxError::ParseError( + "ipot broker summary: no table in the page".to_string(), + )); + }; + let mut raw = BrokerSummaryRaw { + from: input_date(html, "startDateBrokerSummary"), + to: input_date(html, "endDateBrokerSummary"), + ..BrokerSummaryRaw::default() + }; + let mut rounded = false; + let mut figure = |text: &str| { + parse_figure(text).map(|(value, abbreviated)| { + rounded |= abbreviated; + value + }) + }; + for (index, row) in html[body_start..body_end].split("").enumerate() { + let row = cells(row); + if row.len() < 9 { + continue; + } + let rank = row[4].text.parse::().unwrap_or(index as u32 + 1); + for (side, at) in [(&mut raw.buyers, 0), (&mut raw.sellers, 5)] { + let code = row[at].text.to_ascii_uppercase(); + if code.is_empty() { + continue; + } + if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { + return Err(IdxError::ParseError(format!( + "ipot broker summary: unexpected broker code {code:?}" + ))); + } + side.push(BrokerFlow { + rank, + code, + name: None, + kind: broker_kind(row[at].class.as_deref()), + lots: figure(&row[at + 1].text), + value: figure(&row[at + 2].text), + average_price: figure(&row[at + 3].text), + }); + } + } + let mut total = |label: &str| footer(html, label).and_then(&mut figure); + raw.total_value = total("T. Val :"); + raw.foreign_net_value = total("F. NVal :"); + raw.total_lots = total("T.Lot :"); + // The page prints `Avg : 0` when nothing traded; there is no average then. + raw.average_price = total("Avg :").filter(|_| raw.total_lots.is_some_and(|lots| lots > 0)); + raw.rounded = rounded; + Ok(IpotPage::Summary(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: &str = include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261016.html"); + const RANGE: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html"); + const NO_TRADES: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261017.html"); + const INVALID: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_zzzz_invalid.html"); + + fn summary(html: &str) -> BrokerSummaryRaw { + match parse_broker_summary(html).unwrap() { + IpotPage::Summary(raw) => raw, + IpotPage::Invalid => panic!("expected a summary"), + } + } + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn test_parse_figure_scales_in_decimal() { + assert_eq!(parse_figure("1.6 M"), Some((1_600_000, true))); + assert_eq!(parse_figure("841.2 B"), Some((841_200_000_000, true))); + assert_eq!(parse_figure("-2.3 T"), Some((-2_300_000_000_000, true))); + assert_eq!(parse_figure("12 K"), Some((12_000, true))); + assert_eq!(parse_figure("687,067 "), Some((687_067, false))); + assert_eq!(parse_figure("5,191.5"), Some((5_192, false))); + assert_eq!(parse_figure("0"), Some((0, false))); + for bad in ["", " ", "n/a", "1.2.3 M", "M", "-", "1.6789 K"] { + assert_eq!(parse_figure(bad), None, "{bad:?}"); + } + } + + #[test] + fn test_parse_broker_summary_one_day() { + let raw = summary(DAY); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 16)), Some(date(2026, 10, 16))) + ); + assert_eq!((raw.buyers.len(), raw.sellers.len()), (10, 10)); + + let top = &raw.buyers[0]; + assert_eq!( + (top.rank, top.code.as_str(), top.kind), + (1, "MG", Some(BrokerKind::Local)) + ); + assert_eq!( + (top.lots, top.value, top.average_price), + (Some(1_600_000), Some(841_200_000_000), Some(5_191)) + ); + let seller = &raw.sellers[0]; + assert_eq!( + (seller.code.as_str(), seller.kind, seller.value), + ("CC", Some(BrokerKind::StateOwned), Some(2_200_000_000_000)) + ); + assert_eq!(raw.sellers[1].kind, Some(BrokerKind::Foreign)); + let last = &raw.buyers[9]; + assert_eq!((last.rank, last.code.as_str()), (10, "AK")); + assert_eq!(last.lots, Some(398_474), "small figures are exact"); + + assert_eq!(raw.total_value, Some(6_600_000_000_000)); + assert_eq!(raw.foreign_net_value, Some(-2_300_000_000_000)); + assert_eq!(raw.total_lots, Some(12_600_000)); + assert_eq!(raw.average_price, Some(5_228)); + assert!(raw.rounded); + } + + #[test] + fn test_parse_broker_summary_range_echoes_its_dates() { + let raw = summary(RANGE); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 12)), Some(date(2026, 10, 16))) + ); + assert_eq!(raw.buyers.len(), 10); + assert!( + raw.total_value + .is_some_and(|value| value > 6_600_000_000_000) + ); + } + + #[test] + fn test_parse_broker_summary_without_trades_is_empty() { + let raw = summary(NO_TRADES); + assert!(raw.buyers.is_empty() && raw.sellers.is_empty()); + assert_eq!((raw.total_value, raw.total_lots), (Some(0), Some(0))); + assert_eq!(raw.average_price, None, "Avg : 0 means no average"); + assert!(!raw.rounded); + assert_eq!(raw.to, Some(date(2026, 10, 17))); + } + + #[test] + fn test_parse_broker_summary_rejects_unknown_codes_and_markup() { + assert_eq!(parse_broker_summary(INVALID).unwrap(), IpotPage::Invalid); + assert!(matches!( + parse_broker_summary("maintenance"), + Err(IdxError::ParseError(_)) + )); + let odd = DAY.replacen(">MG<", ">M G<", 1); + assert!(matches!( + parse_broker_summary(&odd), + Err(IdxError::ParseError(_)) + )); + } +} diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 9959599..2292039 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -2,7 +2,10 @@ //! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. use super::types::{Bar, Fundamentals, Interval, Period, Quote}; -use super::{FundamentalsProvider, HistoryProvider, QuoteProvider, msn, yahoo}; +use super::{ + BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, ipot, msn, yahoo, +}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; use crate::error::IdxError; @@ -10,6 +13,15 @@ pub(crate) fn enabled() -> bool { std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() } +/// `IDX_MOCK_TODAY` (YYYY-MM-DD) pins today's date under the mock provider. +pub(crate) fn today() -> Option { + if !enabled() { + return None; + } + let raw = std::env::var("IDX_MOCK_TODAY").ok()?; + chrono::NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok() +} + pub struct MockProvider { quote: Result, fundamentals: Result, @@ -117,3 +129,38 @@ impl HistoryProvider for MockProvider { self.history.clone() } } + +/// Broker summaries from `tests/fixtures/ipot_brokersummary__ +/// [_][__].html`; a query without a fixture reads as an +/// unknown code. +pub struct MockBrokerProvider; + +impl BrokerSummaryProvider for MockBrokerProvider { + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Err(IdxError::ProviderUnavailable); + } + let mut name = format!( + "ipot_brokersummary_{}_{}", + code.to_ascii_lowercase(), + query.from.format("%Y%m%d") + ); + if query.to != query.from { + name.push_str(&query.to.format("_%Y%m%d").to_string()); + } + if (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All) { + name.push_str(&format!( + "_{}_{}", + query.investor.as_str(), + query.board.as_str() + )); + } + let html = std::fs::read_to_string(format!("tests/fixtures/{name}.html")) + .unwrap_or_else(|_| "Invalid stock code!".to_string()); + ipot::summary_from_html(code, &html) + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 1a7bf04..7b7d459 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -1,3 +1,4 @@ +pub mod ipot; #[cfg(feature = "testing")] pub mod mock; pub mod msn; @@ -6,6 +7,7 @@ pub mod yahoo; use chrono::FixedOffset; +use crate::broker::types::BrokerQuery; use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ @@ -41,6 +43,17 @@ pub trait HistoryProvider { ) -> Result, IdxError>; } +/// Which brokers bought and sold a stock: a separate capability, served by +/// IPOT whichever market provider is selected. +pub trait BrokerSummaryProvider { + /// `code` is the bare IDX code, e.g. `BBCA`. + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -258,6 +271,23 @@ fn msn_capability_error(subject: &str) -> IdxError { IdxError::Unsupported(format!("{subject}: command requires --provider msn")) } +pub fn broker_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockBrokerProvider); + } + Box::new(ipot::IpotProvider::new()) +} + +/// Today in WIB, the date IDX trades on. +pub(crate) fn today_wib() -> chrono::NaiveDate { + #[cfg(feature = "testing")] + if let Some(today) = mock::today() { + return today; + } + chrono::Utc::now().with_timezone(&idx_offset()).date_naive() +} + /// The provider that serves history for `provider` under `history_mode`. pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind { match history_mode { diff --git a/crates/idx-core/src/broker/codes.tsv b/crates/idx-core/src/broker/codes.tsv new file mode 100644 index 0000000..ffb5c85 --- /dev/null +++ b/crates/idx-core/src/broker/codes.tsv @@ -0,0 +1,92 @@ +# IDX exchange members: codename, from idx.co.id +# primary/ExchangeMember/GetBrokerCodeList, fetched 2026-10-18 (89 members). +# A code missing here is shown without a name. +AD SUKADANA PRIMA SEKURITAS +AF HARITA KENCANA SEKURITAS +AG KIWOOM SEKURITAS INDONESIA +AH SHINHAN SEKURITAS INDONESIA +AI KAY HIAN SEKURITAS +AK UBS SEKURITAS INDONESIA +AO ERDIKHA ELIT SEKURITAS +AP PACIFIC SEKURITAS INDONESIA +AR BINAARTHA SEKURITAS +AT PHINTRACO SEKURITAS +AZ SUCOR SEKURITAS +BB VERDHANA SEKURITAS INDONESIA +BF INTI FIKASA SEKURITAS +BK J.P. MORGAN SEKURITAS INDONESIA +BQ KOREA INVESTMENT AND SEKURITAS INDONESIA +BR TRUST SEKURITAS +BS EQUITY SEKURITAS INDONESIA +CC MANDIRI SEKURITAS +CD MEGA CAPITAL SEKURITAS +CP KB VALBURY SEKURITAS +DD MAKINDO SEKURITAS +DH SINARMAS SEKURITAS +DP DBS VICKERS SEKURITAS INDONESIA +DR RHB SEKURITAS INDONESIA +DU KAF SEKURITAS INDONESIA +DX BAHANA SEKURITAS +EL EVERGREEN SEKURITAS INDONESIA +EP MNC SEKURITAS +ES EKOKAPITAL SEKURITAS +FO FORTE GLOBAL SEKURITAS +FS YUANTA SEKURITAS INDONESIA +FZ WATERFRONT SEKURITAS INDONESIA +GA BNC SEKURITAS INDONESIA +GI WEBULL SEKURITAS INDONESIA +GR PANIN SEKURITAS TBK. +HD KGI SEKURITAS INDONESIA +HP HENAN PUTIHRAI SEKURITAS +IC INTEGRITY CAPITAL SEKURITAS +ID ANUGERAH SEKURITAS INDONESIA +IF SAMUEL SEKURITAS INDONESIA +IH INDO HARVEST SEKURITAS +II DANATAMA MAKMUR SEKURITAS +IN INVESTINDO NUSANTARA SEKURITAS +IT INTI TELADAN SEKURITAS +IU INDO CAPITAL SEKURITAS +KI CIPTADANA SEKURITAS ASIA +KK PHILLIP SEKURITAS INDONESIA +KZ CLSA SEKURITAS INDONESIA +LG TRIMEGAH SEKURITAS INDONESIA TBK. +LS RELIANCE SEKURITAS INDONESIA TBK. +MG SEMESTA INDOVEST SEKURITAS +MI VICTORIA SEKURITAS INDONESIA +MU MINNA PADI INVESTAMA SEKURITAS TBK +NI BNI SEKURITAS +OD BRI DANAREKSA SEKURITAS +OK NET SEKURITAS +PC FAC SEKURITAS INDONESIA +PD INDO PREMIER SEKURITAS +PF DANASAKTI SEKURITAS INDONESIA +PG PANCA GLOBAL SEKURITAS +PI MAGENTA KAPITAL SEKURITAS INDONESIA +PO PILARMAS INVESTINDO SEKURITAS +PP ALDIRACITA SEKURITAS INDONESIA +PS PARAMITRA ALFA SEKURITAS +QA TUNTUN SEKURITAS INDONESIA +RB INA SEKURITAS INDONESIA +RF BUANA CAPITAL SEKURITAS +RG PROFINDO SEKURITAS INDONESIA +RO PLUANG MAJU SEKURITAS +RS YULIE SEKURITAS INDONESIA TBK. +RX MACQUARIE SEKURITAS INDONESIA +SA ELIT SUKSES SEKURITAS +SF SURYA FAJAR SEKURITAS +SH ARTHA SEKURITAS INDONESIA +SQ BCA SEKURITAS +SS SUPRA SEKURITAS INDONESIA +TF LABA SEKURITAS INDONESIA +TP OCBC SEKURITAS INDONESIA +TS DWIDANA SAKTI SEKURITAS +XA NH KORINDO SEKURITAS INDONESIA +XC AJAIB SEKURITAS ASIA +XL STOCKBIT SEKURITAS DIGITAL +YB YAKIN BERTUMBUH SEKURITAS +YJ LOTUS ANDALAN SEKURITAS +YO AMANTARA SEKURITAS INDONESIA +YP MIRAE ASSET SEKURITAS INDONESIA +YU CGS INTERNATIONAL SEKURITAS INDONESIA +ZP MAYBANK SEKURITAS INDONESIA +ZR BUMIPUTERA SEKURITAS diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs new file mode 100644 index 0000000..dab8815 --- /dev/null +++ b/crates/idx-core/src/broker/mod.rs @@ -0,0 +1,222 @@ +//! Broker summaries: which IDX brokers bought and sold a stock over a date +//! range (IPOT, end of day). See docs/ARCHITECTURE.md. + +pub mod types; + +use std::collections::HashMap; +use std::sync::OnceLock; + +use chrono::{Datelike, Duration, NaiveDate, Weekday}; + +use crate::api::{broker_provider, today_wib}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; + +const SOURCE: &str = "ipot"; +/// Longest range one summary may cover. +pub const MAX_RANGE_DAYS: i64 = 366; +/// Calendar days searched back for the latest session with trades. +const LATEST_LOOKBACK_DAYS: i64 = 10; +/// A range that ended before today no longer changes. +const TTL_CLOSED_SECS: u64 = 7 * 24 * 3600; +/// Today's summary moves with every trade. +const TTL_TODAY_SECS: u64 = 5 * 60; + +const CODES: &str = include_str!("codes.tsv"); + +/// Member name for an IDX broker code such as `CC`; `None` when the code is +/// not on the bundled member list. +pub fn broker_name(code: &str) -> Option<&'static str> { + static NAMES: OnceLock> = OnceLock::new(); + NAMES + .get_or_init(|| { + CODES + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('\t')) + .collect() + }) + .get(code.trim().to_ascii_uppercase().as_str()) + .copied() +} + +pub struct Broker<'a> { + pub(crate) idx: &'a Idx, +} + +impl Broker<'_> { + /// The broker summary for `symbol` over `query`'s range. An empty + /// summary means no trades (weekend, holiday, suspension). + pub fn summary( + &self, + symbol: &str, + query: &BrokerQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let (resolved, code) = self.idx_code(symbol)?; + let today = today_wib(); + check_range(query, today)?; + let key = format!( + "{code}-{}-{}-{}-{}", + query.from, + query.to, + query.investor.as_str(), + query.board.as_str() + ); + let provider = broker_provider(); + cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: "ipot-broker", + key: &key, + ttl_secs: if query.to < today { + TTL_CLOSED_SECS + } else { + TTL_TODAY_SECS + }, + subject: &resolved, + source: SOURCE, + }, + policy, + || provider.broker_summary(&code, query), + |raw| { + let filtered = + (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All); + let mut summary = BrokerSummary { + symbol: resolved.clone(), + from: raw.from.unwrap_or(query.from), + to: raw.to.unwrap_or(query.to), + investor: query.investor, + board: query.board, + buyers: raw.buyers, + sellers: raw.sellers, + total_value: raw.total_value, + total_lots: raw.total_lots, + average_price: raw.average_price, + foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), + rounded: raw.rounded, + }; + for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { + flow.name = broker_name(&flow.code).map(str::to_string); + } + Ok(summary) + }, + ) + } + + /// The summary of the latest session with trades, searching back from + /// today (WIB) over weekdays. + pub fn latest( + &self, + symbol: &str, + investor: BrokerInvestor, + board: BrokerBoard, + policy: CachePolicy, + ) -> Result, IdxError> { + let (resolved, _) = self.idx_code(symbol)?; + let today = today_wib(); + for back in 0..LATEST_LOOKBACK_DAYS { + let day = today - Duration::days(back); + if matches!(day.weekday(), Weekday::Sat | Weekday::Sun) { + continue; + } + let query = BrokerQuery { + from: day, + to: day, + investor, + board, + }; + match self.summary(symbol, &query, policy) { + Ok(fetched) if !fetched.data.is_empty() => return Ok(fetched), + Ok(_) => {} + // Offline, an uncached day says nothing; look further back. + Err(IdxError::CacheMiss(_)) if policy == CachePolicy::Offline => {} + Err(err) => return Err(err), + } + } + if policy == CachePolicy::Offline { + return Err(IdxError::CacheMiss(format!( + "ipot-broker/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)" + ))); + } + Err(IdxError::NoMarketData(format!( + "{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days" + ))) + } + + /// The resolved symbol and the bare IDX code IPOT takes. + fn idx_code(&self, symbol: &str) -> Result<(String, String), IdxError> { + let resolved = self.idx.resolve(symbol)?; + match resolved.strip_suffix(".JK") { + Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())), + _ => Err(IdxError::Unsupported(format!( + "{resolved}: broker summaries cover IDX (.JK) stocks only" + ))), + } + } +} + +fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> { + if query.from > query.to { + return Err(IdxError::InvalidInput(format!( + "--from {} is after --to {}", + query.from, query.to + ))); + } + if query.to > today { + return Err(IdxError::InvalidInput(format!( + "{} is in the future (today is {today} in WIB)", + query.to + ))); + } + if (query.to - query.from).num_days() >= MAX_RANGE_DAYS { + return Err(IdxError::InvalidInput(format!( + "a broker summary covers at most {MAX_RANGE_DAYS} days" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + fn query(from: NaiveDate, to: NaiveDate) -> BrokerQuery { + BrokerQuery { + from, + to, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + } + } + + #[test] + fn test_broker_name_reads_the_bundled_member_list() { + assert_eq!(broker_name("cc"), Some("MANDIRI SEKURITAS")); + assert_eq!(broker_name("XL"), Some("STOCKBIT SEKURITAS DIGITAL")); + assert_eq!(broker_name("??"), None); + } + + #[test] + fn test_check_range_bounds() { + let today = date(2026, 10, 18); + assert!(check_range(&query(date(2026, 10, 12), date(2026, 10, 16)), today).is_ok()); + assert!(check_range(&query(today, today), today).is_ok()); + assert!(check_range(&query(date(2025, 10, 16), date(2026, 10, 16)), today).is_ok()); + for bad in [ + query(date(2026, 10, 16), date(2026, 10, 12)), + query(date(2026, 10, 19), date(2026, 10, 19)), + query(date(2025, 10, 15), date(2026, 10, 16)), + ] { + assert!( + matches!(check_range(&bad, today), Err(IdxError::InvalidInput(_))), + "{bad:?}" + ); + } + } +} diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs new file mode 100644 index 0000000..84a9a87 --- /dev/null +++ b/crates/idx-core/src/broker/types.rs @@ -0,0 +1,146 @@ +//! Broker summary types: which IDX brokers bought and sold a stock. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +/// Whose trades a broker summary counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum BrokerInvestor { + /// Every trade. + #[default] + All, + /// Trades by foreign investors only. + Foreign, + /// Trades by domestic investors only. + Domestic, +} + +impl BrokerInvestor { + pub fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Foreign => "foreign", + Self::Domestic => "domestic", + } + } +} + +/// Which market board's trades a broker summary counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum BrokerBoard { + /// Every board. + #[default] + All, + /// Regular market (RG), the continuous order book. + Regular, + /// Cash market (TN). + Cash, + /// Negotiated market (NG): pre-arranged block trades. + Negotiated, +} + +impl BrokerBoard { + pub fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Regular => "regular", + Self::Cash => "cash", + Self::Negotiated => "negotiated", + } + } +} + +/// The source's classification of a securities house. This is the broker's +/// own status, not the nationality of its clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrokerKind { + Local, + Foreign, + /// State-owned securities house (BUMN). + StateOwned, +} + +impl BrokerKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Foreign => "foreign", + Self::StateOwned => "state_owned", + } + } +} + +/// The trading days a broker summary covers, both ends included (WIB dates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerQuery { + pub from: NaiveDate, + pub to: NaiveDate, + #[serde(default)] + pub investor: BrokerInvestor, + #[serde(default)] + pub board: BrokerBoard, +} + +/// One broker on one side of a summary: gross buys for a buyer, gross sells +/// for a seller. A broker can appear on both sides. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerFlow { + /// Position on its side as the source ranks it, 1 = most lots. + pub rank: u32, + /// IDX member code, e.g. `CC`. + pub code: String, + /// Member name from the bundled IDX member list; `None` for a code that + /// is not on it. + pub name: Option, + /// The source's classification; `None` when it gives none. + pub kind: Option, + /// Lots bought or sold (one lot is 100 shares). + pub lots: Option, + /// Value bought or sold, whole IDR. + pub value: Option, + /// Average price, whole IDR. + pub average_price: Option, +} + +/// The top buyers and sellers of one stock over a date range, as published. +/// Nothing is netted or estimated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerSummary { + /// Resolved ticker, e.g. `BBCA.JK`. + pub symbol: String, + /// First trading date covered, as the source applied it. + pub from: NaiveDate, + /// Last trading date covered, as the source applied it. + pub to: NaiveDate, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// Top buyers by lots, most first (up to 10). + pub buyers: Vec, + /// Top sellers by lots, most first (up to 10). + pub sellers: Vec, + /// Value traded over the range, whole IDR. + pub total_value: Option, + /// Lots traded over the range. + pub total_lots: Option, + /// Average traded price, whole IDR; `None` when nothing traded. + pub average_price: Option, + /// Foreign buy value minus foreign sell value over the range, whole IDR, + /// across all boards. The source does not apply the investor or board + /// filter to it, so it is `None` for a filtered summary. + pub foreign_net_value: Option, + /// True when the source abbreviated at least one figure (`1.6 M` lots, + /// `841.2 B` IDR), so those figures carry only one decimal of precision. + pub rounded: bool, +} + +impl BrokerSummary { + /// No trades in the range (weekend, holiday, suspension). + pub fn is_empty(&self) -> bool { + self.buyers.is_empty() && self.sellers.is_empty() + } +} diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index c92d58d..f9db17c 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -86,6 +86,10 @@ impl Idx { crate::market::Market { idx: self } } + pub fn broker(&self) -> crate::broker::Broker<'_> { + crate::broker::Broker { idx: self } + } + pub fn financial(&self) -> crate::financial::Financial<'_> { crate::financial::Financial { idx: self } } diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 0e67aca..559c5e6 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod analysis; pub mod api; +pub mod broker; pub mod cache; mod client; pub mod config; diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html new file mode 100644 index 0000000..564e859 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.7 M963.0 B5,573 1CC5.1 M2.7 T5,336
MG1.6 M842.0 B5,192 2RX1.8 M928.5 B5,295
ZP1.6 M900.9 B5,780 3ZP1.6 M942.1 B5,756
YU1.4 M781.7 B5,626 4MG1.5 M800.5 B5,239
CC1.4 M737.9 B5,408 5AK1.4 M786.5 B5,785
XL1.3 M687.2 B5,313 6BB852,704 457.7 B5,368
AK1.1 M643.9 B5,771 7YU830,940 466.7 B5,617
SQ973,921 519.4 B5,333 8BK709,658 393.3 B5,542
YP919,655 487.1 B5,296 9KZ621,425 363.3 B5,847
PD646,162 341.2 B5,280 10XL441,903 242.6 B5,490
+
+ T. Val : 9.9 T + F. NVal : -4.1 T + T.Lot : 18.1 M + Avg : 5,471 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html new file mode 100644 index 0000000..769eb2b --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
MG1.6 M841.2 B5,191 1CC4.3 M2.2 T5,211
XL1.1 M600.0 B5,229 2RX1.7 M908.2 B5,282
CC1.0 M543.5 B5,227 3MG1.5 M799.4 B5,238
KZ1.0 M522.4 B5,198 4BB687,067 357.4 B5,202
SQ828,163 432.4 B5,221 5ZP529,223 275.4 B5,203
YP827,340 431.7 B5,218 6AK444,696 233.5 B5,251
YU687,603 356.6 B5,186 7BK419,266 218.8 B5,219
PD592,667 309.2 B5,218 8YU418,986 218.0 B5,202
ZP441,000 230.2 B5,220 9NI357,177 186.1 B5,212
AK398,474 209.9 B5,268 10XL296,269 155.0 B5,232
+
+ T. Val : 6.6 T + F. NVal : -2.3 T + T.Lot : 12.6 M + Avg : 5,228 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html new file mode 100644 index 0000000..34e6cda --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.0 M521.3 B5,198 1CC3.9 M2.0 T5,211
YU575,965 298.4 B5,181 2RX1.6 M820.1 B5,208
BK369,446 196.0 B5,304 3ZP474,808 247.1 B5,203
AK366,209 193.1 B5,274 4AK444,447 233.4 B5,251
ZP287,065 150.0 B5,226 5BK419,265 218.8 B5,219
CC198,140 104.4 B5,267 6YU252,383 131.3 B5,203
BB123,966 64.8 B5,225 7DX252,099 130.5 B5,175
TP70,621 36.6 B5,189 8KZ115,380 60.1 B5,212
RX17,322 9.0 B5,202 9BB109,509 56.9 B5,200
DX15,000 7.8 B5,181 10TP34,490 17.9 B5,195
+
+ T. Val : 1.6 T + F. NVal : -2.3 T + T.Lot : 3.1 M + Avg : 5,225 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html new file mode 100644 index 0000000..758d1b4 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
1
2
3
4
5
6
7
8
9
10
+
+ T. Val : 0 + F. NVal : 0 + T.Lot : 0 + Avg : 0 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html new file mode 100644 index 0000000..0346068 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html @@ -0,0 +1 @@ +Invalid stock code! diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb7d1dd..1afb718 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,6 +51,8 @@ command parses arguments, makes one idx-core call, and renders the result. ### Dual Provider Model - **MSN Finance** — default provider. Rich data: quotes, fundamentals, profile, earnings, financials, sentiment, insights, news, screener, and explicit price-only chart history for supported IDX windows. - **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`. +- **IPOT (Indo Premier)** — broker summaries, whichever provider is selected + (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -83,6 +85,22 @@ WIB as the fallback. when the initial batch is incomplete, and rejects a still-truncated response. - Full-list screen caches use an `all` key, separate from older limited results. +### Broker Summaries +- `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php` + takes `code`, `start`/`end` (`MM/DD/YYYY`), `fd` (`all|F|D`) and `board` + (`all|RG|TN|NG`). It needs no login and answers an HTML fragment: the top 10 + buyers and top 10 sellers ranked by lots (gross, so a broker can be on both + sides), plus range totals. `api/ipot/parse.rs` parses it purely. +- Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled + in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the + investor and board filters, so `foreign_net_value` is null for a filtered + summary. +- An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The + server also answers spurious nginx 404s for seconds at a time, so the client + retries 404 and 5xx with backoff. A range without trades is an empty summary. +- Data is end of day: IDX hides broker codes during trading. Summaries that + ended before today are cached for 7 days, today's for 5 minutes. Broker + names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). ### Capability Gating ``` @@ -92,6 +110,7 @@ HistoryProvider = separate trait, not all providers implement Factory functions: default_provider(kind) → Box history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> + broker_provider() → Box ``` ## Ownership Module (SQLite-backed)