mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(core): no broker totals for one investor side
With fd=F or fd=D, IPOT's footer covers only that side's buying: BBCA's foreign/regular summary for 2026-10-16 gives T. Val 1.6 T while its top seller alone sold 2.0 T. Passing it through as the range total gave consumers a denominator smaller than one listed broker, so total_value, total_lots and average_price are now null for an investor-filtered summary. The page-to-summary step is a pure function, tested on both fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
2c93c73b6a
commit
7c8151a3a6
3 changed files with 77 additions and 26 deletions
|
|
@ -8,6 +8,7 @@ use std::sync::OnceLock;
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||||
|
|
||||||
|
use crate::api::ipot::BrokerSummaryRaw;
|
||||||
use crate::api::{broker_provider, today_wib};
|
use crate::api::{broker_provider, today_wib};
|
||||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -80,28 +81,7 @@ impl Broker<'_> {
|
||||||
},
|
},
|
||||||
policy,
|
policy,
|
||||||
|| provider.broker_summary(&code, query),
|
|| provider.broker_summary(&code, query),
|
||||||
|raw| {
|
|raw| Ok(summary_from_raw(&resolved, query, raw)),
|
||||||
let filtered =
|
|
||||||
(query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All);
|
|
||||||
let mut summary = BrokerSummary {
|
|
||||||
symbol: resolved.clone(),
|
|
||||||
from: raw.from.unwrap_or(query.from),
|
|
||||||
to: raw.to.unwrap_or(query.to),
|
|
||||||
investor: query.investor,
|
|
||||||
board: query.board,
|
|
||||||
buyers: raw.buyers,
|
|
||||||
sellers: raw.sellers,
|
|
||||||
total_value: raw.total_value,
|
|
||||||
total_lots: raw.total_lots,
|
|
||||||
average_price: raw.average_price,
|
|
||||||
foreign_net_value: raw.foreign_net_value.filter(|_| !filtered),
|
|
||||||
rounded: raw.rounded,
|
|
||||||
};
|
|
||||||
for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) {
|
|
||||||
flow.name = broker_name(&flow.code).map(str::to_string);
|
|
||||||
}
|
|
||||||
Ok(summary)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +137,33 @@ impl Broker<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The public summary of one parsed page. With one investor side selected,
|
||||||
|
/// the page's footer covers only part of the trades the lists show (foreign
|
||||||
|
/// buying, not foreign selling), so no totals are given; `F. NVal` ignores
|
||||||
|
/// both filters, so the foreign net goes with any filter.
|
||||||
|
fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary {
|
||||||
|
let one_side = query.investor != BrokerInvestor::All;
|
||||||
|
let filtered = one_side || query.board != BrokerBoard::All;
|
||||||
|
let mut summary = BrokerSummary {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
from: raw.from.unwrap_or(query.from),
|
||||||
|
to: raw.to.unwrap_or(query.to),
|
||||||
|
investor: query.investor,
|
||||||
|
board: query.board,
|
||||||
|
buyers: raw.buyers,
|
||||||
|
sellers: raw.sellers,
|
||||||
|
total_value: raw.total_value.filter(|_| !one_side),
|
||||||
|
total_lots: raw.total_lots.filter(|_| !one_side),
|
||||||
|
average_price: raw.average_price.filter(|_| !one_side),
|
||||||
|
foreign_net_value: raw.foreign_net_value.filter(|_| !filtered),
|
||||||
|
rounded: raw.rounded,
|
||||||
|
};
|
||||||
|
for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) {
|
||||||
|
flow.name = broker_name(&flow.code).map(str::to_string);
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> {
|
fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> {
|
||||||
if query.from > query.to {
|
if query.from > query.to {
|
||||||
return Err(IdxError::InvalidInput(format!(
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
|
@ -202,6 +209,44 @@ mod tests {
|
||||||
assert_eq!(broker_name("??"), None);
|
assert_eq!(broker_name("??"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_summary_from_raw_drops_totals_that_do_not_cover_the_lists() {
|
||||||
|
let day = date(2026, 10, 16);
|
||||||
|
let read = |name: &str| {
|
||||||
|
let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap();
|
||||||
|
crate::api::ipot::summary_from_html("BBCA", &html).unwrap()
|
||||||
|
};
|
||||||
|
let all = summary_from_raw(
|
||||||
|
"BBCA.JK",
|
||||||
|
&query(day, day),
|
||||||
|
read("ipot_brokersummary_bbca_20261016.html"),
|
||||||
|
);
|
||||||
|
assert_eq!(all.total_value, Some(6_600_000_000_000));
|
||||||
|
assert_eq!(all.foreign_net_value, Some(-2_300_000_000_000));
|
||||||
|
assert_eq!(all.sellers[0].name.as_deref(), Some("MANDIRI SEKURITAS"));
|
||||||
|
|
||||||
|
// Foreign, regular board: the footer's 1.6 T is less than the 2.0 T
|
||||||
|
// its top seller alone sold.
|
||||||
|
let foreign = BrokerQuery {
|
||||||
|
investor: BrokerInvestor::Foreign,
|
||||||
|
board: BrokerBoard::Regular,
|
||||||
|
..query(day, day)
|
||||||
|
};
|
||||||
|
let raw = read("ipot_brokersummary_bbca_20261016_foreign_regular.html");
|
||||||
|
assert!(raw.total_value < raw.sellers[0].value);
|
||||||
|
let filtered = summary_from_raw("BBCA.JK", &foreign, raw);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
filtered.total_value,
|
||||||
|
filtered.total_lots,
|
||||||
|
filtered.average_price
|
||||||
|
),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(filtered.foreign_net_value, None);
|
||||||
|
assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_range_bounds() {
|
fn test_check_range_bounds() {
|
||||||
let today = date(2026, 10, 18);
|
let today = date(2026, 10, 18);
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,15 @@ pub struct BrokerSummary {
|
||||||
pub buyers: Vec<BrokerFlow>,
|
pub buyers: Vec<BrokerFlow>,
|
||||||
/// Top sellers by lots, most first (up to 10).
|
/// Top sellers by lots, most first (up to 10).
|
||||||
pub sellers: Vec<BrokerFlow>,
|
pub sellers: Vec<BrokerFlow>,
|
||||||
/// Value traded over the range, whole IDR.
|
/// Value traded over the range on the selected board, whole IDR. `None`
|
||||||
|
/// with one investor side selected: the source's footer then covers only
|
||||||
|
/// that side's buying, not the trades the lists show.
|
||||||
pub total_value: Option<i64>,
|
pub total_value: Option<i64>,
|
||||||
/// Lots traded over the range.
|
/// Lots traded over the range on the selected board; `None` as for
|
||||||
|
/// `total_value`.
|
||||||
pub total_lots: Option<i64>,
|
pub total_lots: Option<i64>,
|
||||||
/// Average traded price, whole IDR; `None` when nothing traded.
|
/// Average traded price, whole IDR; `None` when nothing traded, or as
|
||||||
|
/// for `total_value`.
|
||||||
pub average_price: Option<i64>,
|
pub average_price: Option<i64>,
|
||||||
/// Foreign buy value minus foreign sell value over the range, whole IDR,
|
/// Foreign buy value minus foreign sell value over the range, whole IDR,
|
||||||
/// across all boards. The source does not apply the investor or board
|
/// across all boards. The source does not apply the investor or board
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ WIB as the fallback.
|
||||||
- Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled
|
- Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled
|
||||||
in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the
|
in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the
|
||||||
investor and board filters, so `foreign_net_value` is null for a filtered
|
investor and board filters, so `foreign_net_value` is null for a filtered
|
||||||
summary.
|
summary. With `fd=F` or `fd=D` the footer covers only that side's buying
|
||||||
|
(BBCA 2026-10-16 foreign: `T. Val` 1.6 T while seller CC alone sold
|
||||||
|
2.0 T), so totals are null for an investor-filtered summary.
|
||||||
- An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The
|
- An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The
|
||||||
server also answers spurious nginx 404s for seconds at a time, so the client
|
server also answers spurious nginx 404s for seconds at a time, so the client
|
||||||
retries 404 and 5xx with backoff. A range without trades is an empty summary.
|
retries 404 and 5xx with backoff. A range without trades is an empty summary.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue