mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
7c8151a3a6
commit
860d4daf77
25 changed files with 939 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue