feat(cli,mcp): idx stocks broker and get_broker_summary

`idx stocks broker SYMBOL` shows the top 10 buyers and sellers of a stock
by lots for the latest session with trades, one `--date`, or a
`--from`/`--to` range, filtered by `--investor` and `--board`. The MCP
server exposes the same as get_broker_summary.

- Range and filter parsing lives in idx-core (BrokerRange::from_args,
  FromStr for the filters), so both frontends accept the same input.
- Without dates, the latest session is found by walking back over
  weekdays; offline, uncached days are skipped.
- Table output lists foreign and state-owned brokers, and says when the
  source rounded figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 04:38:58 +00:00
commit 860d4daf77
25 changed files with 939 additions and 13 deletions

View file

@ -12,7 +12,7 @@ use crate::api::ipot::BrokerSummaryRaw;
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};
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary};
const SOURCE: &str = "ipot";
/// Longest range one summary may cover.
@ -47,6 +47,35 @@ pub struct Broker<'a> {
}
impl Broker<'_> {
/// Today in WIB, the date `BrokerRange::from_args` defaults `to` to.
pub fn today(&self) -> NaiveDate {
today_wib()
}
/// The summary a frontend asked for: the latest session or a range.
pub fn fetch(
&self,
symbol: &str,
range: BrokerRange,
investor: BrokerInvestor,
board: BrokerBoard,
policy: CachePolicy,
) -> Result<Fetched<BrokerSummary>, IdxError> {
match range {
BrokerRange::Latest => self.latest(symbol, investor, board, policy),
BrokerRange::Dates { from, to } => self.summary(
symbol,
&BrokerQuery {
from,
to,
investor,
board,
},
policy,
),
}
}
/// The broker summary for `symbol` over `query`'s range. An empty
/// summary means no trades (weekend, holiday, suspension).
pub fn summary(
@ -247,6 +276,38 @@ mod tests {
assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000));
}
#[test]
fn test_broker_range_from_args() {
let today = date(2026, 10, 18);
let range = |date, from, to| BrokerRange::from_args(date, from, to, today);
assert_eq!(range(None, None, None).unwrap(), BrokerRange::Latest);
assert_eq!(
range(Some("2026-10-16"), None, None).unwrap(),
BrokerRange::Dates {
from: date(2026, 10, 16),
to: date(2026, 10, 16)
}
);
assert_eq!(
range(None, Some("2026-10-12"), None).unwrap(),
BrokerRange::Dates {
from: date(2026, 10, 12),
to: today
}
);
for (d, f, t) in [
(None, None, Some("2026-10-16")),
(Some("2026-10-16"), Some("2026-10-12"), None),
(Some("16/10/2026"), None, None),
(None, Some("2026-10-12"), Some("tomorrow")),
] {
assert!(
matches!(range(d, f, t), Err(IdxError::InvalidInput(_))),
"{d:?} {f:?} {t:?}"
);
}
}
#[test]
fn test_check_range_bounds() {
let today = date(2026, 10, 18);

View file

@ -1,8 +1,12 @@
//! Broker summary types: which IDX brokers bought and sold a stock.
use std::str::FromStr;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::IdxError;
/// Whose trades a broker summary counts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
@ -18,6 +22,8 @@ pub enum BrokerInvestor {
}
impl BrokerInvestor {
pub const ALL: [Self; 3] = [Self::All, Self::Foreign, Self::Domestic];
pub fn as_str(self) -> &'static str {
match self {
Self::All => "all",
@ -27,6 +33,21 @@ impl BrokerInvestor {
}
}
impl FromStr for BrokerInvestor {
type Err = IdxError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"all" => Ok(Self::All),
"foreign" => Ok(Self::Foreign),
"domestic" => Ok(Self::Domestic),
_ => Err(IdxError::InvalidInput(format!(
"invalid investor '{value}'; use all, foreign or 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))]
@ -44,6 +65,8 @@ pub enum BrokerBoard {
}
impl BrokerBoard {
pub const ALL: [Self; 4] = [Self::All, Self::Regular, Self::Cash, Self::Negotiated];
pub fn as_str(self) -> &'static str {
match self {
Self::All => "all",
@ -54,6 +77,22 @@ impl BrokerBoard {
}
}
impl FromStr for BrokerBoard {
type Err = IdxError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"all" => Ok(Self::All),
"regular" => Ok(Self::Regular),
"cash" => Ok(Self::Cash),
"negotiated" => Ok(Self::Negotiated),
_ => Err(IdxError::InvalidInput(format!(
"invalid board '{value}'; use all, regular, cash or 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)]
@ -75,6 +114,49 @@ impl BrokerKind {
}
}
/// Which sessions a frontend asked for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrokerRange {
/// The latest session with trades.
Latest,
/// `from..=to`, WIB dates.
Dates { from: NaiveDate, to: NaiveDate },
}
impl BrokerRange {
/// From a frontend's `date`, or `from` with an optional `to` (default
/// today), as `YYYY-MM-DD`; none of them means the latest session.
pub fn from_args(
date: Option<&str>,
from: Option<&str>,
to: Option<&str>,
today: NaiveDate,
) -> Result<Self, IdxError> {
let parse = |name: &str, value: &str| {
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
})
};
match (date, from, to) {
(None, None, None) => Ok(Self::Latest),
(Some(date), None, None) => {
let day = parse("date", date)?;
Ok(Self::Dates { from: day, to: day })
}
(None, Some(from), to) => Ok(Self::Dates {
from: parse("from", from)?,
to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today),
}),
(None, None, Some(_)) => Err(IdxError::InvalidInput(
"to needs from (or use date for one session)".to_string(),
)),
(Some(_), _, _) => Err(IdxError::InvalidInput(
"date cannot be combined with from or to".to_string(),
)),
}
}
}
/// The trading days a broker summary covers, both ends included (WIB dates).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrokerQuery {