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);