idx-cli/crates/idx-core/src/api/ipot/mod.rs
hermes 2c93c73b6a 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
2026-10-18 04:28:38 +00:00

101 lines
3.2 KiB
Rust

//! 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"
);
}
}