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:
hermes 2026-10-18 04:28:38 +00:00
commit 2c93c73b6a
16 changed files with 1767 additions and 1 deletions

View file

@ -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 {