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
62
crates/idx-core/src/api/ipot/client.rs
Normal file
62
crates/idx-core/src/api/ipot/client.rs
Normal 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()
|
||||
)))
|
||||
}
|
||||
}
|
||||
101
crates/idx-core/src/api/ipot/mod.rs
Normal file
101
crates/idx-core/src/api/ipot/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
317
crates/idx-core/src/api/ipot/parse.rs
Normal file
317
crates/idx-core/src/api/ipot/parse.rs
Normal 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(" ", " ").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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
pub mod analysis;
|
||||
pub mod api;
|
||||
pub mod broker;
|
||||
pub mod cache;
|
||||
mod client;
|
||||
pub mod config;
|
||||
|
|
|
|||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.7 M</span></td>
|
||||
<td align="right"><span class="text-up">963.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,573 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">5.1 M</span></td>
|
||||
<td align="right"><span class="text-down">2.7 T</span></td>
|
||||
<td align="right"><span class="text-down">5,336 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">842.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,192 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||
<td align="right"><span class="text-down">928.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,295 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">900.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,780 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||
<td align="right"><span class="text-down">942.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,756 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||
<td align="right"><span class="text-up">781.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,626 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">1.5 M</span></td>
|
||||
<td align="right"><span class="text-down">800.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,239 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||
<td align="right"><span class="text-up">737.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,408 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">1.4 M</span></td>
|
||||
<td align="right"><span class="text-down">786.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,785 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.3 M</span></td>
|
||||
<td align="right"><span class="text-up">687.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,313 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">852,704 </span></td>
|
||||
<td align="right"><span class="text-down">457.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,368 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">1.1 M</span></td>
|
||||
<td align="right"><span class="text-up">643.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,771 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">830,940 </span></td>
|
||||
<td align="right"><span class="text-down">466.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,617 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">973,921 </span></td>
|
||||
<td align="right"><span class="text-up">519.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,333 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">709,658 </span></td>
|
||||
<td align="right"><span class="text-down">393.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,542 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">919,655 </span></td>
|
||||
<td align="right"><span class="text-up">487.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,296 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">621,425 </span></td>
|
||||
<td align="right"><span class="text-down">363.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,847 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">646,162 </span></td>
|
||||
<td align="right"><span class="text-up">341.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,280 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">441,903 </span></td>
|
||||
<td align="right"><span class="text-down">242.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,490 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 9.9 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -4.1 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 18.1 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,471 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">841.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,191 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">4.3 M</span></td>
|
||||
<td align="right"><span class="text-down">2.2 T</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.1 M</span></td>
|
||||
<td align="right"><span class="text-up">600.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,229 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.7 M</span></td>
|
||||
<td align="right"><span class="text-down">908.2 B</span></td>
|
||||
<td align="right"><span class="text-down">5,282 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">543.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,227 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">1.5 M</span></td>
|
||||
<td align="right"><span class="text-down">799.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,238 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">522.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,198 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">687,067 </span></td>
|
||||
<td align="right"><span class="text-down">357.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,202 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">828,163 </span></td>
|
||||
<td align="right"><span class="text-up">432.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,221 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">529,223 </span></td>
|
||||
<td align="right"><span class="text-down">275.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">827,340 </span></td>
|
||||
<td align="right"><span class="text-up">431.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,218 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">444,696 </span></td>
|
||||
<td align="right"><span class="text-down">233.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,251 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">687,603 </span></td>
|
||||
<td align="right"><span class="text-up">356.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,186 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">419,266 </span></td>
|
||||
<td align="right"><span class="text-down">218.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,219 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">592,667 </span></td>
|
||||
<td align="right"><span class="text-up">309.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,218 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">418,986 </span></td>
|
||||
<td align="right"><span class="text-down">218.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,202 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">441,000 </span></td>
|
||||
<td align="right"><span class="text-up">230.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,220 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-down">357,177 </span></td>
|
||||
<td align="right"><span class="text-down">186.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">398,474 </span></td>
|
||||
<td align="right"><span class="text-up">209.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,268 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">296,269 </span></td>
|
||||
<td align="right"><span class="text-down">155.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,232 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 6.6 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 12.6 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,228 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" selected>Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">521.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,198 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">3.9 M</span></td>
|
||||
<td align="right"><span class="text-down">2.0 T</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">575,965 </span></td>
|
||||
<td align="right"><span class="text-up">298.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||
<td align="right"><span class="text-down">820.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,208 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">369,446 </span></td>
|
||||
<td align="right"><span class="text-up">196.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,304 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">474,808 </span></td>
|
||||
<td align="right"><span class="text-down">247.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">366,209 </span></td>
|
||||
<td align="right"><span class="text-up">193.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,274 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">444,447 </span></td>
|
||||
<td align="right"><span class="text-down">233.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,251 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">287,065 </span></td>
|
||||
<td align="right"><span class="text-up">150.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,226 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">419,265 </span></td>
|
||||
<td align="right"><span class="text-down">218.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,219 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">198,140 </span></td>
|
||||
<td align="right"><span class="text-up">104.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,267 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">252,383 </span></td>
|
||||
<td align="right"><span class="text-down">131.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">123,966 </span></td>
|
||||
<td align="right"><span class="text-up">64.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,225 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-down">252,099 </span></td>
|
||||
<td align="right"><span class="text-down">130.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,175 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-up">70,621 </span></td>
|
||||
<td align="right"><span class="text-up">36.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,189 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">115,380 </span></td>
|
||||
<td align="right"><span class="text-down">60.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">17,322 </span></td>
|
||||
<td align="right"><span class="text-up">9.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,202 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">109,509 </span></td>
|
||||
<td align="right"><span class="text-down">56.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,200 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-up">15,000 </span></td>
|
||||
<td align="right"><span class="text-up">7.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-down">34,490 </span></td>
|
||||
<td align="right"><span class="text-down">17.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,195 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 1.6 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 3.1 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,225 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/17/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/17/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 0 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
1
crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Invalid stock code!
|
||||
|
|
@ -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<dyn MarketDataProvider>
|
||||
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
|
||||
broker_provider() → Box<dyn BrokerSummaryProvider>
|
||||
```
|
||||
|
||||
## Ownership Module (SQLite-backed)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue