diff --git a/Cargo.lock b/Cargo.lock index 306781f..070ab65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + [[package]] name = "csv" version = "1.4.0" @@ -444,6 +467,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "difflib" version = "0.4.0" @@ -501,12 +545,33 @@ dependencies = [ "litrs", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -732,6 +797,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.4.0" @@ -910,6 +985,7 @@ dependencies = [ "fastrand", "quick-xml", "rusqlite", + "scraper", "serde", "serde_json", "sha2", @@ -1025,6 +1101,17 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1047,6 +1134,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" @@ -1149,6 +1242,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1176,6 +1322,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "3.1.4" @@ -1377,6 +1529,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1469,6 +1636,45 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1532,6 +1738,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1555,6 +1770,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -1573,6 +1794,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1618,6 +1863,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "termtree" version = "0.5.1" @@ -2005,6 +2259,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/ROADMAP.md b/ROADMAP.md index 319318d..56cf036 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,13 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) +- Order book (bid/offer depth): no free, login-free source exists. IDX sells + delayed depth to vendors and Stockbit's needs a user login. Revisit if a + permitted source appears; do not ship the closing best bid/offer as one. +- Bandarmology follow-ups: a daily series (per-session readings and the + cumulative top-N net) and a price-move phase (quiet accumulation, markup), + calibration of the `top_concentration_v1` thresholds across more sessions, + and a market-wide foreign flow screen from one IDX trading summary - News beyond MSN in `news` / `get_company` (`docs/IDX_NEWS.md`): Google News RSS per ticker, and IDX disclosures and exchange news through a maintainer-published feed @@ -26,6 +33,12 @@ history lives in git. - Composite `analyze` command (quote, fundamentals, ownership, sentiment) ## Known limits +- `stocks broker` and `stocks bandar` show only IPOT's top 10 buyers and + sellers, ranked by gross lots, with figures of a million and more rounded + to one decimal. +- `stocks flow --source idx` depends on IDX's Cloudflare passing a + curl-impersonate profile (only `curl_safari184` in October 2026) and on the + network; the default IPOT source rounds to one decimal. - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 21f14ab..d27e576 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,18 +4,26 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - render_compare, render_earnings, render_financials, render_fundamental, render_growth, - render_history, render_insights, render_news, render_profile, render_quotes, render_risk, - render_screener, render_sentiment, render_technical, render_valuation, + render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, + render_foreign_flow, render_fundamental, render_growth, render_history, render_insights, + render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, + render_technical, render_valuation, }; +use idx_core::analysis::BandarQuery; +use idx_core::analysis::bandar::DEFAULT_TOP; use idx_core::api::types::{Interval, Period}; +use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; +use idx_core::flow::types::{FlowRange, FlowSource}; use idx_core::market::ScreenQuery; use idx_core::{CachePolicy, Idx, runtime}; /// Splits comma-separated symbol arguments; `-` reads symbols from stdin, /// separated by whitespace or commas. +/// Most symbols one `bandar` screen fetches, one request each. +const MAX_BANDAR_SYMBOLS: usize = 100; + fn expand_symbols(args: &[String]) -> Result, IdxError> { let mut symbols = Vec::new(); for arg in args { @@ -76,6 +84,37 @@ impl From for EarningsFilter { } } +/// Which sessions and trades a broker command covers. +#[derive(Debug, Clone, Args)] +pub struct BrokerScopeArgs { + /// One session, YYYY-MM-DD. Default: the latest session with trades. + #[arg(long, conflicts_with_all = ["from", "to"])] + date: Option, + /// First session of a range, YYYY-MM-DD (at most 366 days). + #[arg(long)] + from: Option, + /// Last session of the range, YYYY-MM-DD. Default: today. + #[arg(long, requires = "from")] + to: Option, + /// Whose trades to count. + #[arg(long, value_enum, default_value_t = BrokerInvestor::All)] + investor: BrokerInvestor, + /// Which board's trades to count; `regular` leaves out negotiated block trades. + #[arg(long, value_enum, default_value_t = BrokerBoard::All)] + board: BrokerBoard, +} + +impl BrokerScopeArgs { + fn range(&self, idx: &Idx) -> Result { + BrokerRange::from_args( + self.date.as_deref(), + self.from.as_deref(), + self.to.as_deref(), + idx.broker().today(), + ) + } +} + #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] pub struct StocksCmd { @@ -150,6 +189,65 @@ pub enum StocksSubcommand { /// Single ticker symbol (e.g. BBCA). symbol: String, }, + #[command( + about = "Which brokers bought and sold a stock", + long_about = "Which brokers bought and sold a stock: the top 10 buyers and top 10 \ +sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX \ +does not show broker codes during trading. Without dates, the latest session with trades.", + after_help = "Examples:\n idx stocks broker BBCA\n idx stocks broker BBCA --date 2026-10-16\n idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular\n idx -o json stocks broker BBCA --investor foreign" + )] + Broker { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + #[command(flatten)] + scope: BrokerScopeArgs, + }, + #[command( + about = "Bandarmology: is buying more concentrated than selling", + long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \ +against the same number of sellers', as a share of value traded, with a signal from big \ +accumulation to big distribution. Several symbols give a screen ranked by the headline tier \ +(at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers \ +against its buying and its sellers against its selling (needs `--board all`). A \ +heuristic over end-of-day broker codes, not advice.", + after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -" + )] + Bandar { + /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. + #[arg(required = true)] + symbols: Vec, + #[command(flatten)] + scope: BrokerScopeArgs, + /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side. + #[arg(long, default_value_t = DEFAULT_TOP)] + top: u8, + }, + #[command( + about = "Foreign buying and selling of a stock, session by session", + long_about = "Foreign buying and selling of a stock, session by session, end of day. By \ +default from Indo Premier (IPOT): foreign buy, sell and net value across all boards and \ +foreign investors' share of trading, two plain requests per session. `--source idx` reads \ +IDX's own trading summary instead: exact foreign share counts, the close and the share \ +traded outside the regular board, one request per session to idx.co.id, which needs \ +curl-impersonate and a network IDX's Cloudflare lets through.", + after_help = "Examples:\n idx stocks flow BBCA\n idx stocks flow BBCA --days 20\n idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16\n idx stocks flow BBCA --source idx\n idx -o json stocks flow BBCA | jq '.foreign_net_value'" + )] + Flow { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + /// Latest sessions to show (default 10, at most 60). + #[arg(long, conflicts_with_all = ["from", "to"])] + days: Option, + /// First session of a range, YYYY-MM-DD (at most 92 days). + #[arg(long)] + from: Option, + /// Last session of the range, YYYY-MM-DD. Default: today. + #[arg(long, requires = "from")] + to: Option, + /// Where the figures come from. + #[arg(long, value_enum, default_value_t = FlowSource::Ipot)] + source: FlowSource, + }, #[command(about = "Get company profile")] Profile { symbol: String }, #[command( @@ -256,6 +354,53 @@ pub fn handle( let report = idx.analysis().fundamental(symbol, policy)?; render_fundamental(&report.data, format, no_color) } + StocksSubcommand::Broker { symbol, scope } => { + let summary = idx.broker().fetch( + symbol, + scope.range(&idx)?, + scope.investor, + scope.board, + policy, + )?; + render_broker_summary(&summary.data, format) + } + StocksSubcommand::Bandar { + symbols, + scope, + top, + } => { + let symbols = expand_symbols(symbols)?; + if symbols.len() > MAX_BANDAR_SYMBOLS { + return Err(IdxError::InvalidInput(format!( + "at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})", + symbols.len() + ))); + } + let query = BandarQuery { + range: scope.range(&idx)?, + investor: scope.investor, + board: scope.board, + top: *top, + }; + let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?; + for (resolved, err) in screen.failures { + runtime::warn(format!("failed to read brokers for {resolved}: {err}")); + } + let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect(); + render_bandar(&reports, symbols.len() > 1, format, no_color) + } + StocksSubcommand::Flow { + symbol, + days, + from, + to, + source, + } => { + let range = + FlowRange::from_args(*days, from.as_deref(), to.as_deref(), idx.flow().today())?; + let flow = idx.flow().foreign(symbol, range, *source, policy)?; + render_foreign_flow(&flow.data, format) + } StocksSubcommand::Profile { symbol } => { let profile = idx.financial().profile(symbol, policy)?; render_profile(&profile.data, format) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index f9057b2..3429e04 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -8,6 +8,7 @@ use std::io::IsTerminal; use clap::ValueEnum; use serde::Serialize; +use idx_core::analysis::bandar::BandarReport; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -16,7 +17,9 @@ use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; +use idx_core::broker::types::BrokerSummary; use idx_core::error::IdxError; +use idx_core::flow::types::ForeignFlow; /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] @@ -73,6 +76,38 @@ pub fn render_quotes( } } +pub fn render_broker_summary( + summary: &BrokerSummary, + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_broker_summary(summary), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(summary), + } +} + +/// One symbol gets the detailed table, several the ranked screen (even when +/// only one of them succeeded); JSON is always the array. +pub fn render_bandar( + reports: &[BandarReport], + screen: bool, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match (format, reports) { + (OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color), + (OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color), + (OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports), + } +} + +pub fn render_foreign_flow(flow: &ForeignFlow, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_foreign_flow(flow), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(flow), + } +} + pub fn render_history( symbol: &str, history: &[Ohlc], diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 05f0907..4481b62 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -2,6 +2,9 @@ use crate::output::style::Paint; use crate::output::text::{cell, painted, terminal_safe, truncate_chars}; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; +use idx_core::analysis::bandar::{ + ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD, +}; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -11,7 +14,9 @@ use idx_core::api::types::{ CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; +use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; +use idx_core::flow::types::{FlowSource, ForeignFlow}; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -89,19 +94,29 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { } /// A quote whose last trade is over a week old is almost always a suspended -/// stock; say so under the table instead of letting it pass as current. +/// stock; say so under the table instead of letting it pass as current. One +/// from before the latest session is usually the provider lagging the close. fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime) -> Vec { quotes .iter() .filter_map(|q| { let as_of = q.as_of?; - (now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| { - format!( + if now.signed_duration_since(as_of) > chrono::Duration::days(7) { + Some(format!( "Note: {} last traded on {}; this price is not current.", q.symbol, as_of.date_naive() - ) - }) + )) + } else if q.behind_session { + Some(format!( + "Note: {} last traded on {}, before the latest IDX session; \ + this price may be out of date.", + q.symbol, + as_of.date_naive() + )) + } else { + None + } }) .collect() } @@ -893,6 +908,483 @@ fn truncate_url(url: &str) -> String { truncate_chars(url, 72) } +/// A figure at the precision the broker summary source publishes: +/// `841.2B`, `1.6M`, exact below a million. +fn format_compact(value: i64) -> String { + let abs = value.unsigned_abs(); + let (unit, suffix) = match abs { + 1_000_000_000_000.. => (1e12, "T"), + 1_000_000_000.. => (1e9, "B"), + 1_000_000.. => (1e6, "M"), + _ => return format_idr(value), + }; + format!("{:.1}{suffix}", value as f64 / unit) +} + +fn format_compact_option(value: Option) -> String { + value.map(format_compact).unwrap_or_else(|| "-".to_string()) +} + +fn broker_cells(flow: Option<&BrokerFlow>) -> [Cell; 4] { + match flow { + Some(flow) => [ + cell(&flow.code), + cell(format_compact_option(flow.lots)), + cell(format_compact_option(flow.value)), + cell( + flow.average_price + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + ], + None => [cell(""), cell(""), cell(""), cell("")], + } +} + +/// `2026-10-16`, or `2026-10-12 to 2026-10-16`, plus any filters. +pub(crate) fn broker_scope( + from: chrono::NaiveDate, + to: chrono::NaiveDate, + investor: BrokerInvestor, + board: BrokerBoard, +) -> String { + let mut scope = if from == to { + from.to_string() + } else { + format!("{from} to {to}") + }; + if investor != BrokerInvestor::All { + scope.push_str(&format!(", {} investors", investor.as_str())); + } + if board != BrokerBoard::All { + scope.push_str(&format!(", {} board", board.as_str())); + } + scope +} + +/// What one investor side bought and sold, when a summary covers one side. +fn side_totals_line( + investor: BrokerInvestor, + board: BrokerBoard, + buy_value: Option, + sell_value: Option, + foreign_net: Option, +) -> Option { + let side = match investor { + BrokerInvestor::All => return None, + BrokerInvestor::Foreign => "Foreign", + BrokerInvestor::Domestic => "Domestic", + }; + let buy = format_compact_option(buy_value); + Some(match sell_value { + Some(sell) => { + let net = foreign_net + .map(|net| match investor { + BrokerInvestor::Domestic => -net, + _ => net, + }) + .map(|net| format!(" (net {:+})", format_compact(net))) + .unwrap_or_default(); + format!( + "{side} investors bought {buy} IDR and sold {} IDR{net}.", + format_compact(sell) + ) + } + None => format!( + "{side} investors bought {buy} IDR on the {} board; the source gives one side's \ +selling only across all boards.", + board.as_str() + ), + }) +} + +pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Broker summary for {}, {}", + terminal_safe(&summary.symbol), + broker_scope(summary.from, summary.to, summary.investor, summary.board) + ) + .bold() + ); + if summary.is_empty() { + println!("No trades."); + return Ok(()); + } + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "#", "BUYER", "LOTS", "VALUE", "AVG", "SELLER", "LOTS", "VALUE", "AVG", + ]); + let rows = summary.buyers.len().max(summary.sellers.len()); + for index in 0..rows { + let mut row = vec![cell(index + 1)]; + row.extend(broker_cells(summary.buyers.get(index))); + row.extend(broker_cells(summary.sellers.get(index))); + table.add_row(row); + } + println!("{table}"); + + if summary.total_value.is_some() { + let mut totals = format!( + "Total {} IDR, {} lots", + format_compact_option(summary.total_value), + format_compact_option(summary.total_lots) + ); + if let Some(average) = summary.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = summary.foreign_net_value { + totals.push_str(&format!("; foreign net {:+}", format_compact(net))); + } + println!("{totals}."); + } else if let Some(line) = side_totals_line( + summary.investor, + summary.board, + summary.buy_value, + summary.sell_value, + summary.foreign_net_value, + ) { + println!("{line}"); + } + for (kind, label) in [ + (BrokerKind::Foreign, "Foreign brokers"), + (BrokerKind::StateOwned, "State-owned brokers"), + ] { + let mut codes: Vec<&str> = summary + .buyers + .iter() + .chain(&summary.sellers) + .filter(|flow| flow.kind == Some(kind)) + .map(|flow| flow.code.as_str()) + .collect(); + codes.sort_unstable(); + codes.dedup(); + if !codes.is_empty() { + println!("{label}: {}.", terminal_safe(&codes.join(", "))); + } + } + println!("Buyers and sellers are each ranked by lots; a broker can be on both sides."); + if summary.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +fn format_bps_pct(bps: Option) -> String { + bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bps_share(bps: Option) -> String { + bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bandar_signal(signal: Option, no_color: bool) -> String { + let Some(signal) = signal else { + return "-".to_string(); + }; + let label = signal.as_str().replace('_', " "); + if no_color { + return label; + } + match signal { + BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(), + BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(), + BandarSignal::Neutral => label, + } +} + +fn bandar_method_note(top: u8) -> String { + format!( + "Method {BANDAR_METHOD}: the top {top} buyers' share of the value bought minus the top \ +{top} sellers' share of the value sold; {}% or more reads accumulation, {}% or more big \ +(negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.", + ACCUMULATION_BPS / 100, + BIG_BPS / 100 + ) +} + +pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Bandar reading for {}, {}", + terminal_safe(&report.symbol), + broker_scope(report.from, report.to, report.investor, report.board) + ) + .bold() + ); + // Brokers on either side mean trades, even when a total is unavailable. + if report + .tiers + .iter() + .all(|tier| tier.buy_value == Some(0) && tier.sell_value == Some(0)) + { + println!("No trades."); + return Ok(()); + } + println!( + "Signal (top {}): {}, {} of value", + report.top, + format_bandar_signal(report.signal, no_color), + format_bps_pct(report.imbalance_bps) + ); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL", + ]); + for tier in &report.tiers { + table.add_row(vec![ + cell(tier.top), + cell(format_compact_option(tier.buy_value)), + cell(format_compact_option(tier.sell_value)), + cell(format_compact_option(tier.imbalance_value)), + cell(format_bps_pct(tier.imbalance_bps)), + cell( + tier.buy_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + cell( + tier.sell_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + painted(format_bandar_signal(tier.signal, no_color)), + ]); + } + println!("{table}"); + if let Some(line) = side_totals_line( + report.investor, + report.board, + report.buy_value, + report.sell_value, + report.foreign_net_value, + ) { + println!("{line}"); + } else { + let mut totals = format!("Total {} IDR", format_compact_option(report.total_value)); + if let Some(average) = report.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = report.foreign_net_value { + totals.push_str(&format!( + "; foreign net {:+} ({} of value)", + format_compact(net), + format_bps_pct(report.foreign_net_bps) + )); + } + println!("{totals}."); + } + if !report.both_sides.is_empty() { + println!( + "On both sides: {}; their gross figures overstate which way they traded.", + terminal_safe(&report.both_sides.join(", ")) + ); + } + println!("{}", bandar_method_note(report.top)); + if report.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> { + let top = reports.first().map(|report| report.top).unwrap_or_default(); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "SYMBOL".to_string(), + "DATES".to_string(), + "SIGNAL".to_string(), + format!("TOP {top} NET %"), + "BUY SHARE".to_string(), + "SELL SHARE".to_string(), + "VALUE".to_string(), + "FOREIGN NET".to_string(), + ]); + for report in reports { + let headline = report.tiers.iter().find(|tier| tier.top == report.top); + table.add_row(vec![ + cell(&report.symbol), + cell(broker_scope( + report.from, + report.to, + BrokerInvestor::All, + BrokerBoard::All, + )), + painted(format_bandar_signal(report.signal, no_color)), + cell(format_bps_pct(report.imbalance_bps)), + cell(format_bps_share( + headline.and_then(|tier| tier.buy_share_bps), + )), + cell(format_bps_share( + headline.and_then(|tier| tier.sell_share_bps), + )), + cell(format_compact_option(report.total_value)), + cell( + report + .foreign_net_value + .map(|net| format!("{:+}", format_compact(net))) + .unwrap_or_else(|| "-".to_string()), + ), + ]); + } + println!("{table}"); + if let Some(first) = reports.first() + && (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All) + { + println!( + "Counting {} investors on the {} board.", + first.investor.as_str(), + first.board.as_str() + ); + } + println!("{}", bandar_method_note(top)); + Ok(()) +} + +fn format_signed_compact(value: Option) -> String { + value + .map(|value| { + let text = format_compact(value); + if value > 0 { format!("+{text}") } else { text } + }) + .unwrap_or_else(|| "-".to_string()) +} + +pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { + let span = if flow.from == flow.to { + flow.from.to_string() + } else { + format!("{} to {}", flow.from, flow.to) + }; + let sessions = flow.days.len(); + println!( + "{}", + format!( + "Foreign flow for {}, {span} ({sessions} session{}, {})", + terminal_safe(&flow.symbol), + if sessions == 1 { "" } else { "s" }, + match flow.source { + FlowSource::Ipot => "IPOT", + FlowSource::Idx => "IDX", + } + ) + .bold() + ); + match flow.source { + FlowSource::Ipot => print_ipot_flow(flow), + FlowSource::Idx => print_idx_flow(flow), + } + if flow.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +fn print_ipot_flow(flow: &ForeignFlow) { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "DATE", + "VALUE", + "FOREIGN BUY", + "FOREIGN SELL", + "FOREIGN NET", + "FOREIGN %", + ]); + for day in &flow.days { + table.add_row(vec![ + cell(day.date), + cell(format_compact_option(day.value)), + cell(format_compact_option(day.foreign_buy_value)), + cell(format_compact_option(day.foreign_sell_value)), + cell(format_signed_compact(day.foreign_net_value)), + cell(format_bps_share(day.foreign_share_bps)), + ]); + } + println!("{table}"); + let mut summary = format!( + "Foreign investors bought {} IDR and sold {} IDR: net {}", + format_compact_option(flow.foreign_buy_value), + format_compact_option(flow.foreign_sell_value), + format_signed_compact(flow.foreign_net_value) + ); + if let Some(share) = flow.foreign_share_bps { + summary.push_str(&format!( + "; they made up {} of trading", + format_bps_share(Some(share)) + )); + } + println!("{summary}."); + println!( + "Values in IDR across all boards. For exact share counts, the close and the non-regular \ +share, use --source idx (needs curl-impersonate)." + ); +} + +fn print_idx_flow(flow: &ForeignFlow) { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "DATE", + "CLOSE", + "CHG%", + "VALUE", + "FOREIGN BUY", + "FOREIGN SELL", + "FOREIGN NET", + "NET VALUE", + "NON-REG", + ]); + for day in &flow.days { + table.add_row(vec![ + cell(day.date), + cell(day.close.map(format_idr).unwrap_or_else(|| "-".to_string())), + cell(format_bps_pct(day.change_bps)), + cell(format_compact_option(day.value)), + cell(format_compact_option(day.foreign_buy)), + cell(format_compact_option(day.foreign_sell)), + cell(format_signed_compact(day.foreign_net)), + cell(format_signed_compact(day.foreign_net_value)), + cell(format_bps_share(day.non_regular_bps)), + ]); + } + println!("{table}"); + let mut summary = format!( + "Net foreign: {} shares, about {} IDR", + format_signed_compact(flow.foreign_net), + format_signed_compact(flow.foreign_net_value) + ); + if let Some(share) = flow.foreign_share_bps { + summary.push_str(&format!( + "; foreign investors made up {} of trading", + format_bps_share(Some(share)) + )); + } + println!("{summary}."); + println!( + "Volume, value and foreign figures are the regular board, volumes in shares. Net value \ +is estimated at each session's average price; NON-REG is the share of all value traded \ +outside the regular board (negotiated and cash)." + ); +} + #[cfg(test)] mod tests { use super::{ @@ -917,6 +1409,7 @@ mod tests { prev_close: None, avg_volume: None, as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), + behind_session: false, } } @@ -936,6 +1429,30 @@ mod tests { ); } + #[test] + fn notes_quotes_behind_the_latest_session() { + let now = chrono::DateTime::parse_from_rfc3339("2026-10-16T16:53:00+07:00") + .unwrap() + .to_utc(); + let mut behind = quote_as_of("BBCA.JK", "2026-10-15T16:15:00+07:00"); + behind.behind_session = true; + let mut suspended = quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"); + suspended.behind_session = true; + let quotes = [ + behind, + suspended, + quote_as_of("BBRI.JK", "2026-10-16T16:15:00+07:00"), + ]; + assert_eq!( + stale_quote_notes("es, now), + vec![ + "Note: BBCA.JK last traded on 2026-10-15, before the latest IDX session; \ + this price may be out of date.", + "Note: WIKA.JK last traded on 2025-02-17; this price is not current.", + ] + ); + } + #[test] fn formats_idr_numbers() { assert_eq!(format_idr(9875), "9,875"); diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 0fb9812..abdade7 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2407,6 +2407,68 @@ fn msn_screen_ranks_complete_candidates_and_caches_before_limit() { } } +#[test] +fn msn_screen_flags_idx_rows_behind_the_session() { + let root = test_env_dir("screen-behind-session"); + let fixture = write_large_screener_fixture(&root, Some(503)); + let behind = |rows: &serde_json::Value| -> Vec { + rows.as_array() + .unwrap() + .iter() + .map(|row| row["behind_session"].as_bool().unwrap()) + .collect() + }; + // Every fixture row last traded on 2026-01-02, long before any session now. + let fresh = run_success_json(&mut screener_fixture_command( + &root, + &fixture, + "top-performers", + 3, + )); + assert_eq!(behind(&fresh), [true; 3]); + let mut offline = screener_fixture_command(&root, &fixture, "top-performers", 3); + offline.env("IDX_MOCK_ERROR", "1").arg("--offline"); + assert_eq!(behind(&run_success_json(&mut offline)), [true; 3]); + // IDX's session rule says nothing about other exchanges. + let mut us = screener_fixture_command(&root, &fixture, "top-performers", 3); + us.args(["--region", "us"]); + assert_eq!(behind(&run_success_json(&mut us)), [false; 3]); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn quote_read_from_a_0_3_3_cache_entry_is_behind_the_session() { + // IDX's session rule says nothing about other exchanges. + for (symbol, file, behind) in [ + ("BBCA", "BBCA.JK.json", true), + ("AAPL.US", "AAPL.US.json", false), + ] { + let root = test_env_dir(&format!("quote-behind-session-{symbol}")); + let mut warm = mock_bin_with_root(&root); + run_success_json(warm.args(["-o", "json", "stocks", "quote", symbol])); + let entry = fs::read_dir(root.join("cache")) + .unwrap() + .flat_map(|dir| fs::read_dir(dir.unwrap().path()).unwrap()) + .flat_map(|bucket| fs::read_dir(bucket.unwrap().path()).unwrap()) + .map(|file| file.unwrap().path()) + .find(|path| path.ends_with(file)) + .expect("cached quote"); + // 0.3.3 wrote quotes without the field; this one last traded long ago. + let mut cached: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&entry).unwrap()).unwrap(); + let data = cached["data"].as_object_mut().unwrap(); + data.remove("behind_session") + .expect("field is cached unmarked"); + data.insert("as_of".into(), "2026-01-02T16:15:00+07:00".into()); + fs::write(&entry, cached.to_string()).unwrap(); + let mut offline = mock_bin_with_root(&root); + let quotes = + run_success_json(offline.args(["--offline", "-o", "json", "stocks", "quote", symbol])); + assert_eq!(quotes[0]["behind_session"], behind, "{symbol}"); + fs::remove_dir_all(root).unwrap(); + } +} + #[test] fn msn_screen_rejects_invalid_totals_without_populating_cache() { for (name, count) in [("missing", None), ("incomplete", Some(504))] { @@ -2434,3 +2496,629 @@ fn msn_screen_rejects_invalid_totals_without_populating_cache() { fs::remove_dir_all(root).unwrap(); } } + +/// A command on the fixture-backed mock provider, never the network. +fn mock_bin(name: &str) -> Command { + let mut cmd = test_bin(name); + cmd.env("IDX_USE_MOCK_PROVIDER", "1"); + cmd +} + +fn mock_bin_with_root(root: &Path) -> Command { + let mut cmd = bin_with_root(root); + cmd.env("IDX_USE_MOCK_PROVIDER", "1"); + cmd +} + +#[test] +fn broker_summary_json_names_brokers_and_keeps_source_totals() { + let summary = run_success_json(mock_bin("broker-json").args([ + "-o", + "json", + "stocks", + "broker", + "bbca", + "--date", + "2026-10-16", + ])); + assert_eq!(summary["symbol"], "BBCA.JK"); + assert_eq!( + (summary["from"].as_str(), summary["to"].as_str()), + (Some("2026-10-16"), Some("2026-10-16")) + ); + assert_eq!(summary["buyers"].as_array().unwrap().len(), 10); + let top = &summary["buyers"][0]; + assert_eq!(top["code"], "MG"); + assert_eq!(top["name"], "SEMESTA INDOVEST SEKURITAS"); + assert_eq!(top["kind"], "local"); + assert_eq!(top["lots"], 1_600_000); + assert_eq!(top["value"], 841_200_000_000_i64); + assert_eq!(top["average_price"], 5_191); + assert_eq!(summary["sellers"][0]["kind"], "state_owned"); + assert_eq!(summary["total_value"], 6_600_000_000_000_i64); + assert_eq!(summary["foreign_net_value"], -2_300_000_000_000_i64); + assert_eq!(summary["rounded"], true); + + // The source does not filter its foreign net figure, so a filtered + // summary leaves it out rather than passing it off as filtered. + let filtered = run_success_json(mock_bin("broker-filtered").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + "--board", + "regular", + ])); + assert_eq!(filtered["investor"], "foreign"); + assert_eq!(filtered["board"], "regular"); + assert_eq!(filtered["buyers"][0]["code"], "KZ"); + for field in [ + "total_value", + "total_lots", + "average_price", + "sell_value", + "foreign_net_value", + ] { + assert!(filtered[field].is_null(), "{field}: {filtered}"); + } + assert_eq!(filtered["buy_value"], 1_600_000_000_000_i64); + let table = run_success_stdout(mock_bin("broker-filtered-table").args([ + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + "--board", + "regular", + ])); + assert!(!table.contains("Total "), "{table}"); + assert!( + table.contains("Foreign investors bought 1.6T IDR on the regular board"), + "{table}" + ); + + // On all boards, the foreign net gives foreign selling. + let foreign = run_success_json(mock_bin("broker-foreign").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!(foreign["total_value"].is_null()); + assert_eq!(foreign["buy_value"], 1_700_000_000_000_i64); + assert_eq!(foreign["sell_value"], 4_000_000_000_000_i64); + assert_eq!(foreign["foreign_net_value"], -2_300_000_000_000_i64); +} + +#[test] +fn broker_summary_without_dates_finds_the_latest_session_offline() { + let root = test_env_dir("broker-latest"); + run_success_json(mock_bin_with_root(&root).args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + ])); + // Monday 2026-10-19 is not cached and the weekend is skipped, so the + // offline lookup lands on Friday's cached session. + let latest = run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-19") + .args(["-o", "json", "--offline", "stocks", "broker", "BBCA"]), + ); + assert_eq!(latest["from"], "2026-10-16"); + assert_eq!(latest["buyers"][0]["code"], "MG"); + run_error_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-19") + .args(["-o", "json", "--offline", "stocks", "broker", "BBRI"]), + "CACHEMISS", + ); +} + +#[test] +fn broker_summary_session_without_trades_is_empty_not_an_error() { + let summary = run_success_json(mock_bin("broker-empty").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-17", + ])); + assert_eq!(summary["buyers"], serde_json::json!([])); + assert_eq!(summary["total_value"], 0); + assert!(summary["average_price"].is_null()); + + let table = run_success_stdout(mock_bin("broker-empty-table").args([ + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-17", + ])); + assert!(table.contains("No trades."), "{table}"); +} + +#[test] +fn broker_summary_errors_follow_the_output_contract() { + for (args, code) in [ + (vec!["ZZZZ", "--date", "2026-10-16"], "SYMBOLNOTFOUND"), + (vec!["BBCA.US", "--date", "2026-10-16"], "UNSUPPORTED"), + (vec!["BBCA", "--date", "16/10/2026"], "INVALIDINPUT"), + ( + vec!["BBCA", "--from", "2026-10-16", "--to", "2026-10-12"], + "INVALIDINPUT", + ), + (vec!["BBCA", "--to", "2026-10-16"], "INVALIDINPUT"), + ( + vec!["BBCA", "--date", "2026-10-16", "--from", "2026-10-12"], + "INVALIDINPUT", + ), + (vec!["BBCA", "--date", "2999-01-01"], "INVALIDINPUT"), + ] { + let mut cmd = mock_bin("broker-errors"); + cmd.args(["-o", "json", "stocks", "broker"]).args(&args); + run_error_json(&mut cmd, code); + } + let mut failing = mock_bin("broker-provider-error"); + failing.env("IDX_MOCK_ERROR", "1").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + ]); + run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); +} + +#[test] +fn bandar_reads_the_broker_summary_tiers() { + let reports = run_success_json(mock_bin("bandar-json").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + let report = &reports[0]; + assert_eq!(reports.as_array().unwrap().len(), 1); + assert_eq!(report["symbol"], "BBCA.JK"); + assert_eq!(report["method"], "top_concentration_v1"); + let tiers: Vec = report["tiers"] + .as_array() + .unwrap() + .iter() + .map(|tier| tier["top"].as_i64().unwrap()) + .collect(); + assert_eq!(tiers, [1, 3, 5, 10]); + // Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded. + let top5 = &report["tiers"][2]; + assert_eq!(top5["buy_value"], 2_939_500_000_000_i64); + assert_eq!(top5["sell_value"], 4_540_400_000_000_i64); + assert_eq!(top5["imbalance_bps"], -2425); + assert_eq!(report["top"], 5); + assert_eq!(report["imbalance_bps"], -2425); + assert_eq!(report["signal"], "big_distribution"); + assert_eq!( + report["both_sides"], + serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"]) + ); +} + +#[test] +fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() { + let output = mock_bin("bandar-screen") + .args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA,ZZZZ", + "TLKM", + "BBRI", + "--date", + "2026-10-16", + "--top", + "3", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let reports: Value = serde_json::from_slice(&output.stdout).unwrap(); + let ranked: Vec<(&str, i64)> = reports + .as_array() + .unwrap() + .iter() + .map(|r| { + ( + r["symbol"].as_str().unwrap(), + r["imbalance_bps"].as_i64().unwrap(), + ) + }) + .collect(); + assert_eq!(ranked.len(), 3); + assert!( + ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1), + "{ranked:?}" + ); + assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3)); + let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line"); + assert_eq!(warning["level"], "warning"); + assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK")); + + // A one-symbol screen that loses a symbol still prints the screen table. + let table = run_success_stdout(mock_bin("bandar-screen-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA,ZZZZ", + "--date", + "2026-10-16", + ])); + assert!(table.contains("TOP 5 NET %"), "{table}"); +} + +#[test] +fn bandar_errors_follow_the_output_contract() { + let many: Vec = (0..101).map(|i| format!("S{i}")).collect(); + for (args, code) in [ + ( + vec!["BBCA".to_string(), "--top".into(), "4".into()], + "INVALIDINPUT", + ), + (vec![many.join(",")], "INVALIDINPUT"), + ( + vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()], + "SYMBOLNOTFOUND", + ), + ( + vec![ + "BBCA,BBRI".to_string(), + "--date".into(), + "2999-01-01".into(), + ], + "INVALIDINPUT", + ), + ( + vec![ + "BBCA".to_string(), + "--date".into(), + "2026-10-16".into(), + "--investor".into(), + "foreign".into(), + "--board".into(), + "regular".into(), + ], + "INVALIDINPUT", + ), + ] { + let mut cmd = mock_bin("bandar-errors"); + cmd.args(["-o", "json", "stocks", "bandar"]).args(&args); + run_error_json(&mut cmd, code); + } +} + +#[test] +fn foreign_flow_from_idx_reads_sessions_oldest_first() { + let flow = run_success_json( + mock_bin("flow-json") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", "json", "stocks", "flow", "BBCA", "--days", "3", "--source", "idx", + ]), + ); + assert_eq!(flow["symbol"], "BBCA.JK"); + let dates: Vec<&str> = flow["days"] + .as_array() + .unwrap() + .iter() + .map(|day| day["date"].as_str().unwrap()) + .collect(); + assert_eq!(dates, ["2026-10-14", "2026-10-15", "2026-10-16"]); + let last = &flow["days"][2]; + assert_eq!(last["close"], 5175); + assert_eq!(last["change_bps"], -1481); + assert_eq!(last["foreign_net"], 309_392_800_i64 - 757_227_900_i64); + assert_eq!(flow["from"], "2026-10-14"); + + // A range skips the weekend and the Saturday without trading. + let range = run_success_json( + mock_bin("flow-range") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "--source", + "idx", + "BBCA", + "--from", + "2026-10-12", + "--to", + "2026-10-17", + ]), + ); + assert_eq!(range["days"].as_array().unwrap().len(), 5); + let net: i64 = range["days"] + .as_array() + .unwrap() + .iter() + .map(|day| day["foreign_net"].as_i64().unwrap()) + .sum(); + assert_eq!(range["foreign_net"], net); +} + +#[test] +fn foreign_flow_from_idx_offline_uses_cached_sessions_only() { + let root = test_env_dir("flow-offline"); + run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "--source", + "idx", + "BBCA", + "--from", + "2026-10-15", + "--to", + "2026-10-16", + ]), + ); + // Another stock's flow for the same sessions needs no new request. + let offline = run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "--offline", + "stocks", + "flow", + "--source", + "idx", + "TLKM", + "--days", + "5", + ]), + ); + assert_eq!(offline["from"], "2026-10-15"); + assert_eq!(offline["days"].as_array().unwrap().len(), 2); + run_error_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "--offline", + "stocks", + "flow", + "--source", + "idx", + "TLKM", + "--from", + "2026-10-12", + "--to", + "2026-10-16", + ]), + "CACHEMISS", + ); +} + +#[test] +fn foreign_flow_from_ipot_by_default_gives_values_across_all_boards() { + let flow = run_success_json( + mock_bin("flow-ipot") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "BBCA", + "--from", + "2026-10-12", + "--to", + "2026-10-17", + ]), + ); + assert_eq!(flow["source"], "ipot"); + assert_eq!(flow["rounded"], true); + let days = flow["days"].as_array().unwrap(); + assert_eq!(days.len(), 5, "the Saturday had no trading"); + let last = &days[4]; + assert_eq!(last["date"], "2026-10-16"); + assert_eq!(last["value"], 6_600_000_000_000_i64); + assert_eq!(last["foreign_buy_value"], 1_700_000_000_000_i64); + assert_eq!(last["foreign_sell_value"], 4_000_000_000_000_i64); + assert_eq!(last["foreign_net_value"], -2_300_000_000_000_i64); + for field in ["close", "foreign_buy", "foreign_net", "non_regular_bps"] { + assert!(last[field].is_null(), "{field}"); + } + let net: i64 = days + .iter() + .map(|day| day["foreign_net_value"].as_i64().unwrap()) + .sum(); + assert_eq!(flow["foreign_net_value"], net); + assert!(flow["foreign_net"].is_null()); + + let table = run_success_stdout( + mock_bin("flow-ipot-table") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args(["stocks", "flow", "BBCA", "--days", "1"]), + ); + assert!(table.contains("(1 session, IPOT)"), "{table}"); + assert!(table.contains("FOREIGN %"), "{table}"); +} + +#[test] +fn foreign_flow_errors_follow_the_output_contract() { + for (args, code) in [ + (vec!["ZZZZ", "--days", "2"], "SYMBOLNOTFOUND"), + (vec!["BBCA.US"], "UNSUPPORTED"), + (vec!["BBCA", "--days", "61"], "INVALIDINPUT"), + (vec!["BBCA", "--days", "0"], "INVALIDINPUT"), + ( + vec!["BBCA", "--days", "3", "--from", "2026-10-12"], + "INVALIDINPUT", + ), + ( + vec!["BBCA", "--from", "2026-06-01", "--to", "2026-10-16"], + "INVALIDINPUT", + ), + (vec!["BBCA", "--source", "stockbit"], "INVALIDINPUT"), + ( + vec!["ZZZZ", "--days", "2", "--source", "idx"], + "SYMBOLNOTFOUND", + ), + ] { + let mut cmd = mock_bin("flow-errors"); + cmd.env("IDX_MOCK_TODAY", "2026-10-18") + .args(["-o", "json", "stocks", "flow"]) + .args(&args); + run_error_json(&mut cmd, code); + } + let mut failing = mock_bin("flow-provider-error"); + failing + .env("IDX_MOCK_ERROR", "1") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args(["-o", "json", "stocks", "flow", "BBCA"]); + run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); +} + +#[test] +fn bandar_reads_one_investor_side_against_its_own_trading() { + let reports = run_success_json(mock_bin("bandar-foreign").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + let report = &reports[0]; + assert_eq!(report["investor"], "foreign"); + assert_eq!(report["buy_value"], 1_700_000_000_000_i64); + assert_eq!(report["sell_value"], 4_000_000_000_000_i64); + let top5 = &report["tiers"][2]; + // Shares of foreign buying and of foreign selling, each within 100%. + let (buy, sell) = ( + top5["buy_share_bps"].as_i64().unwrap(), + top5["sell_share_bps"].as_i64().unwrap(), + ); + assert!( + (0..=10_000).contains(&buy) && (0..=10_000).contains(&sell), + "{top5}" + ); + assert!((top5["imbalance_bps"].as_i64().unwrap() - (buy - sell)).abs() <= 1); + assert!(report["signal"].is_string()); + + let table = run_success_stdout(mock_bin("bandar-foreign-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!( + table.contains("Foreign investors bought 1.7T IDR and sold 4.0T IDR (net -2.3T)."), + "{table}" + ); +} + +#[test] +fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() { + // A summary cached before buy_value/sell_value existed: fresh for a + // week, with trades, but no denominators for a reading. + let root = test_env_dir("bandar-legacy-cache"); + let bucket = root.join("cache").join("idx").join("ipot-broker"); + fs::create_dir_all(&bucket).unwrap(); + let flow = |code: &str, value: i64| { + serde_json::json!({"rank": 1, "code": code, "name": null, "kind": null, + "lots": value / 100, "value": value, "average_price": 1}) + }; + let legacy = serde_json::json!({ + "fetched_at": chrono::Utc::now().to_rfc3339(), + "ttl_secs": 7 * 24 * 3600, + "schema_version": 1, + "data": { + "symbol": "BBCA.JK", "from": "2026-10-16", "to": "2026-10-16", + "investor": "all", "board": "all", + "buyers": [flow("AA", 60_000)], "sellers": [flow("BB", 40_000)], + "total_value": 100_000, "total_lots": 1_000, "average_price": 100, + "foreign_net_value": null, "rounded": false + } + }); + fs::write( + bucket.join("BBCA-2026-10-16-2026-10-16-all-all.json"), + legacy.to_string(), + ) + .unwrap(); + + // Offline, the old entry is not served as a complete summary. + run_error_json( + mock_bin_with_root(&root).args([ + "-o", + "json", + "--offline", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ]), + "CACHEMISS", + ); + // Online, the summary is fetched again and read in full. + let reports = run_success_json(mock_bin_with_root(&root).args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + assert_eq!(reports[0]["imbalance_bps"], -2425); + let table = run_success_stdout(mock_bin_with_root(&root).args([ + "--no-color", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + assert!(!table.contains("No trades."), "{table}"); + assert!(table.contains("big distribution"), "{table}"); +} diff --git a/crates/idx-cli/tests/golden/auto-format-piped.golden b/crates/idx-cli/tests/golden/auto-format-piped.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/auto-format-piped.golden +++ b/crates/idx-cli/tests/golden/auto-format-piped.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 2a44511..2cb5311 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -244,6 +244,48 @@ name = "yahoo-fundamental" args = ["stocks", "fundamental", "BBCA"] env = { IDX_PROVIDER = "yahoo" } +# --- stocks: broker summaries (IPOT) and foreign flow (IDX) -------------------- + +[[case]] +name = "stocks-broker" +args = ["stocks", "broker", "BBCA", "--date", "2026-10-16"] + +[[case]] +name = "stocks-broker-range" +args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] + +[[case]] +name = "stocks-broker-no-trades" +args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"] + +[[case]] +name = "stocks-bandar" +args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"] + +[[case]] +name = "stocks-bandar-screen" +formats = ["table", "json", "jsonl"] +args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"] + +[[case]] +name = "stocks-flow" +formats = ["table", "json", "jsonl"] +args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] + +[[case]] +name = "stocks-flow-idx" +formats = ["table", "json", "jsonl"] +args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16", "--source", "idx"] + +[[case]] +name = "stocks-flow-idx-latest" +args = ["stocks", "flow", "TLKM", "--days", "3", "--source", "idx"] +env = { IDX_MOCK_TODAY = "2026-10-18" } + +[[case]] +name = "error-broker-unknown-symbol" +args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] + # --- errors --------------------------------------------------------------------- [[case]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index f9c1e71..aff5968 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -174,6 +174,12 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stocks,bandar) + cmd="idx__help__stocks__bandar" + ;; + idx__help__stocks,broker) + cmd="idx__help__stocks__broker" + ;; idx__help__stocks,compare) cmd="idx__help__stocks__compare" ;; @@ -183,6 +189,9 @@ _idx() { idx__help__stocks,financials) cmd="idx__help__stocks__financials" ;; + idx__help__stocks,flow) + cmd="idx__help__stocks__flow" + ;; idx__help__stocks,fundamental) cmd="idx__help__stocks__fundamental" ;; @@ -336,6 +345,12 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stocks,bandar) + cmd="idx__stocks__bandar" + ;; + idx__stocks,broker) + cmd="idx__stocks__broker" + ;; idx__stocks,compare) cmd="idx__stocks__compare" ;; @@ -345,6 +360,9 @@ _idx() { idx__stocks,financials) cmd="idx__stocks__financials" ;; + idx__stocks,flow) + cmd="idx__stocks__flow" + ;; idx__stocks,fundamental) cmd="idx__stocks__fundamental" ;; @@ -384,6 +402,12 @@ _idx() { idx__stocks,valuation) cmd="idx__stocks__valuation" ;; + idx__stocks__help,bandar) + cmd="idx__stocks__help__bandar" + ;; + idx__stocks__help,broker) + cmd="idx__stocks__help__broker" + ;; idx__stocks__help,compare) cmd="idx__stocks__help__compare" ;; @@ -393,6 +417,9 @@ _idx() { idx__stocks__help,financials) cmd="idx__stocks__help__financials" ;; + idx__stocks__help,flow) + cmd="idx__stocks__help__flow" + ;; idx__stocks__help,fundamental) cmd="idx__stocks__help__fundamental" ;; @@ -1191,7 +1218,7 @@ _idx() { return 0 ;; idx__help__stocks) - opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare" + opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1204,6 +1231,34 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__broker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1246,6 +1301,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__fundamental) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2193,7 +2262,7 @@ _idx() { return 0 ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2214,6 +2283,94 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__bandar) + opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help ..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --date) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --investor) + COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}")) + return 0 + ;; + --board) + COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}")) + return 0 + ;; + --top) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__broker) + opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --date) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --investor) + COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}")) + return 0 + ;; + --board) + COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__compare) opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2284,6 +2441,44 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__flow) + opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --days) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --source) + COMPREPLY=($(compgen -W "ipot idx" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__fundamental) opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2329,7 +2524,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2342,6 +2537,34 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__broker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2384,6 +2607,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__fundamental) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden new file mode 100644 index 0000000..a54bade --- /dev/null +++ b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ"} diff --git a/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden new file mode 100644 index 0000000..97ba417 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +Error: symbol not found: ZZZZ diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 8210119..368e4a9 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -12,6 +12,9 @@ Commands: valuation Run valuation analysis on a stock risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock + broker Which brokers bought and sold a stock + bandar Bandarmology: is buying more concentrated than selling + flow Foreign buying and selling of a stock, session by session profile Get company profile financials Get financial statements earnings Get earnings report diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden new file mode 100644 index 0000000..c875813 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden new file mode 100644 index 0000000..0cceb2c --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -0,0 +1,6 @@ +exit: 0 +--- stdout +{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} +{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden new file mode 100644 index 0000000..33a2a13 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐ +│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │ +╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡ +│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │ +└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘ +Method top_concentration_v1: the top 3 buyers' share of the value bought minus the top 3 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.json.golden b/crates/idx-cli/tests/golden/stocks-bandar.json.golden new file mode 100644 index 0000000..bda32bb --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.table.golden b/crates/idx-cli/tests/golden/stocks-bandar.table.golden new file mode 100644 index 0000000..953b403 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +Bandar reading for BBCA.JK, 2026-10-16 +Signal (top 5): big distribution, -24.2% of value +┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐ +│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │ +╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡ +│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.2% ┆ 5,211 ┆ 5,229 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │ +└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ +Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.8% of value). +On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded. +Method top_concentration_v1: the top 5 buyers' share of the value bought minus the top 5 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden new file mode 100644 index 0000000..457e28f --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"buy_value":0,"sell_value":0,"foreign_net_value":0,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden new file mode 100644 index 0000000..e9ac964 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden @@ -0,0 +1,5 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-17 +No trades. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden new file mode 100644 index 0000000..4ef1b15 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"buy_value":9900000000000,"sell_value":9900000000000,"foreign_net_value":-4100000000000,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.table.golden b/crates/idx-cli/tests/golden/stocks-broker-range.table.golden new file mode 100644 index 0000000..2387095 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-range.table.golden @@ -0,0 +1,32 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-12 to 2026-10-16 +┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐ +│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │ +╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡ +│ 1 ┆ KZ ┆ 1.7M ┆ 963.0B ┆ 5,573 ┆ CC ┆ 5.1M ┆ 2.7T ┆ 5,336 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 2 ┆ MG ┆ 1.6M ┆ 842.0B ┆ 5,192 ┆ RX ┆ 1.8M ┆ 928.5B ┆ 5,295 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 3 ┆ ZP ┆ 1.6M ┆ 900.9B ┆ 5,780 ┆ ZP ┆ 1.6M ┆ 942.1B ┆ 5,756 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 4 ┆ YU ┆ 1.4M ┆ 781.7B ┆ 5,626 ┆ MG ┆ 1.5M ┆ 800.5B ┆ 5,239 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 5 ┆ CC ┆ 1.4M ┆ 737.9B ┆ 5,408 ┆ AK ┆ 1.4M ┆ 786.5B ┆ 5,785 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 6 ┆ XL ┆ 1.3M ┆ 687.2B ┆ 5,313 ┆ BB ┆ 852,704 ┆ 457.7B ┆ 5,368 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 7 ┆ AK ┆ 1.1M ┆ 643.9B ┆ 5,771 ┆ YU ┆ 830,940 ┆ 466.7B ┆ 5,617 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 8 ┆ SQ ┆ 973,921 ┆ 519.4B ┆ 5,333 ┆ BK ┆ 709,658 ┆ 393.3B ┆ 5,542 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 9 ┆ YP ┆ 919,655 ┆ 487.1B ┆ 5,296 ┆ KZ ┆ 621,425 ┆ 363.3B ┆ 5,847 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 10 ┆ PD ┆ 646,162 ┆ 341.2B ┆ 5,280 ┆ XL ┆ 441,903 ┆ 242.6B ┆ 5,490 │ +└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘ +Total 9.9T IDR, 18.1M lots, average 5,471; foreign net -4.1T. +Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP. +State-owned brokers: CC. +Buyers and sellers are each ranked by lots; a broker can be on both sides. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.json.golden b/crates/idx-cli/tests/golden/stocks-broker.json.golden new file mode 100644 index 0000000..858fb29 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"buy_value":6600000000000,"sell_value":6600000000000,"foreign_net_value":-2300000000000,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.table.golden b/crates/idx-cli/tests/golden/stocks-broker.table.golden new file mode 100644 index 0000000..af8125c --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker.table.golden @@ -0,0 +1,32 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-16 +┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐ +│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │ +╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡ +│ 1 ┆ MG ┆ 1.6M ┆ 841.2B ┆ 5,191 ┆ CC ┆ 4.3M ┆ 2.2T ┆ 5,211 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 2 ┆ XL ┆ 1.1M ┆ 600.0B ┆ 5,229 ┆ RX ┆ 1.7M ┆ 908.2B ┆ 5,282 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 3 ┆ CC ┆ 1.0M ┆ 543.5B ┆ 5,227 ┆ MG ┆ 1.5M ┆ 799.4B ┆ 5,238 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 4 ┆ KZ ┆ 1.0M ┆ 522.4B ┆ 5,198 ┆ BB ┆ 687,067 ┆ 357.4B ┆ 5,202 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 5 ┆ SQ ┆ 828,163 ┆ 432.4B ┆ 5,221 ┆ ZP ┆ 529,223 ┆ 275.4B ┆ 5,203 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 6 ┆ YP ┆ 827,340 ┆ 431.7B ┆ 5,218 ┆ AK ┆ 444,696 ┆ 233.5B ┆ 5,251 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 7 ┆ YU ┆ 687,603 ┆ 356.6B ┆ 5,186 ┆ BK ┆ 419,266 ┆ 218.8B ┆ 5,219 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 8 ┆ PD ┆ 592,667 ┆ 309.2B ┆ 5,218 ┆ YU ┆ 418,986 ┆ 218.0B ┆ 5,202 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 9 ┆ ZP ┆ 441,000 ┆ 230.2B ┆ 5,220 ┆ NI ┆ 357,177 ┆ 186.1B ┆ 5,212 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 10 ┆ AK ┆ 398,474 ┆ 209.9B ┆ 5,268 ┆ XL ┆ 296,269 ┆ 155.0B ┆ 5,232 │ +└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘ +Total 6.6T IDR, 12.6M lots, average 5,228; foreign net -2.3T. +Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP. +State-owned brokers: CC, NI. +Buyers and sellers are each ranked by lots; a broker can be on both sides. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden new file mode 100644 index 0000000..b600c20 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"TLKM.JK","source":"idx","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_buy_value":142986543253,"foreign_sell_value":137952652523,"foreign_net_value":5033890729,"foreign_share_bps":7104,"non_regular_bps":979},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_buy_value":215684085637,"foreign_sell_value":233704878341,"foreign_net_value":-18020792704,"foreign_share_bps":6742,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_buy_value":1307354023123,"foreign_sell_value":1400322657713,"foreign_net_value":-92968634589,"foreign_share_bps":7297,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_buy_value":1666024652013,"foreign_sell_value":1771980188577,"foreign_net_value":-105955536564,"foreign_share_bps":7213,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden new file mode 100644 index 0000000..8dad2c5 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden @@ -0,0 +1,15 @@ +exit: 0 +--- stdout +Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions, IDX) +┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ +│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ +╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ +│ 2026-10-14 ┆ 2,250 ┆ +0.5% ┆ 197.7B ┆ 63.0M ┆ 60.8M ┆ +2.2M ┆ +5.0B ┆ 9.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 2,230 ┆ -0.9% ┆ 333.3B ┆ 97.4M ┆ 105.5M ┆ -8.1M ┆ -18.0B ┆ 0.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 1,900 ┆ -14.8% ┆ 1.9T ┆ 679.0M ┆ 727.3M ┆ -48.3M ┆ -93.0B ┆ 14.8% │ +└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ +Net foreign: -54.2M shares, about -106.0B IDR; foreign investors made up 72.1% of trading. +Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden new file mode 100644 index 0000000..f75365d --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden new file mode 100644 index 0000000..f75365d --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden new file mode 100644 index 0000000..32fa111 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden @@ -0,0 +1,19 @@ +exit: 0 +--- stdout +Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions, IDX) +┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ +│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ +╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ +│ 2026-10-12 ┆ 5,950 ┆ -1.6% ┆ 586.5B ┆ 51.5M ┆ 60.9M ┆ -9.4M ┆ -56.2B ┆ 0.1% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 24.1% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 10.5% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.5% │ +└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ +Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading. +Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.json.golden b/crates/idx-cli/tests/golden/stocks-flow.json.golden new file mode 100644 index 0000000..29e9d90 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden new file mode 100644 index 0000000..29e9d90 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.table.golden b/crates/idx-cli/tests/golden/stocks-flow.table.golden new file mode 100644 index 0000000..066a27a --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.table.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions, IPOT) +┌────────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┐ +│ DATE ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ FOREIGN % │ +╞════════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╡ +│ 2026-10-12 ┆ 587.1B ┆ 308.7B ┆ 364.6B ┆ -55.9B ┆ 57.3% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 738.2B ┆ 537.3B ┆ 507.2B ┆ +30.1B ┆ 70.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 1.1T ┆ 991.8B ┆ 775.2B ┆ +216.6B ┆ 80.3% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 912.0B ┆ 826.9B ┆ 540.8B ┆ +286.1B ┆ 75.0% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 6.6T ┆ 1.7T ┆ 4.0T ┆ -2.3T ┆ 43.2% │ +└────────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┘ +Foreign investors bought 4.4T IDR and sold 6.2T IDR: net -1.8T; they made up 53.1% of trading. +Values in IDR across all boards. For exact share counts, the close and the non-regular share, use --source idx (needs curl-impersonate). +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden index 36b651b..c57ea57 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden @@ -1,5 +1,5 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} -{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} +{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false} +{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden index 5f15d77..ffbf6c5 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden b/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote.json.golden b/crates/idx-cli/tests/golden/stocks-quote.json.golden index 5f15d77..ffbf6c5 100644 --- a/crates/idx-cli/tests/golden/stocks-quote.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden b/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden index 191abda..ee20b98 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden b/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden index 5303d7b..644a14c 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden b/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden index 5303d7b..644a14c 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen.json.golden b/crates/idx-cli/tests/golden/stocks-screen.json.golden index 191abda..ee20b98 100644 --- a/crates/idx-cli/tests/golden/stocks-screen.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/yahoo-quote.json.golden b/crates/idx-cli/tests/golden/yahoo-quote.json.golden index 74ddf4e..07942ab 100644 --- a/crates/idx-cli/tests/golden/yahoo-quote.json.golden +++ b/crates/idx-cli/tests/golden/yahoo-quote.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml index 1ac22af..187ff54 100644 --- a/crates/idx-core/Cargo.toml +++ b/crates/idx-core/Cargo.toml @@ -34,3 +34,4 @@ rusqlite = { workspace = true, optional = true } quick-xml = { version = "0.37", optional = true } sha2 = { workspace = true, optional = true } zip = { workspace = true, optional = true } +scraper = { version = "0.27", default-features = false } diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs new file mode 100644 index 0000000..10439f2 --- /dev/null +++ b/crates/idx-core/src/analysis/bandar.rs @@ -0,0 +1,466 @@ +//! Bandarmology over a broker summary: is buying more concentrated in a few +//! brokers than selling? No network; see docs/ARCHITECTURE.md. +//! +//! The summary lists the top 10 buyers and the top 10 sellers by gross lots. +//! For N in 1, 3, 5 and 10 the top N buyers' share of the value bought is set +//! against the top N sellers' share of the value sold. For all investors both +//! are the value traded, so this is the top N's buy value less their sell +//! value as a share of it; for one investor side (foreign or domestic), each +//! is that side's own buying or selling. +//! +//! | top-N buy share - sell share, % | signal | +//! |------------------------------------|--------------------| +//! | >= +15 | `big_accumulation` | +//! | +5 to +15 | `accumulation` | +//! | within +-5 | `neutral` | +//! | -15 to -5 | `distribution` | +//! | <= -15 | `big_distribution` | +//! +//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most +//! traded IDX stocks in October 2026. They are heuristics: a broker code is +//! not an investor, and gross figures overstate a broker that both bought +//! and sold (listed in `both_sides`). + +use serde::{Deserialize, Serialize}; + +use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary}; +use crate::error::IdxError; +use chrono::NaiveDate; + +/// Names the rules below; bumped when they change. +pub const METHOD: &str = "top_concentration_v1"; +/// Tiers every report carries. +pub const TIERS: [u8; 4] = [1, 3, 5, 10]; +/// Tier behind the headline signal unless asked otherwise. +pub const DEFAULT_TOP: u8 = 5; +/// Imbalance, in basis points of value traded, from which a tier reads +/// accumulation (or, negative, distribution). +pub const ACCUMULATION_BPS: i64 = 500; +/// Imbalance from which the reading is big. +pub const BIG_BPS: i64 = 1500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BandarSignal { + BigAccumulation, + Accumulation, + Neutral, + Distribution, + BigDistribution, +} + +impl BandarSignal { + pub fn as_str(self) -> &'static str { + match self { + Self::BigAccumulation => "big_accumulation", + Self::Accumulation => "accumulation", + Self::Neutral => "neutral", + Self::Distribution => "distribution", + Self::BigDistribution => "big_distribution", + } + } + + pub fn from_imbalance_bps(bps: i64) -> Self { + match bps { + b if b >= BIG_BPS => Self::BigAccumulation, + b if b >= ACCUMULATION_BPS => Self::Accumulation, + b if b <= -BIG_BPS => Self::BigDistribution, + b if b <= -ACCUMULATION_BPS => Self::Distribution, + _ => Self::Neutral, + } + } +} + +/// The top N buyers against the top N sellers. Every figure is `None` when +/// a broker in the tier lacks it, or when nothing traded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarTier { + pub top: u8, + /// Value the top N buyers bought, whole IDR. + pub buy_value: Option, + /// Value the top N sellers sold, whole IDR. + pub sell_value: Option, + /// `buy_value - sell_value`, whole IDR. + pub imbalance_value: Option, + /// `buy_share_bps - sell_share_bps`, computed exactly: for all investors, + /// `imbalance_value` in basis points of the value traded. + pub imbalance_bps: Option, + /// The top N buyers' share of the value bought, basis points. + pub buy_share_bps: Option, + /// The top N sellers' share of the value sold, basis points. + pub sell_share_bps: Option, + /// Volume-weighted average price the top N buyers paid, whole IDR. + pub buy_average: Option, + /// Volume-weighted average price the top N sellers got, whole IDR. + pub sell_average: Option, + pub signal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarReport { + pub symbol: String, + pub from: NaiveDate, + pub to: NaiveDate, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// The rules applied, e.g. `top_concentration_v1`. + pub method: String, + /// Tier behind the headline `signal`. + pub top: u8, + /// The headline tier's signal; `None` when nothing traded or a figure + /// is missing. + pub signal: Option, + /// The headline tier's imbalance, basis points of value traded. + pub imbalance_bps: Option, + pub tiers: Vec, + /// Value traded over the range, whole IDR; `None` for one investor side. + pub total_value: Option, + /// Value the selected investors bought, whole IDR. + pub buy_value: Option, + /// Value the selected investors sold, whole IDR. + pub sell_value: Option, + /// Average traded price, whole IDR. + pub average_price: Option, + /// Foreign net value, whole IDR; `None` for a filtered summary. + pub foreign_net_value: Option, + /// `foreign_net_value` in basis points of the value traded. + pub foreign_net_bps: Option, + /// Brokers among the top 10 on both sides, whose gross figures overstate + /// which way they traded. + pub both_sides: Vec, + /// The source rounded figures of a million and more to one decimal. + pub rounded: bool, +} + +/// The headline tier must be one the report carries. +pub fn check_top(top: u8) -> Result { + if TIERS.contains(&top) { + Ok(top) + } else { + Err(IdxError::InvalidInput(format!( + "top must be 1, 3, 5 or 10 (got {top})" + ))) + } +} + +/// `part` in basis points of `whole`, truncated toward zero so a negative +/// reading never reaches a threshold its magnitude has not; `None` without +/// a positive whole. +fn bps(part: i64, whole: Option) -> Option { + let whole = whole.filter(|whole| *whole > 0)?; + i64::try_from(i128::from(part) * 10_000 / i128::from(whole)).ok() +} + +/// Sum of `field` over `flows`; `None` when any flow lacks it. +fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option) -> Option { + flows + .iter() + .try_fold(0_i64, |total, flow| total.checked_add(field(flow)?)) +} + +/// Value over shares, the shares taken from each broker's value and its +/// exact published average (lots of a million and more are rounded, +/// averages never are). +fn vwap(flows: &[BrokerFlow]) -> Option { + let (mut value, mut shares) = (0.0_f64, 0.0_f64); + for flow in flows { + let (flow_value, average) = (flow.value?, flow.average_price?); + if average <= 0 { + return None; + } + value += flow_value as f64; + shares += flow_value as f64 / average as f64; + } + (shares > 0.0).then(|| (value / shares).round() as i64) +} + +pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { + let n = usize::from(top); + let buyers = &summary.buyers[..n.min(summary.buyers.len())]; + let sellers = &summary.sellers[..n.min(summary.sellers.len())]; + let buy_value = sum(buyers, |flow| flow.value); + let sell_value = sum(sellers, |flow| flow.value); + let imbalance_value = buy_value + .zip(sell_value) + .and_then(|(buy, sell)| buy.checked_sub(sell)); + let imbalance_bps = match (buy_value, sell_value, summary.buy_value, summary.sell_value) { + (Some(buy), Some(sell), Some(bought), Some(sold)) if bought > 0 && sold > 0 => { + // buy/bought - sell/sold over one denominator, so equal totals + // give exactly (buy - sell) / total. + let (buy, sell, bought, sold) = ( + i128::from(buy), + i128::from(sell), + i128::from(bought), + i128::from(sold), + ); + // Checked: past i128 there is no reading, never a wrapped one. + buy.checked_mul(sold) + .zip(sell.checked_mul(bought)) + .and_then(|(buys, sells)| buys.checked_sub(sells)) + .and_then(|diff| diff.checked_mul(10_000)) + .zip(bought.checked_mul(sold)) + .and_then(|(diff, denominator)| i64::try_from(diff / denominator).ok()) + } + _ => None, + }; + BandarTier { + top, + buy_value, + sell_value, + imbalance_value, + imbalance_bps, + buy_share_bps: buy_value.and_then(|value| bps(value, summary.buy_value)), + sell_share_bps: sell_value.and_then(|value| bps(value, summary.sell_value)), + buy_average: vwap(buyers), + sell_average: vwap(sellers), + signal: imbalance_bps.map(BandarSignal::from_imbalance_bps), + } +} + +/// The full reading of one summary, headlined by the top `top` tier (one of +/// [`TIERS`]; see [`check_top`]). +pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport { + let tiers: Vec = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect(); + let headline = tiers + .iter() + .find(|tier| tier.top == top) + .cloned() + .unwrap_or_else(|| bandar_tier(summary, top)); + let mut both_sides: Vec = summary + .buyers + .iter() + .filter(|buyer| { + summary + .sellers + .iter() + .any(|seller| seller.code == buyer.code) + }) + .map(|buyer| buyer.code.clone()) + .collect(); + both_sides.sort_unstable(); + BandarReport { + symbol: summary.symbol.clone(), + from: summary.from, + to: summary.to, + investor: summary.investor, + board: summary.board, + method: METHOD.to_string(), + top, + signal: headline.signal, + imbalance_bps: headline.imbalance_bps, + tiers, + total_value: summary.total_value, + buy_value: summary.buy_value, + sell_value: summary.sell_value, + average_price: summary.average_price, + foreign_net_value: summary.foreign_net_value, + foreign_net_bps: summary + .foreign_net_value + .and_then(|value| bps(value, summary.total_value)), + both_sides, + rounded: summary.rounded, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flow(code: &str, value: Option, average: i64) -> BrokerFlow { + BrokerFlow { + rank: 0, + code: code.to_string(), + name: None, + kind: None, + lots: value.map(|value| value / average / 100), + value, + average_price: Some(average), + } + } + + fn summary(buyers: Vec, sellers: Vec, total: i64) -> BrokerSummary { + let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(); + BrokerSummary { + symbol: "TEST.JK".to_string(), + from: day, + to: day, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + buyers, + sellers, + total_value: Some(total), + total_lots: None, + average_price: None, + buy_value: Some(total), + sell_value: Some(total), + foreign_net_value: Some(-total / 10), + rounded: false, + } + } + + #[test] + fn test_bandar_signal_thresholds_are_inclusive() { + use BandarSignal::*; + for (bps, signal) in [ + (1500, BigAccumulation), + (1499, Accumulation), + (500, Accumulation), + (499, Neutral), + (-499, Neutral), + (-500, Distribution), + (-1499, Distribution), + (-1500, BigDistribution), + ] { + assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}"); + } + } + + #[test] + fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() { + let s = summary( + vec![ + flow("AA", Some(400), 100), + flow("BB", Some(200), 100), + flow("CC", Some(100), 50), + ], + vec![ + flow("DD", Some(300), 110), + flow("AA", Some(100), 110), + flow("EE", Some(50), 110), + ], + 1000, + ); + let report = analyze_bandar(&s, 3); + let top1 = &report.tiers[0]; + assert_eq!((top1.top, top1.imbalance_value), (1, Some(100))); + assert_eq!(top1.imbalance_bps, Some(1000)); + assert_eq!(top1.signal, Some(BandarSignal::Accumulation)); + + let top3 = &report.tiers[1]; + assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450))); + assert_eq!( + (top3.buy_share_bps, top3.sell_share_bps), + (Some(7000), Some(4500)) + ); + assert_eq!(top3.imbalance_bps, Some(2500)); + // 700 IDR over 4 + 2 + 2 shares. + assert_eq!(top3.buy_average, Some(88)); + assert_eq!(top3.sell_average, Some(110)); + + // Short lists: every broker on a side is listed, so the sums hold. + assert_eq!(report.tiers[3].imbalance_value, Some(250)); + assert_eq!( + (report.top, report.signal), + (3, Some(BandarSignal::BigAccumulation)) + ); + assert_eq!(report.imbalance_bps, Some(2500)); + assert_eq!(report.foreign_net_bps, Some(-1000)); + assert_eq!(report.both_sides, vec!["AA".to_string()]); + assert_eq!(report.method, METHOD); + } + + #[test] + fn test_bandar_tier_thresholds_are_symmetric_for_sign() { + let reading = |buy: i64, sell: i64| { + let s = summary( + vec![flow("AA", Some(buy), 100)], + vec![flow("BB", Some(sell), 100)], + 2_100_000_000, + ); + let tier = bandar_tier(&s, 1); + (tier.imbalance_bps, tier.signal) + }; + // -4.9952% and -14.9952% stay short of the thresholds, like their + // mirror images. + assert_eq!( + reading(504_300_000, 609_200_000), + (Some(-499), Some(BandarSignal::Neutral)) + ); + assert_eq!( + reading(609_200_000, 504_300_000), + (Some(499), Some(BandarSignal::Neutral)) + ); + assert_eq!( + reading(504_300_000, 819_200_000), + (Some(-1499), Some(BandarSignal::Distribution)) + ); + assert_eq!( + reading(819_200_000, 504_300_000), + (Some(1499), Some(BandarSignal::Accumulation)) + ); + } + + #[test] + fn test_bandar_tier_weighs_one_side_against_its_own_buying_and_selling() { + // Foreign investors bought 1,000 and sold 4,000: the top buyer took + // half of the buying, the top seller a quarter of the selling. + let mut s = summary( + vec![flow("AA", Some(500), 100)], + vec![flow("BB", Some(1_000), 100)], + 0, + ); + s.investor = BrokerInvestor::Foreign; + s.total_value = None; + s.buy_value = Some(1_000); + s.sell_value = Some(4_000); + s.foreign_net_value = Some(-3_000); + let report = analyze_bandar(&s, 1); + let tier = &report.tiers[0]; + assert_eq!( + (tier.buy_share_bps, tier.sell_share_bps), + (Some(5000), Some(2500)) + ); + assert_eq!(tier.imbalance_bps, Some(2500)); + assert_eq!(tier.imbalance_value, Some(-500)); + assert_eq!(report.signal, Some(BandarSignal::BigAccumulation)); + assert_eq!(report.foreign_net_bps, None, "no total to weigh it against"); + + // Without the selling total (one side on one board) there is no reading. + s.sell_value = None; + assert_eq!(analyze_bandar(&s, 1).signal, None); + } + + #[test] + fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { + let s = summary( + vec![flow("AA", Some(400), 100), flow("BB", None, 100)], + vec![flow("DD", Some(300), 100)], + 1000, + ); + let report = analyze_bandar(&s, 3); + assert_eq!(report.tiers[0].imbalance_bps, Some(1000)); + let top3 = &report.tiers[1]; + assert_eq!( + (top3.buy_value, top3.imbalance_bps, top3.signal), + (None, None, None) + ); + assert_eq!(top3.buy_average, None); + assert_eq!(report.signal, None); + } + + #[test] + fn test_analyze_bandar_without_trades_has_no_signal() { + let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP); + assert!(report.tiers.iter().all(|tier| tier.signal.is_none())); + assert_eq!((report.signal, report.imbalance_bps), (None, None)); + assert_eq!(report.tiers[2].buy_value, Some(0)); + } + + #[test] + fn test_bandar_tier_past_i128_has_no_reading_instead_of_a_wrapped_one() { + let reading = |total: i64| { + let buyers = vec![flow("AA", Some(total / 10 * 6), 1)]; + let sellers = vec![flow("BB", Some(total / 10 * 4), 1)]; + bandar_tier(&summary(buyers, sellers, total), 1).imbalance_bps + }; + // A quadrillion rupiah, far past any session, is still exact. + assert_eq!(reading(1_000_000_000_000_000), Some(2000)); + assert_eq!(reading(1_000_000_000_000_000_000), None); + } + + #[test] + fn test_check_top_allows_only_report_tiers() { + assert_eq!(check_top(5).unwrap(), 5); + assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_)))); + } +} diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 87c3aea..b0741f2 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -1,3 +1,4 @@ +pub mod bandar; pub mod fundamental; pub mod report; pub mod signals; @@ -8,9 +9,11 @@ use serde::de::DeserializeOwned; use crate::api::history_provider; use crate::api::types::{Fundamentals, Interval, Period}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; use crate::market::HistorySource; +use bandar::{BandarReport, analyze_bandar, check_top}; use fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, analyze_growth, analyze_risk, analyze_valuation, @@ -28,7 +31,85 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } +/// One investor side is weighed against its own selling, which the source +/// gives only across all boards. +fn check_scope(investor: BrokerInvestor, board: BrokerBoard) -> Result<(), IdxError> { + if investor == BrokerInvestor::All || board == BrokerBoard::All { + Ok(()) + } else { + Err(IdxError::InvalidInput(format!( + "bandar for {} investors needs --board all: the broker summary gives one side's \ +selling only across all boards", + investor.as_str() + ))) + } +} + +/// Bandar readings for several symbols, strongest accumulation first; +/// symbols that failed are listed with their error. +pub struct BandarScreen { + pub reports: Vec>, + pub failures: Vec<(String, IdxError)>, +} + +/// The sessions and trades a bandar reading covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BandarQuery { + pub range: BrokerRange, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// Headline tier, one of `bandar::TIERS`. + pub top: u8, +} + impl Analysis<'_> { + /// Bandarmology over the broker summary `query` selects. + pub fn bandar( + &self, + symbol: &str, + query: &BandarQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let top = check_top(query.top)?; + check_scope(query.investor, query.board)?; + let summary = + self.idx + .broker() + .fetch(symbol, query.range, query.investor, query.board, policy)?; + Ok(summary.map(|summary| analyze_bandar(&summary, top))) + } + + /// [`Self::bandar`] for each symbol, ranked by the headline imbalance. + /// Invalid input fails the whole screen; other errors fail one symbol. + pub fn bandar_screen>( + &self, + symbols: &[S], + query: &BandarQuery, + policy: CachePolicy, + ) -> Result { + check_top(query.top)?; + check_scope(query.investor, query.board)?; + let mut reports = Vec::new(); + let mut failures = Vec::new(); + for symbol in symbols { + let resolved = self.idx.resolve(symbol.as_ref())?; + match self.bandar(&resolved, query, policy) { + Ok(report) => reports.push(report), + Err(err @ IdxError::InvalidInput(_)) => return Err(err), + Err(err) => failures.push((resolved, err)), + } + } + if reports.is_empty() { + return Err(failures + .pop() + .map(|(_, err)| err) + .unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string()))); + } + // Strongest accumulation first; readings without an imbalance last. + reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps)); + Ok(BandarScreen { reports, failures }) + } + /// Technical analysis over one year of daily history from `source` /// (see `Market::history_source`). pub fn technical( diff --git a/crates/idx-core/src/api/idx/mod.rs b/crates/idx-core/src/api/idx/mod.rs new file mode 100644 index 0000000..4ad369d --- /dev/null +++ b/crates/idx-core/src/api/idx/mod.rs @@ -0,0 +1,73 @@ +//! IDX's own site: the daily trading summary of every listed stock. +//! +//! `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` +//! `?length=9999&start=0&date=YYYYMMDD` answers JSON with one row per stock: +//! prices, volume and value, foreign buy and sell volume, and non-regular +//! (negotiated and cash) volume and value. A day without trading has no +//! rows. Cloudflare in front of the site rejects some TLS fingerprints, so +//! the request goes through curl-impersonate's profile fallback. + +mod parse; + +use chrono::NaiveDate; +pub use parse::parse_stock_summary; + +use super::TradingSummaryProvider; +use crate::curl_impersonate; +use crate::error::IdxError; +use crate::flow::types::StockDay; + +const STOCK_SUMMARY_URL: &str = "https://www.idx.co.id/primary/TradingSummary/GetStockSummary"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15"; +/// One session's summary is about 650 KB; it must not hold a command or an +/// MCP call for long. +const MAX_TIME_SECS: &str = "30"; + +pub fn stock_summary_url(date: NaiveDate) -> String { + format!( + "{STOCK_SUMMARY_URL}?length=9999&start=0&date={}", + date.format("%Y%m%d") + ) +} + +/// A Cloudflare challenge page is not the JSON asked for: try the next +/// profile. +fn accept_json(body: &[u8]) -> Result<(), IdxError> { + match body.iter().find(|byte| !byte.is_ascii_whitespace()) { + Some(b'{') => Ok(()), + _ => Err(IdxError::Http( + "IDX stock summary: answered a challenge page instead of JSON (HTTP 403)".to_string(), + )), + } +} + +pub struct IdxSiteProvider; + +impl TradingSummaryProvider for IdxSiteProvider { + fn stock_summary(&self, date: NaiveDate) -> Result, IdxError> { + let args: Vec = [ + "--silent", + "--show-error", + "--location", + "--fail", + "--compressed", + "--max-time", + MAX_TIME_SECS, + "--header", + &format!("User-Agent: {USER_AGENT}"), + "--header", + "Accept: application/json,text/plain,*/*", + "--header", + "Referer: https://www.idx.co.id/id/data-pasar/ringkasan-perdagangan/ringkasan-saham/", + &stock_summary_url(date), + ] + .iter() + .map(|arg| arg.to_string()) + .collect(); + let output = + curl_impersonate::run_owned_with_fallback("IDX stock summary", &args, &accept_json)?; + let body = String::from_utf8(output.stdout) + .map_err(|e| IdxError::Http(format!("IDX stock summary: not UTF-8: {e}")))?; + parse_stock_summary(&body, date) + } +} diff --git a/crates/idx-core/src/api/idx/parse.rs b/crates/idx-core/src/api/idx/parse.rs new file mode 100644 index 0000000..21f9005 --- /dev/null +++ b/crates/idx-core/src/api/idx/parse.rs @@ -0,0 +1,121 @@ +//! Pure parser for IDX's `GetStockSummary` JSON, tested against +//! `tests/fixtures/idx_stocksummary_*.json`. + +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::error::IdxError; +use crate::flow::types::StockDay; + +#[derive(Deserialize)] +struct StockSummaryResponse { + data: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct StockSummaryRawRow { + date: String, + stock_code: String, + previous: Option, + high: Option, + low: Option, + close: Option, + volume: Option, + value: Option, + frequency: Option, + foreign_buy: Option, + foreign_sell: Option, + non_regular_volume: Option, + non_regular_value: Option, +} + +fn whole(value: Option) -> Option { + value + .filter(|value| value.is_finite()) + .map(|value| value.round() as i64) +} + +/// The rows of one session. A day without trading (weekend, holiday) is +/// empty; rows dated other than `date` are dropped, so another session's +/// figures never pass as this one's. Rows for other days only are an error, +/// not an empty session, so they are never cached as one. +pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, IdxError> { + let response: StockSummaryResponse = serde_json::from_str(raw) + .map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?; + let answered = response.data.len(); + let rows: Vec = response + .data + .into_iter() + .filter(|row| { + row.date + .get(..10) + .and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) + == Some(date) + }) + .map(|row| StockDay { + code: row.stock_code.trim().to_ascii_uppercase(), + date, + previous: whole(row.previous), + high: whole(row.high), + low: whole(row.low), + close: whole(row.close), + volume: whole(row.volume), + value: whole(row.value), + frequency: whole(row.frequency), + foreign_buy: whole(row.foreign_buy), + foreign_sell: whole(row.foreign_sell), + non_regular_volume: whole(row.non_regular_volume), + non_regular_value: whole(row.non_regular_value), + }) + .collect(); + if rows.is_empty() && answered > 0 { + return Err(IdxError::ParseError(format!( + "idx stock summary: asked for {date}, got {answered} rows for other days" + ))); + } + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261016.json"); + const SATURDAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261017.json"); + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn test_parse_stock_summary_reads_foreign_and_non_regular_figures() { + let rows = parse_stock_summary(DAY, date(2026, 10, 16)).unwrap(); + let bbca = rows.iter().find(|row| row.code == "BBCA").unwrap(); + assert_eq!((bbca.previous, bbca.close), (Some(6075), Some(5175))); + assert_eq!(bbca.volume, Some(1_241_098_200)); + assert_eq!(bbca.value, Some(6_474_934_315_000)); + assert_eq!( + (bbca.foreign_buy, bbca.foreign_sell), + (Some(309_392_800), Some(757_227_900)) + ); + assert_eq!(bbca.non_regular_value, Some(100_183_151_475)); + } + + #[test] + fn test_parse_stock_summary_keeps_only_the_requested_session() { + assert!( + parse_stock_summary(SATURDAY, date(2026, 10, 17)) + .unwrap() + .is_empty() + ); + assert!(matches!( + parse_stock_summary(DAY, date(2026, 10, 15)), + Err(IdxError::ParseError(_)) + )); + assert!(matches!( + parse_stock_summary("Just a moment...", date(2026, 10, 16)), + Err(IdxError::ParseError(_)) + )); + } +} diff --git a/crates/idx-core/src/api/ipot/client.rs b/crates/idx-core/src/api/ipot/client.rs new file mode 100644 index 0000000..6e8135c --- /dev/null +++ b/crates/idx-core/src/api/ipot/client.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use crate::error::IdxError; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +/// Tries per request. In some stretches of seconds the server answers its +/// own nginx 404 page to a third or more of requests and then recovers, so a +/// 404 is retried (an unknown ticker is a 200, not a 404). +const ATTEMPTS: u32 = 5; +const FIRST_PAUSE: Duration = Duration::from_millis(250); + +pub(super) struct IpotClient { + agent: ureq::Agent, +} + +impl IpotClient { + pub(super) fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + // A server that stalls before sending headers must not hold a + // call (and an MCP call slot) forever. + .timeout_global(Some(Duration::from_secs(20))) + .build() + .into(); + Self { agent } + } + + pub(super) fn fetch_html(&self, url: &str) -> Result { + let mut pause = FIRST_PAUSE; + let mut last = None; + for attempt in 1..=ATTEMPTS { + if attempt > 1 { + std::thread::sleep(pause); + pause *= 2; + } + let response = self + .agent + .get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html") + .call(); + match response { + Ok(ok) => { + return ok + .into_body() + .read_to_string() + .map_err(|e| IdxError::Http(format!("ipot broker summary: {e}"))); + } + Err(ureq::Error::StatusCode(code)) if code == 404 || code >= 500 => { + last = Some(code); + } + Err(ureq::Error::StatusCode(429)) => return Err(IdxError::RateLimited), + Err(err) => return Err(IdxError::Http(format!("ipot broker summary: {err}"))), + } + } + Err(IdxError::Http(format!( + "ipot broker summary: status {} after {ATTEMPTS} attempts", + last.unwrap_or_default() + ))) + } +} diff --git a/crates/idx-core/src/api/ipot/mod.rs b/crates/idx-core/src/api/ipot/mod.rs new file mode 100644 index 0000000..49f42dd --- /dev/null +++ b/crates/idx-core/src/api/ipot/mod.rs @@ -0,0 +1,101 @@ +//! Indo Premier Sekuritas (IPOT) broker summary: which brokers bought and +//! sold a stock. No login or key. +//! +//! `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php` +//! `?code=BBCA&start=MM/DD/YYYY&end=MM/DD/YYYY&fd=all|F|D&board=all|RG|TN|NG` +//! answers an HTML fragment with the top 10 buyers and the top 10 sellers, +//! ranked by lots, and totals for the range. Figures of a million and more +//! are abbreviated to one decimal (`1.6 M`). A range without trades gives +//! blank rows and zero totals; an unknown code gives `Invalid stock code!` +//! with HTTP 200. The endpoint's behavior was first mapped by fzn0x/idx +//! (MIT). + +mod client; +pub mod parse; + +use client::IpotClient; +pub use parse::{BrokerSummaryRaw, IpotPage, parse_broker_summary, parse_figure}; + +use super::BrokerSummaryProvider; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; +use crate::error::IdxError; + +const ENDPOINT: &str = "https://www.indopremier.com/module/saham/include/data-brokersummary.php"; + +/// The request URL for `code` (a bare IDX code such as `BBCA`). +pub fn broker_summary_url(code: &str, query: &BrokerQuery) -> String { + let investor = match query.investor { + BrokerInvestor::All => "all", + BrokerInvestor::Foreign => "F", + BrokerInvestor::Domestic => "D", + }; + let board = match query.board { + BrokerBoard::All => "all", + BrokerBoard::Regular => "RG", + BrokerBoard::Cash => "TN", + BrokerBoard::Negotiated => "NG", + }; + format!( + "{ENDPOINT}?code={code}&start={}&end={}&fd={investor}&board={board}", + query.from.format("%m/%d/%Y"), + query.to.format("%m/%d/%Y"), + ) +} + +/// A parsed page as a summary; an unknown code is `SymbolNotFound`. +pub(crate) fn summary_from_html(code: &str, html: &str) -> Result { + match parse_broker_summary(html)? { + IpotPage::Invalid => Err(IdxError::SymbolNotFound(code.to_string())), + IpotPage::Summary(raw) => Ok(raw), + } +} + +pub struct IpotProvider { + client: IpotClient, +} + +impl IpotProvider { + pub fn new() -> Self { + Self { + client: IpotClient::new(), + } + } +} + +impl Default for IpotProvider { + fn default() -> Self { + Self::new() + } +} + +impl BrokerSummaryProvider for IpotProvider { + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result { + let html = self.client.fetch_html(&broker_summary_url(code, query))?; + summary_from_html(code, &html) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::*; + + #[test] + fn test_broker_summary_url_maps_filters_to_source_codes() { + let query = BrokerQuery { + from: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(), + to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(), + investor: BrokerInvestor::Foreign, + board: BrokerBoard::Regular, + }; + assert_eq!( + broker_summary_url("BBCA", &query), + "https://www.indopremier.com/module/saham/include/data-brokersummary.php?code=BBCA&start=10/12/2026&end=10/16/2026&fd=F&board=RG" + ); + } +} diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs new file mode 100644 index 0000000..9e3c544 --- /dev/null +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -0,0 +1,1041 @@ +//! Pure parsers for IPOT's broker summary fragment, tested against the +//! recorded pages in `tests/fixtures/ipot_brokersummary_*.html`. +//! +//! The fragment is a 9-column table: buyer code, lots, value, average, rank, +//! seller code, lots, value, average. Each code sits in a +//! ``; the footer holds +//! `T. Val`, `F. NVal`, `T.Lot` and `Avg`, and the date inputs echo the +//! range the source applied. The page is parsed with html5ever, so markup +//! reads as a browser builds it. + +use chrono::NaiveDate; +use scraper::{ElementRef, Html, Node, Selector}; + +use crate::broker::types::{BrokerFlow, BrokerKind}; +use crate::error::IdxError; + +/// What one page said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpotPage { + /// `Invalid stock code!`: the source does not know the ticker. + Invalid, + Summary(BrokerSummaryRaw), +} + +/// One parsed summary page, before the symbol and query are attached. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BrokerSummaryRaw { + pub from: Option, + pub to: Option, + pub buyers: Vec, + pub sellers: Vec, + pub total_value: Option, + pub total_lots: Option, + pub average_price: Option, + pub foreign_net_value: Option, + pub rounded: bool, +} + +/// A figure as published: `1.6 M` -> `(1_600_000, true)`, `687,067` -> +/// `(687_067, false)`, `-2.3 T` -> `(-2_300_000_000_000, true)`. Scaled in +/// decimal, so `1.6 M` is exact. Empty or unparsable -> `None`. +pub fn parse_figure(text: &str) -> Option<(i64, bool)> { + let text = text.trim().replace(',', ""); + let (number, exponent) = match text.chars().last()?.to_ascii_uppercase() { + 'K' => (&text[..text.len() - 1], 3), + 'M' => (&text[..text.len() - 1], 6), + 'B' => (&text[..text.len() - 1], 9), + 'T' => (&text[..text.len() - 1], 12), + _ => (text.as_str(), 0), + }; + let number = number.trim(); + let (negative, digits) = match number.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, number), + }; + let (whole, fraction) = digits.split_once('.').unwrap_or((digits, "")); + if whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || !fraction.bytes().all(|b| b.is_ascii_digit()) + || (exponent > 0 && fraction.len() > exponent as usize) + { + return None; + } + let scale = 10_i64.checked_pow(exponent)?; + let mut value = whole.parse::().ok()?.checked_mul(scale)?; + if exponent == 0 { + // A plain decimal: round to whole units. + if fraction + .as_bytes() + .first() + .is_some_and(|digit| *digit >= b'5') + { + value = value.checked_add(1)?; + } + } else if !fraction.is_empty() { + let unit = 10_i64.checked_pow(exponent - fraction.len() as u32)?; + value = value.checked_add(fraction.parse::().ok()?.checked_mul(unit)?)?; + } + Some((if negative { -value } else { value }, exponent > 0)) +} + +fn broker_kind(class: Option<&str>) -> Option { + match class?.strip_prefix("text-")? { + "local" => Some(BrokerKind::Local), + "foreign" => Some(BrokerKind::Foreign), + "bumn" => Some(BrokerKind::StateOwned), + _ => None, + } +} + +/// Text with ` ` as a space, trimmed. +fn clean(text: &str) -> String { + text.replace('\u{a0}', " ").trim().to_string() +} + +/// Class tokens as HTML splits them: on ASCII whitespace only, so a +/// no-break space stays part of a token. +fn class_tokens(value: &str) -> impl Iterator { + value + .split(['\t', '\n', '\x0c', '\r', ' ']) + .filter(|token| !token.is_empty()) +} + +fn selector(css: &str) -> Selector { + Selector::parse(css).expect("a valid selector") +} + +/// The one element matching `css` within `scope`; `None` for none or more. +fn only<'a>(scope: ElementRef<'a>, css: &str) -> Option> { + let selector = selector(css); + let mut found = scope.select(&selector); + match (found.next(), found.next()) { + (Some(one), None) => Some(one), + _ => None, + } +} + +fn parse_error(what: &str) -> IdxError { + IdxError::ParseError(format!("ipot broker summary: {what}")) +} + +/// Whether `element` has only attributes named in `allowed`. +fn only_attrs(element: ElementRef, allowed: &[&str]) -> bool { + element + .value() + .attrs() + .all(|(name, _)| allowed.contains(&name)) +} + +/// The text of an element whose children are text (and comments) only; +/// `None` when it holds other elements. +fn leaf_text(element: ElementRef) -> Option { + let mut text = String::new(); + for child in element.children() { + match child.value() { + Node::Text(part) => text.push_str(part), + Node::Comment(_) => {} + _ => return None, + } + } + Some(text) +} + +/// A body cell as IPOT writes it: `` holding text, or one +/// `` holding text. Its text and the span's class; +/// `None` for any other shape, which could show other text than it holds. +fn cell(td: ElementRef) -> Option<(String, Option)> { + if !only_attrs(td, &["align"]) { + return None; + } + let mut text = String::new(); + let mut span_text = None; + let mut class = None; + for child in td.children() { + match child.value() { + Node::Text(part) => text.push_str(part), + Node::Comment(_) => {} + Node::Element(element) if element.name() == "span" && class.is_none() => { + let span = ElementRef::wrap(child)?; + // One class token, as the browser splits it. + let mut tokens = class_tokens(span.attr("class")?); + let token = tokens + .next() + .filter(|token| token.starts_with("text-") && tokens.next().is_none())?; + if !only_attrs(span, &["class"]) { + return None; + } + span_text = Some(leaf_text(span)?); + class = Some(token.to_string()); + } + _ => return None, + } + } + match span_text { + // The span holds all the cell shows, so its class is the code's. + Some(inner) if clean(&text).is_empty() => Some((clean(&inner), class)), + Some(_) => None, + None => Some((clean(&text), None)), + } +} + +/// Each element IPOT's page uses with the attributes it gives it, the class +/// tokens it uses, and the attribute values that change what shows (recorded +/// 2026-10). Markup outside this list, whatever it would do (hide, move or +/// replace content: `hidden`, `popover`, `template`, other styles ...), +/// rejects the page. Rules in the site's stylesheet cannot be seen here. +const ELEMENTS: [(&str, &[&str]); 15] = [ + ("html", &[]), + ("button", &["class", "onclick", "type"]), + ("div", &["class", "id", "style"]), + ( + "input", + &["class", "id", "name", "placeholder", "type", "value"], + ), + ("option", &["selected", "value"]), + ("script", &["src"]), + ("select", &["class", "id"]), + ("span", &["class"]), + ( + "table", + &["border", "cellpadding", "cellspacing", "class", "width"], + ), + ("thead", &[]), + ("tbody", &[]), + ("tfoot", &[]), + ("tr", &["class"]), + ("th", &["align", "colspan"]), + ("td", &["align"]), +]; +/// The class tokens each element type carries in IPOT's page. +const CLASSES: [(&str, &[&str]); 7] = [ + ( + "button", + &["btn", "btn-block", "btn-default", "btn-xs-input"], + ), + ( + "div", + &[ + "col", + "col-sm-1", + "col-sm-2", + "col-sm-3", + "col-xs-2", + "col-xs-5", + "mincol-3", + "mt15", + "pa5", + "pt20", + "row", + "table-responsive", + "text-center", + "valign-middle", + "visible-xs", + ], + ), + ("input", &["form-control", "input-xs", "text-center"]), + ("select", &["form-control", "input-xs"]), + ( + "span", + &[ + "fsize12", + "ml5", + "mr5", + "text-", + "text-bumn", + "text-down", + "text-foreign", + "text-local", + "text-up", + ], + ), + ( + "table", + &["nm", "noborder", "table", "table-hover", "table-summary"], + ), + ("tr", &["bdr-t"]), +]; +const PINNED: [(&str, &str, &str); 4] = [ + ("input", "type", "text"), + ("button", "type", "button"), + ("div", "style", "height:50px;"), + ("th", "colspan", "9"), +]; + +/// Whether every element and attribute in the page is one IPOT uses. +fn known_markup(page: &Html) -> bool { + page.root_element().descendent_elements().all(|element| { + let name = element.value().name(); + let Some((_, attrs)) = ELEMENTS.iter().find(|(known, _)| *known == name) else { + return false; + }; + element.value().attrs().all(|(attr, value)| { + attrs.contains(&attr) + && PINNED + .iter() + .filter(|(on, pinned, _)| *on == name && *pinned == attr) + .all(|(_, _, only)| *only == value) + && (attr != "class" + || class_tokens(value).all(|class| { + CLASSES + .iter() + .any(|(on, classes)| *on == name && classes.contains(&class)) + })) + }) + }) +} + +/// Whether `element`'s ancestors are exactly `path` (name and class, nearest +/// first), then the page root: where IPOT places its table and date inputs. +fn placed(element: ElementRef, path: &[(&str, &str)]) -> bool { + let ancestors: Vec = element.ancestors().filter_map(ElementRef::wrap).collect(); + ancestors.len() == path.len() + 1 + && ancestors + .iter() + .zip(path) + .all(|(ancestor, (name, classes))| { + ancestor.value().name() == *name && same_classes(*ancestor, classes) + }) +} + +/// Whether `element`'s classes are exactly `classes`, as sets: order, +/// spacing and repeats don't change them. +fn same_classes(element: ElementRef, classes: &str) -> bool { + let set = |value: &str| { + let mut tokens: Vec = class_tokens(value).map(str::to_string).collect(); + tokens.sort_unstable(); + tokens.dedup(); + tokens + }; + set(element.attr("class").unwrap_or_default()) == set(classes) +} + +const TABLE_PATH: [(&str, &str); 1] = [("div", "table-responsive")]; +const INPUT_PATH: [(&str, &str); 3] = [ + ("div", "col col-xs-5 col-sm-3"), + ("div", "row mincol-3"), + ("div", "pa5"), +]; + +/// The `MM/DD/YYYY` value of the one input with this id, where IPOT puts it. +fn input_date(input: ElementRef) -> Option { + // A text input drops line breaks from its value before showing it. + let value = input.attr("value")?.replace(['\r', '\n'], ""); + NaiveDate::parse_from_str(value.trim(), "%m/%d/%Y").ok() +} + +/// The range the page echoes: IPOT's start and end date inputs, in that +/// order, each with its id and name where IPOT puts it, both readable. +fn echoed_dates(page: &Html) -> Option<(NaiveDate, NaiveDate)> { + let inputs: Vec = page + .root_element() + .descendent_elements() + .filter(|element| { + element.value().name() == "input" + && DATE_INPUTS + .iter() + .any(|(id, _)| element.attr("id") == Some(*id)) + }) + .collect(); + let in_place = inputs.len() == DATE_INPUTS.len() + && inputs.iter().zip(DATE_INPUTS).all(|(input, (id, name))| { + input.attr("id") == Some(id) + && input.attr("name") == Some(name) + && placed(*input, &INPUT_PATH) + }); + match inputs[..] { + [start, end] if in_place => Some((input_date(start)?, input_date(end)?)), + _ => None, + } +} + +const DATE_INPUTS: [(&str, &str); 2] = [ + ("startDateBrokerSummary", "startDate"), + ("endDateBrokerSummary", "endDate"), +]; + +/// The only child element of `parent`, when it is a `name`. +fn sole_child<'a>(parent: ElementRef<'a>, name: &str) -> Option> { + let mut children = parent.child_elements(); + match (children.next(), children.next()) { + (Some(child), None) if child.value().name() == name => Some(child), + _ => None, + } +} + +const MAX_PAGE_BYTES: usize = 64 * 1024; +const MAX_PAGE_TAGS: usize = 2_000; + +/// The column labels, in IPOT's order. +const COLUMNS: [&str; 9] = [ + "Buyer", "B.Lot", "B.Val", "B.Avg", "#", "Seller", "S.Lot", "S.Val", "S.Avg", +]; + +/// The footer's labels, in the order IPOT writes them. +const FOOTER: [&str; 4] = ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"]; + +/// The footer's figures after their labels (`6.6 T` for `T. Val : 6.6 T`), +/// from `tfoot > tr > th > div` holding exactly IPOT's four text-only spans +/// in order, with nothing else shown between them. +fn footer_figures(tfoot: ElementRef) -> Option<[String; 4]> { + // Each element on the way carries exactly IPOT's classes, which could + // otherwise hide a figure (Bootstrap's `visible-xs` on a total, say). + let row = sole_child(tfoot, "tr").filter(|row| same_classes(*row, "bdr-t"))?; + let div = sole_child(sole_child(row, "th")?, "div") + .filter(|div| same_classes(*div, "text-center pt20 valign-middle"))?; + let mut figures = Vec::new(); + for child in div.children() { + match child.value() { + Node::Text(text) if text.trim().is_empty() => {} + Node::Comment(_) => {} + Node::Element(element) if element.name() == "span" => { + let span = ElementRef::wrap(child) + .filter(|span| same_classes(*span, "fsize12 ml5 mr5"))?; + let text = clean(&leaf_text(span)?); + let label = FOOTER.get(figures.len())?; + figures.push(text.strip_prefix(label)?.trim().to_string()); + } + _ => return None, + } + } + figures.try_into().ok() +} + +/// The page is read as a browser builds it from the fragment (html5ever), +/// so markup it would not show as table cells or footer text, such as +/// comments, attributes or other elements' content, is never read as such. +pub fn parse_broker_summary(html: &str) -> Result { + // IPOT's page is about 8 KB with under 500 tags. A bound before parsing + // keeps HTML tree building (which scans open elements per tag) cheap. + if html.len() > MAX_PAGE_BYTES || html.matches('<').count() > MAX_PAGE_TAGS { + return Err(parse_error("page larger than IPOT serves")); + } + // For an unknown code the whole response is this one line. + if html.trim().eq_ignore_ascii_case("invalid stock code!") { + return Ok(IpotPage::Invalid); + } + let page = Html::parse_fragment(html); + if !known_markup(&page) { + return Err(parse_error("markup the page does not use")); + } + let table = only(page.root_element(), "table") + .filter(|table| { + placed(*table, &TABLE_PATH) + && same_classes(*table, "table table-summary table-hover noborder nm") + }) + .ok_or_else(|| parse_error("no single table where the page puts it"))?; + let parts: Vec = table.child_elements().collect(); + let [head, body, tfoot] = parts[..] else { + return Err(parse_error("unexpected table parts")); + }; + // The columns must be labelled as IPOT labels them, in its order. + let headers: Option> = sole_child(head, "tr").and_then(|row| { + row.child_elements() + .map(|th| { + // Only `align`: a span would move the columns under the labels. + (th.value().name() == "th" && only_attrs(th, &["align"])) + .then(|| leaf_text(th)) + .flatten() + }) + .map(|text| text.map(|text| clean(&text))) + .collect() + }); + if headers.as_deref() != Some(&COLUMNS.map(String::from)[..]) { + return Err(parse_error("unexpected column labels")); + } + let names: Vec<&str> = parts.iter().map(|part| part.value().name()).collect(); + if names != ["thead", "tbody", "tfoot"] { + return Err(parse_error("unexpected table parts")); + } + // Without the range it applied, a page's figures could be any session's. + let (from, to) = + echoed_dates(&page).ok_or_else(|| parse_error("no readable echoed date range"))?; + let mut raw = BrokerSummaryRaw { + from: Some(from), + to: Some(to), + ..BrokerSummaryRaw::default() + }; + let mut rounded = false; + let mut figure = |text: &str| { + parse_figure(text).map(|(value, abbreviated)| { + rounded |= abbreviated; + value + }) + }; + // Rows must be whole and ranked in order: a skipped or merged row would + // shift brokers between ranks. A broker after a side's blank rows + // rejects the page too. + let (mut buyers_ended, mut sellers_ended) = (false, false); + for (index, row) in body.child_elements().enumerate() { + let rank = index as u32 + 1; + let tds: Vec = row.child_elements().collect(); + let cells: Option)>> = + if row.value().name() == "tr" && !row.value().attrs().any(|_| true) && tds.len() == 9 { + tds.iter() + .map(|td| (td.value().name() == "td").then(|| cell(*td)).flatten()) + .collect() + } else { + None + }; + let Some(cells) = cells.filter(|cells| cells[4].0.parse::().ok() == Some(rank)) else { + return Err(parse_error(&format!("unexpected row {rank}"))); + }; + for (side, at, ended) in [ + (&mut raw.buyers, 0, &mut buyers_ended), + (&mut raw.sellers, 5, &mut sellers_ended), + ] { + let code = cells[at].0.to_ascii_uppercase(); + if code.is_empty() { + // A blank side shows no figures either. + if cells[at + 1..at + 4] + .iter() + .any(|(text, _)| !text.is_empty()) + { + return Err(parse_error(&format!( + "figures without a broker in row {rank}" + ))); + } + *ended = true; + continue; + } + if *ended { + return Err(parse_error(&format!("broker {code:?} after a blank row"))); + } + if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { + return Err(parse_error(&format!("unexpected broker code {code:?}"))); + } + side.push(BrokerFlow { + rank, + code, + name: None, + kind: broker_kind(cells[at].1.as_deref()), + lots: figure(&cells[at + 1].0), + value: figure(&cells[at + 2].0), + average_price: figure(&cells[at + 3].0), + }); + } + } + let [value, net, lots, average] = + footer_figures(tfoot).ok_or_else(|| parse_error("unexpected footer markup"))?; + raw.total_value = figure(&value); + raw.foreign_net_value = figure(&net); + raw.total_lots = figure(&lots); + // The page prints `Avg : 0` when nothing traded; there is no average then. + raw.average_price = figure(&average).filter(|_| raw.total_lots.is_some_and(|lots| lots > 0)); + raw.rounded = rounded; + Ok(IpotPage::Summary(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: &str = include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261016.html"); + const RANGE: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html"); + const NO_TRADES: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261017.html"); + const INVALID: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_zzzz_invalid.html"); + + fn summary(html: &str) -> BrokerSummaryRaw { + match parse_broker_summary(html).unwrap() { + IpotPage::Summary(raw) => raw, + IpotPage::Invalid => panic!("expected a summary"), + } + } + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn test_parse_figure_scales_in_decimal() { + assert_eq!(parse_figure("1.6 M"), Some((1_600_000, true))); + assert_eq!(parse_figure("841.2 B"), Some((841_200_000_000, true))); + assert_eq!(parse_figure("-2.3 T"), Some((-2_300_000_000_000, true))); + assert_eq!(parse_figure("12 K"), Some((12_000, true))); + assert_eq!(parse_figure("687,067 "), Some((687_067, false))); + assert_eq!(parse_figure("5,191.5"), Some((5_192, false))); + assert_eq!(parse_figure("0"), Some((0, false))); + for bad in ["", " ", "n/a", "1.2.3 M", "M", "-", "1.6789 K"] { + assert_eq!(parse_figure(bad), None, "{bad:?}"); + } + } + + #[test] + fn test_parse_broker_summary_one_day() { + let raw = summary(DAY); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 16)), Some(date(2026, 10, 16))) + ); + assert_eq!((raw.buyers.len(), raw.sellers.len()), (10, 10)); + + let top = &raw.buyers[0]; + assert_eq!( + (top.rank, top.code.as_str(), top.kind), + (1, "MG", Some(BrokerKind::Local)) + ); + assert_eq!( + (top.lots, top.value, top.average_price), + (Some(1_600_000), Some(841_200_000_000), Some(5_191)) + ); + let seller = &raw.sellers[0]; + assert_eq!( + (seller.code.as_str(), seller.kind, seller.value), + ("CC", Some(BrokerKind::StateOwned), Some(2_200_000_000_000)) + ); + assert_eq!(raw.sellers[1].kind, Some(BrokerKind::Foreign)); + let last = &raw.buyers[9]; + assert_eq!((last.rank, last.code.as_str()), (10, "AK")); + assert_eq!(last.lots, Some(398_474), "small figures are exact"); + + assert_eq!(raw.total_value, Some(6_600_000_000_000)); + assert_eq!(raw.foreign_net_value, Some(-2_300_000_000_000)); + assert_eq!(raw.total_lots, Some(12_600_000)); + assert_eq!(raw.average_price, Some(5_228)); + assert!(raw.rounded); + } + + #[test] + fn test_parse_broker_summary_range_echoes_its_dates() { + let raw = summary(RANGE); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 12)), Some(date(2026, 10, 16))) + ); + assert_eq!(raw.buyers.len(), 10); + assert!( + raw.total_value + .is_some_and(|value| value > 6_600_000_000_000) + ); + } + + #[test] + fn test_parse_broker_summary_without_trades_is_empty() { + let raw = summary(NO_TRADES); + assert!(raw.buyers.is_empty() && raw.sellers.is_empty()); + assert_eq!((raw.total_value, raw.total_lots), (Some(0), Some(0))); + assert_eq!(raw.average_price, None, "Avg : 0 means no average"); + assert!(!raw.rounded); + assert_eq!(raw.to, Some(date(2026, 10, 17))); + } + + #[test] + fn test_parse_broker_summary_rejects_unknown_codes_and_markup() { + assert_eq!(parse_broker_summary(INVALID).unwrap(), IpotPage::Invalid); + assert!(matches!( + parse_broker_summary("maintenance"), + Err(IdxError::ParseError(_)) + )); + let odd = DAY.replacen(">MG<", ">M G<", 1); + assert!(matches!( + parse_broker_summary(&odd), + Err(IdxError::ParseError(_)) + )); + } + + /// The BBCA October 16 page with `edit` applied around its rows: `rows` + /// holds where each `` of the body starts. + fn mutated(edit: impl Fn(&str, &[usize]) -> String) -> String { + let body = DAY.find("").unwrap(); + let rows: Vec = DAY[body..] + .match_indices("") + .map(|(at, _)| body + at) + .collect(); + edit(DAY, &rows) + } + + /// Rank 5's row (`` to ``), and its cells alone. + fn rank5(page: &str, rows: &[usize]) -> (usize, usize, usize) { + let start = page[..rows[4]].rfind("").unwrap(); + (start, start + "".len(), rows[4]) + } + + #[test] + fn test_parse_broker_summary_rejects_rows_that_do_not_line_up() { + let first_cell = "MG"; + let lots = "828,163 "; + let hide_rank5 = |wrap: fn(&str) -> String| { + mutated(|page, rows| { + let (start, cells, end) = rank5(page, rows); + page[..start].to_string() + &wrap(&page[cells..end]) + &page[end + 5..] + }) + }; + let pages = [ + ("a missing cell", DAY.replacen(first_cell, "", 1)), + ( + "a rank out of order", + DAY.replacen( + "1", + "2", + 1, + ), + ), + ("a broker after a blank row", DAY.replacen(">XL<", "><", 1)), + ( + "a duplicate rank-5 row", + mutated(|page, rows| { + let (start, _, end) = rank5(page, rows); + page[..start].to_string() + + &page[start..end + 5].to_uppercase() + + &page[start..] + }), + ), + ( + "a table body ended early", + mutated(|page, rows| { + page[..rows[3] + 5].to_string() + "" + &page[rows[3] + 5..] + }), + ), + ( + "cells in a comment", + hide_rank5(|cells| format!("")), + ), + ( + "cells in an attribute", + hide_rank5(|cells| format!("")), + ), + ( + "cells after a quoted '>'", + hide_rank5(|cells| format!("")), + ), + ( + "a row ended mid-cell", + DAY.replacen(lots, &format!("{lots}"), 1), + ), + ( + "a cell under another tag", + DAY.replacen( + "5", + "5", + 1, + ), + ), + ( + "the table in a comment", + DAY.replacen("", "", 1), + ), + ( + "a table inside the table", + DAY.replacen("", "
", 1), + ), + ( + "the body in svg", + DAY.replacen("", "", 1).replacen( + "", + "", + 1, + ), + ), + ( + "the table in a select", + DAY.replacen("", + "", + 1, + ), + ), + ( + "the table in closed details", + DAY.replacen("", + "", + 1, + ), + ), + ("no table", "".to_string()), + // Text a browser would not show, or not as one figure or code. + ( + "a script inside a figure", + DAY.replacen(">828,163 <", ">828,163 <", 1), + ), + ( + "hidden text inside a figure", + DAY.replacen(">828,163 <", ">88,163 <", 1), + ), + ( + "a hidden footer total", + DAY.replacen( + "T. Val", + "T. Val", + 1, + ), + ), + ( + "the table in a template", + DAY.replacen("", + "", + 1, + ), + ), + ( + "another span before the code", + DAY.replacen( + "MG", + "MG", + 1, + ), + ), + ( + "nested footer spans", + DAY.replacen( + "T. Val : 6.6 T", + "xT. Val : 6.6 T", + 1, + ), + ), + ]; + for (what, page) in pages { + assert!( + matches!(parse_broker_summary(&page), Err(IdxError::ParseError(_))), + "{what}" + ); + } + } + + #[test] + fn test_parse_broker_summary_reads_what_a_browser_shows() { + let original = summary(DAY); + // Markup a browser repairs or decodes to the same table. + let same = [ + ( + "rank 1 without cell ends", + mutated(|page, rows| { + let start = page[..rows[0]].rfind("").unwrap(); + page[..start].to_string() + + &page[start..rows[0]].replace("", "") + + &page[rows[0]..] + }), + ), + ( + "rank 9 left open, rank 10 without cell ends", + mutated(|page, rows| { + page[..rows[8]].to_string() + + &page[rows[8] + 5..rows[9]].replace("", "") + + &page[rows[9]..] + }), + ), + ( + "a character reference", + DAY.replacen(">828,163 <", ">828,163 <", 1), + ), + ( + "an abrupt comment", + mutated(|page, rows| { + page[..rows[7] + 5].to_string() + "" + &page[rows[7] + 5..] + }), + ), + ( + "a decoy attribute before the date", + DAY.replacen( + "id=\"startDateBrokerSummary\"", + "id=\"startDateBrokerSummary\" data-value=\"01/01/2001\"", + 1, + ), + ), + ( + "a decoy total outside the footer", + DAY.replacen( + "", + "
\">
", + 1, + ), + ), + ( + "a script after the table", + DAY.replacen("", "", 1), + ), + ]; + // Each page is rejected or read as shown; never read as other data. + let read = |page: &str| match parse_broker_summary(page) { + Ok(IpotPage::Summary(raw)) => Some(raw), + Err(IdxError::ParseError(_)) => None, + other => panic!("{other:?}"), + }; + for (what, page) in same { + if let Some(raw) = read(&page) { + assert_eq!(raw, original, "{what}"); + } + } + // Markup a browser shows differently: the figure it shows, not ours. + let lots = "828,163 "; + let shown_otherwise = [ + ( + "a '>' in a figure", + DAY.replacen(">828,163 <", ">8>28,163 <", 1), + ), + ( + "a cell ended early", + DAY.replacen(lots, &format!("{lots}"), 1), + ), + ]; + for (what, page) in shown_otherwise { + if let Some(raw) = read(&page) { + assert_eq!(raw.buyers[4].lots, None, "{what}"); + } + } + let dates_shown_otherwise = [ + ( + "a comment in the date", + DAY.replacen("value=\"10/16/2026\"", "value=\"10/16/2026\"", 1), + ), + ( + "the date input twice", + DAY.replacen( + "", + "", + 1, + ), + ), + ( + "the date input in details", + DAY.replacen("