feat(core): broker summaries from IPOT

Adds a BrokerSummaryProvider capability served by Indo Premier's public
broker summary page: the top 10 buyers and sellers of a stock by lots over
a date range, with totals. No login or key.

- api/ipot: pure parser (decimal scaling of `1.6 M`-style figures), a
  client that retries the server's spurious 404s, and a fixture mock.
- broker: Broker::summary/latest over the cache (7 days for closed ranges,
  5 minutes for today), range checks, and IDX member names bundled from
  GetBrokerCodeList.
- foreign_net_value is null for filtered summaries: the source does not
  apply the filters to it.

The endpoint's behavior was first mapped by fzn0x/idx (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 04:28:38 +00:00
commit 2c93c73b6a
16 changed files with 1767 additions and 1 deletions

View file

@ -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<String, IdxError> {
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()
)))
}
}

View file

@ -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<BrokerSummaryRaw, IdxError> {
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<BrokerSummaryRaw, IdxError> {
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"
);
}
}

View file

@ -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
//! `<span class="text-local|text-foreign|text-bumn">`; 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<NaiveDate>,
pub to: Option<NaiveDate>,
pub buyers: Vec<BrokerFlow>,
pub sellers: Vec<BrokerFlow>,
pub total_value: Option<i64>,
pub total_lots: Option<i64>,
pub average_price: Option<i64>,
pub foreign_net_value: Option<i64>,
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::<i64>().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::<i64>().ok()?.checked_mul(unit)?)?;
}
Some((if negative { -value } else { value }, exponent > 0))
}
struct Cell {
class: Option<String>,
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("&nbsp;", " ").trim().to_string()
}
fn cells(row: &str) -> Vec<Cell> {
let mut out = Vec::new();
let mut rest = row;
while let Some(start) = rest.find("<td") {
let after = &rest[start..];
let Some(open_end) = after.find('>') else {
break;
};
let inner_start = open_end + 1;
let Some(close) = after[inner_start..].find("</td>") 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<BrokerKind> {
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<NaiveDate> {
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<IpotPage, IdxError> {
if html.to_ascii_lowercase().contains("invalid stock code") {
return Ok(IpotPage::Invalid);
}
let (Some(body_start), Some(body_end)) = (html.find("<tbody>"), html.find("</tbody>")) 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("</tr>").enumerate() {
let row = cells(row);
if row.len() < 9 {
continue;
}
let rank = row[4].text.parse::<u32>().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("<html>maintenance</html>"),
Err(IdxError::ParseError(_))
));
let odd = DAY.replacen(">MG<", ">M G<", 1);
assert!(matches!(
parse_broker_summary(&odd),
Err(IdxError::ParseError(_))
));
}
}

View file

@ -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<chrono::NaiveDate> {
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<Quote, IdxError>,
fundamentals: Result<Fundamentals, IdxError>,
@ -117,3 +129,38 @@ impl HistoryProvider for MockProvider {
self.history.clone()
}
}
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
/// [_<to>][_<investor>_<board>].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<ipot::BrokerSummaryRaw, IdxError> {
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)
}
}

View file

@ -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<Vec<Bar>, 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<ipot::BrokerSummaryRaw, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
@ -258,6 +271,23 @@ fn msn_capability_error(subject: &str) -> IdxError {
IdxError::Unsupported(format!("{subject}: command requires --provider msn"))
}
pub fn broker_provider() -> Box<dyn BrokerSummaryProvider> {
#[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 {

View file

@ -0,0 +1,92 @@
# IDX exchange members: code<TAB>name, 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
Can't render this file because it has a wrong number of fields in line 4.

View file

@ -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<HashMap<&'static str, &'static str>> = 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<Fetched<BrokerSummary>, 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<Fetched<BrokerSummary>, 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:?}"
);
}
}
}

View file

@ -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<String>,
/// The source's classification; `None` when it gives none.
pub kind: Option<BrokerKind>,
/// Lots bought or sold (one lot is 100 shares).
pub lots: Option<i64>,
/// Value bought or sold, whole IDR.
pub value: Option<i64>,
/// Average price, whole IDR.
pub average_price: Option<i64>,
}
/// 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<BrokerFlow>,
/// Top sellers by lots, most first (up to 10).
pub sellers: Vec<BrokerFlow>,
/// Value traded over the range, whole IDR.
pub total_value: Option<i64>,
/// Lots traded over the range.
pub total_lots: Option<i64>,
/// Average traded price, whole IDR; `None` when nothing traded.
pub average_price: Option<i64>,
/// 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<i64>,
/// 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()
}
}

View file

@ -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 }
}

View file

@ -4,6 +4,7 @@
pub mod analysis;
pub mod api;
pub mod broker;
pub mod cache;
mod client;
pub mod config;