mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
ebdba13a41
commit
2c93c73b6a
16 changed files with 1767 additions and 1 deletions
92
crates/idx-core/src/broker/codes.tsv
Normal file
92
crates/idx-core/src/broker/codes.tsv
Normal 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.
|
222
crates/idx-core/src/broker/mod.rs
Normal file
222
crates/idx-core/src/broker/mod.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
crates/idx-core/src/broker/types.rs
Normal file
146
crates/idx-core/src/broker/types.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue