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

@ -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)
}
}