From 8b507d78e2e92153fa28222f36c99ba3b6896f19 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 06:19:02 +0000 Subject: [PATCH] feat: foreign flow from IPOT by default (--source ipot|idx) `idx stocks flow` now reads IPOT by default, which needs nothing installed: per session, the all-investor broker summary gives the value traded and the foreign one gives foreign buying (its footer), the foreign net and selling (buying less the net), across all boards, through the broker summary cache. IPOT's foreign net matched IDX's estimate within 0.3 B on BBCA 2026-10-12 to 16. `--source idx` (and `source` on get_foreign_flow) keeps IDX's trading summary: exact share counts, close and the non-regular share, behind curl-impersonate and Cloudflare. - ForeignFlowDay gains foreign_buy_value, foreign_sell_value and foreign_share_bps for both sources (estimated at the session average for IDX); ForeignFlow gains source, the value totals and rounded. - The table lays out each source's figures; the live smoke runs both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- ROADMAP.md | 5 +- crates/idx-cli/src/cli/stocks.rs | 20 +- crates/idx-cli/src/output/table.rs | 65 +++- crates/idx-cli/tests/cli.rs | 67 +++- crates/idx-cli/tests/golden/cases.toml | 9 +- .../tests/golden/completions-bash.golden | 6 +- .../golden/stocks-flow-idx-latest.json.golden | 4 + ...en => stocks-flow-idx-latest.table.golden} | 2 +- .../tests/golden/stocks-flow-idx.json.golden | 4 + .../tests/golden/stocks-flow-idx.jsonl.golden | 4 + .../tests/golden/stocks-flow-idx.table.golden | 19 ++ .../golden/stocks-flow-latest.json.golden | 4 - .../tests/golden/stocks-flow.json.golden | 2 +- .../tests/golden/stocks-flow.jsonl.golden | 2 +- .../tests/golden/stocks-flow.table.golden | 33 +- crates/idx-core/src/broker/mod.rs | 6 +- crates/idx-core/src/flow/mod.rs | 306 ++++++++++++++---- crates/idx-core/src/flow/types.rs | 92 +++++- .../ipot_brokersummary_bbca_20261012.html | 181 +++++++++++ ...okersummary_bbca_20261012_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261013.html | 181 +++++++++++ ...okersummary_bbca_20261013_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261014.html | 181 +++++++++++ ...okersummary_bbca_20261014_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261015.html | 181 +++++++++++ ...okersummary_bbca_20261015_foreign_all.html | 181 +++++++++++ crates/idx-mcp/src/params.rs | 11 + crates/idx-mcp/src/server.rs | 15 +- crates/idx-mcp/tests/golden/tools.json | 11 +- crates/idx-mcp/tests/mcp.rs | 16 +- docs/ARCHITECTURE.md | 12 +- docs/SMOKE.md | 4 +- scripts/live-smoke.sh | 7 +- web/src/content/docs/cli/reference.md | 14 +- web/src/content/docs/cli/stocks.md | 25 +- web/src/content/docs/mcp/tools.md | 3 +- web/src/content/docs/start/install.mdx | 5 +- 37 files changed, 2079 insertions(+), 142 deletions(-) create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden rename crates/idx-cli/tests/golden/{stocks-flow-latest.table.golden => stocks-flow-idx-latest.table.golden} (97%) create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.table.golden delete mode 100644 crates/idx-cli/tests/golden/stocks-flow-latest.json.golden create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html diff --git a/ROADMAP.md b/ROADMAP.md index 2194c72..56cf036 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,8 +36,9 @@ history lives in git. - `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` depends on IDX's Cloudflare passing a curl-impersonate - profile (only `curl_safari184` in October 2026). +- `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 9667c2d..d27e576 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -15,7 +15,7 @@ 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; +use idx_core::flow::types::{FlowRange, FlowSource}; use idx_core::market::ScreenQuery; use idx_core::{CachePolicy, Idx, runtime}; @@ -224,11 +224,13 @@ heuristic over end-of-day broker codes, not advice.", }, #[command( about = "Foreign buying and selling of a stock, session by session", - long_about = "Foreign buying and selling of a stock, session by session, from IDX's daily \ -trading summary: foreign buy, sell and net volume, the net value at the session's average \ -price, and the share of value traded outside the regular board. End of day. Each session is \ -one request to idx.co.id, which needs curl-impersonate.", - 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 -o json stocks flow BBCA | jq '.foreign_net'" + 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). @@ -242,6 +244,9 @@ one request to idx.co.id, which needs curl-impersonate.", /// 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 }, @@ -389,10 +394,11 @@ pub fn handle( 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, policy)?; + let flow = idx.flow().foreign(symbol, range, *source, policy)?; render_foreign_flow(&flow.data, format) } StocksSubcommand::Profile { symbol } => { diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 4e742e9..42b9afd 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -16,7 +16,7 @@ use idx_core::api::types::{ }; use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; -use idx_core::flow::types::ForeignFlow; +use idx_core::flow::types::{FlowSource, ForeignFlow}; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -1262,12 +1262,70 @@ pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { println!( "{}", format!( - "Foreign flow for {}, {span} ({sessions} session{})", + "Foreign flow for {}, {span} ({sessions} session{}, {})", terminal_safe(&flow.symbol), - if sessions == 1 { "" } else { "s" } + 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) @@ -1314,7 +1372,6 @@ pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { is estimated at each session's average price; NON-REG is the share of all value traded \ outside the regular board (negotiated and cash)." ); - Ok(()) } #[cfg(test)] diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 5028e9f..7cddf9d 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2758,11 +2758,13 @@ fn bandar_errors_follow_the_output_contract() { } #[test] -fn foreign_flow_reads_sessions_oldest_first() { +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"]), + .args([ + "-o", "json", "stocks", "flow", "BBCA", "--days", "3", "--source", "idx", + ]), ); assert_eq!(flow["symbol"], "BBCA.JK"); let dates: Vec<&str> = flow["days"] @@ -2787,6 +2789,8 @@ fn foreign_flow_reads_sessions_oldest_first() { "json", "stocks", "flow", + "--source", + "idx", "BBCA", "--from", "2026-10-12", @@ -2805,7 +2809,7 @@ fn foreign_flow_reads_sessions_oldest_first() { } #[test] -fn foreign_flow_offline_uses_cached_sessions_only() { +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) @@ -2815,6 +2819,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "json", "stocks", "flow", + "--source", + "idx", "BBCA", "--from", "2026-10-15", @@ -2832,6 +2838,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "--offline", "stocks", "flow", + "--source", + "idx", "TLKM", "--days", "5", @@ -2848,6 +2856,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "--offline", "stocks", "flow", + "--source", + "idx", "TLKM", "--from", "2026-10-12", @@ -2858,6 +2868,52 @@ fn foreign_flow_offline_uses_cached_sessions_only() { ); } +#[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 [ @@ -2873,6 +2929,11 @@ fn foreign_flow_errors_follow_the_output_contract() { 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") diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 2e83d91..2cb5311 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -273,8 +273,13 @@ formats = ["table", "json", "jsonl"] args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] [[case]] -name = "stocks-flow-latest" -args = ["stocks", "flow", "TLKM", "--days", "3"] +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]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index f6630c2..aff5968 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -2442,7 +2442,7 @@ _idx() { return 0 ;; idx__stocks__flow) - opts="-o -q -v -h --days --from --to --output --no-color --quiet --verbose --offline --no-cache --help " + 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 @@ -2460,6 +2460,10 @@ _idx() { 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 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-latest.table.golden b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden similarity index 97% rename from crates/idx-cli/tests/golden/stocks-flow-latest.table.golden rename to crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden index d9e7bf6..8dad2c5 100644 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions) +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 │ ╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ 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-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden deleted file mode 100644 index 9711422..0000000 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden +++ /dev/null @@ -1,4 +0,0 @@ -exit: 0 ---- stdout -{"symbol":"TLKM.JK","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_net_value":5033890729,"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_net_value":-18020792704,"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_net_value":-92968634589,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213} ---- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.json.golden b/crates/idx-cli/tests/golden/stocks-flow.json.golden index 3a4105e..29e9d90 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.json.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","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_net_value":-56178999154,"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_net_value":29800279088,"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_net_value":216356236186,"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_net_value":286060704877,"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_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"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 index 3a4105e..29e9d90 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","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_net_value":-56178999154,"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_net_value":29800279088,"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_net_value":216356236186,"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_net_value":286060704877,"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_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"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 index 0736ef1..066a27a 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.table.golden @@ -1,19 +1,20 @@ exit: 0 --- stdout -Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions) -┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ -│ 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). +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-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index b53addf..7392266 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -167,7 +167,11 @@ impl Broker<'_> { /// trades the lists show. `F. NVal`, the foreign net, ignores both filters: /// on all boards it gives the side's selling (foreign buying less the foreign /// net; domestic buying plus it), and with a board filter it is dropped. -fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary { +pub(crate) fn summary_from_raw( + symbol: &str, + query: &BrokerQuery, + raw: BrokerSummaryRaw, +) -> BrokerSummary { let one_side = query.investor != BrokerInvestor::All; let all_boards = query.board == BrokerBoard::All; let foreign_net = raw.foreign_net_value.filter(|_| all_boards); diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs index cbfc52e..eec2d31 100644 --- a/crates/idx-core/src/flow/mod.rs +++ b/crates/idx-core/src/flow/mod.rs @@ -1,18 +1,19 @@ //! Foreign flow: what foreign investors bought and sold of a stock, session -//! by session, from IDX's daily trading summary (end of day). One request -//! covers every stock for one session and is cached by date. +//! by session (end of day). IPOT (the default) needs two plain requests per +//! session; IDX's trading summary covers every stock in one request per +//! session, cached by date, but needs curl-impersonate. pub mod types; use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; use crate::api::{today_wib, trading_summary_provider}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; -use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay}; +use types::{FlowRange, FlowSource, ForeignFlow, ForeignFlowDay, StockDay}; -const SOURCE: &str = "idx"; -/// Most sessions `FlowRange::Latest` reads, one request each. +/// Most sessions `FlowRange::Latest` reads. pub const MAX_SESSIONS: usize = 60; /// Longest `FlowRange::Dates` range, calendar days. pub const MAX_RANGE_DAYS: i64 = 92; @@ -24,17 +25,36 @@ pub struct Flow<'a> { pub(crate) idx: &'a Idx, } +/// What one weekday held for the stock. +enum Day { + /// The market (or, for IPOT, the stock) did not trade. + NoSession, + /// The market traded without the stock (a suspension). + Absent, + Traded { + day: Box, + rounded: bool, + }, +} + +struct Fetch { + day: Day, + fetched_at: DateTime, + stale: bool, +} + impl Flow<'_> { /// Today in WIB. pub fn today(&self) -> NaiveDate { today_wib() } - /// Foreign buying and selling of `symbol` over `range`. + /// Foreign buying and selling of `symbol` over `range`, from `source`. pub fn foreign( &self, symbol: &str, range: FlowRange, + source: FlowSource, policy: CachePolicy, ) -> Result, IdxError> { let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?; @@ -63,8 +83,9 @@ impl Flow<'_> { } }; - let mut rows = Vec::new(); + let mut days = Vec::new(); let mut sessions = 0; + let mut rounded = false; let mut fetched_at: Option> = None; let mut stale = false; for date in dates { @@ -74,39 +95,117 @@ impl Flow<'_> { if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) { continue; } - let day = match self.session(date, today, policy) { - Ok(day) => day, + let fetch = match source { + FlowSource::Ipot => self.ipot_day(&resolved, date, policy), + FlowSource::Idx => self.idx_day(&code, date, today, policy), + }; + let fetch = match fetch { + Ok(fetch) => fetch, Err(IdxError::CacheMiss(_)) if skip_misses => continue, Err(err) => return Err(err), }; - if day.data.is_empty() { - continue; + match fetch.day { + Day::NoSession => continue, + Day::Absent => {} + Day::Traded { + day, + rounded: day_rounded, + } => { + days.push(*day); + rounded |= day_rounded; + } } sessions += 1; - fetched_at = Some(fetched_at.map_or(day.fetched_at, |at| at.min(day.fetched_at))); - stale |= day.stale; - // A stock missing from a session did not trade (suspension). - if let Some(row) = day.data.into_iter().find(|row| row.code == code) { - rows.push(row); - } + fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); + stale |= fetch.stale; } if sessions == 0 { return Err(if policy == CachePolicy::Offline { - IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)")) + IdxError::CacheMiss(format!( + "{}-flow/{resolved} (no cached session)", + source.as_str() + )) } else { - IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range")) + IdxError::NoMarketData(format!("{resolved}: no sessions in range")) }); } - rows.reverse(); - let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?; + days.reverse(); + let flow = + foreign_flow(&resolved, source, days, rounded).ok_or(IdxError::SymbolNotFound(code))?; Ok(Fetched { data: flow, - source: SOURCE, + source: source.as_str(), fetched_at: fetched_at.unwrap_or_else(Utc::now), stale, }) } + /// One session from IPOT: the all-investor summary for the value traded, + /// the foreign one for foreign buying, selling and net. Both go through + /// the broker summary cache. + fn ipot_day( + &self, + resolved: &str, + date: NaiveDate, + policy: CachePolicy, + ) -> Result { + let broker = self.idx.broker(); + let all_query = BrokerQuery { + from: date, + to: date, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + }; + let all = broker.summary(resolved, &all_query, policy)?; + if all.data.is_empty() { + return Ok(Fetch { + day: Day::NoSession, + fetched_at: all.fetched_at, + stale: all.stale, + }); + } + let foreign_query = BrokerQuery { + investor: BrokerInvestor::Foreign, + ..all_query + }; + let foreign = broker.summary(resolved, &foreign_query, policy)?; + Ok(Fetch { + day: Day::Traded { + day: Box::new(ipot_flow_day(date, &all.data, &foreign.data)), + rounded: all.data.rounded || foreign.data.rounded, + }, + fetched_at: all.fetched_at.min(foreign.fetched_at), + stale: all.stale || foreign.stale, + }) + } + + /// One session from IDX's trading summary of every stock. + fn idx_day( + &self, + code: &str, + date: NaiveDate, + today: NaiveDate, + policy: CachePolicy, + ) -> Result { + let session = self.session(date, today, policy)?; + let day = if session.data.is_empty() { + Day::NoSession + } else { + match session.data.iter().find(|row| row.code == code) { + Some(row) => Day::Traded { + day: Box::new(idx_flow_day(row)), + rounded: false, + }, + None => Day::Absent, + } + }; + Ok(Fetch { + day, + fetched_at: session.fetched_at, + stale: session.stale, + }) + } + /// Every stock's summary for one session; empty without trading. fn session( &self, @@ -127,7 +226,7 @@ impl Flow<'_> { TTL_TODAY_SECS }, subject: &key, - source: SOURCE, + source: FlowSource::Idx.as_str(), }, policy, || provider.stock_summary(date), @@ -160,17 +259,54 @@ fn bps(part: i64, whole: i64) -> Option { (whole > 0).then(|| (part as f64 * 10_000.0 / whole as f64).round() as i64) } -fn flow_day(row: &StockDay) -> ForeignFlowDay { +/// `shares` at the session's average price (value over volume), whole IDR. +fn at_average(shares: Option, value: Option, volume: Option) -> Option { + match (shares, value, volume) { + (Some(shares), Some(value), Some(volume)) if volume > 0 => { + i64::try_from(i128::from(shares) * i128::from(value) / i128::from(volume)).ok() + } + _ => None, + } +} + +/// Foreign buying plus selling over twice `total`. +fn share(buy: Option, sell: Option, total: Option) -> Option { + let traded = buy?.checked_add(sell?)?; + bps(traded, total?.checked_mul(2)?) +} + +/// One IPOT session: the value traded from the all-investor summary, the +/// foreign figures from the foreign one (`buy_value` is its footer, +/// `sell_value` that less the foreign net). +pub fn ipot_flow_day( + date: NaiveDate, + all: &BrokerSummary, + foreign: &BrokerSummary, +) -> ForeignFlowDay { + let foreign_net_value = foreign.foreign_net_value.or(all.foreign_net_value); + ForeignFlowDay { + date, + close: None, + change_bps: None, + volume: all.total_lots.and_then(|lots| lots.checked_mul(100)), + value: all.total_value, + foreign_buy: None, + foreign_sell: None, + foreign_net: None, + foreign_buy_value: foreign.buy_value, + foreign_sell_value: foreign.sell_value, + foreign_net_value, + foreign_share_bps: share(foreign.buy_value, foreign.sell_value, all.total_value), + non_regular_bps: None, + } +} + +/// One IDX session for one stock. +pub fn idx_flow_day(row: &StockDay) -> ForeignFlowDay { let foreign_net = row .foreign_buy .zip(row.foreign_sell) .map(|(buy, sell)| buy - sell); - let foreign_net_value = match (foreign_net, row.value, row.volume) { - (Some(net), Some(value), Some(volume)) if volume > 0 => { - i64::try_from(i128::from(net) * i128::from(value) / i128::from(volume)).ok() - } - _ => None, - }; ForeignFlowDay { date: row.date, close: row.close, @@ -183,7 +319,10 @@ fn flow_day(row: &StockDay) -> ForeignFlowDay { foreign_buy: row.foreign_buy, foreign_sell: row.foreign_sell, foreign_net, - foreign_net_value, + foreign_buy_value: at_average(row.foreign_buy, row.value, row.volume), + foreign_sell_value: at_average(row.foreign_sell, row.value, row.volume), + foreign_net_value: at_average(foreign_net, row.value, row.volume), + foreign_share_bps: share(row.foreign_buy, row.foreign_sell, row.volume), // `value` is the regular board only; the share is of all trading. non_regular_bps: row .non_regular_value @@ -197,25 +336,38 @@ fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option) -> Op .try_fold(0_i64, |total, day| total.checked_add(field(day)?)) } -/// The flow over `rows` (one stock, oldest first); `None` without rows. -pub fn foreign_flow(symbol: &str, rows: &[StockDay]) -> Option { - let (first, last) = (rows.first()?, rows.last()?); - let days: Vec = rows.iter().map(flow_day).collect(); - let foreign_share_bps = match ( - sum(&days, |day| day.foreign_buy), - sum(&days, |day| day.foreign_sell), - sum(&days, |day| day.volume), - ) { - (Some(buy), Some(sell), Some(volume)) => bps(buy + sell, 2 * volume), - _ => None, +/// The flow over `days` (one stock, oldest first); `None` without days. +pub fn foreign_flow( + symbol: &str, + source: FlowSource, + days: Vec, + rounded: bool, +) -> Option { + let (from, to) = (days.first()?.date, days.last()?.date); + // IDX has exact shares; IPOT has values. + let foreign_share_bps = match source { + FlowSource::Idx => share( + sum(&days, |day| day.foreign_buy), + sum(&days, |day| day.foreign_sell), + sum(&days, |day| day.volume), + ), + FlowSource::Ipot => share( + sum(&days, |day| day.foreign_buy_value), + sum(&days, |day| day.foreign_sell_value), + sum(&days, |day| day.value), + ), }; Some(ForeignFlow { symbol: symbol.to_string(), - from: first.date, - to: last.date, + source, + from, + to, foreign_net: sum(&days, |day| day.foreign_net), + foreign_buy_value: sum(&days, |day| day.foreign_buy_value), + foreign_sell_value: sum(&days, |day| day.foreign_sell_value), foreign_net_value: sum(&days, |day| day.foreign_net_value), foreign_share_bps, + rounded, days, }) } @@ -242,13 +394,15 @@ mod tests { } } + fn idx_flow(rows: &[StockDay]) -> Option { + let days = rows.iter().map(idx_flow_day).collect(); + foreign_flow("TEST.JK", FlowSource::Idx, days, false) + } + #[test] fn test_foreign_flow_sums_sessions_and_estimates_value() { - let flow = foreign_flow( - "TEST.JK", - &[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))], - ) - .unwrap(); + let flow = + idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))]).unwrap(); let first = &flow.days[0]; assert_eq!( (first.foreign_net, first.change_bps), @@ -256,6 +410,11 @@ mod tests { ); // 400 shares at 1,100 (value over volume). assert_eq!(first.foreign_net_value, Some(440_000)); + assert_eq!( + (first.foreign_buy_value, first.foreign_sell_value), + (Some(660_000), Some(220_000)) + ); + assert_eq!(first.foreign_share_bps, Some(4000)); // 110,000 of 1,100,000 regular plus 110,000 non-regular. assert_eq!(first.non_regular_bps, Some(909)); assert_eq!(flow.days[1].change_bps, Some(-500)); @@ -270,14 +429,10 @@ mod tests { #[test] fn test_foreign_flow_never_counts_a_missing_figure_as_zero() { - let flow = foreign_flow( - "TEST.JK", - &[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)], - ) - .unwrap(); + let flow = idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)]).unwrap(); assert_eq!(flow.days[1].foreign_net, None); assert_eq!((flow.foreign_net, flow.foreign_share_bps), (None, None)); - assert!(foreign_flow("TEST.JK", &[]).is_none()); + assert!(idx_flow(&[]).is_none()); } #[test] @@ -286,11 +441,50 @@ mod tests { let date = NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(); let rows = crate::api::idx::parse_stock_summary(raw, date).unwrap(); let bbri: Vec = rows.into_iter().filter(|row| row.code == "BBRI").collect(); - let flow = foreign_flow("BBRI.JK", &bbri).unwrap(); + let flow = idx_flow(&bbri).unwrap(); // 172.3B non-regular against 338.5B regular: 33.74%, not 50.91%. assert_eq!(flow.days[0].non_regular_bps, Some(3374)); } + #[test] + fn test_ipot_flow_day_reads_foreign_buying_and_selling_from_the_summaries() { + let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(); + let summary = |investor, name: &str| { + let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap(); + let raw = crate::api::ipot::summary_from_html("BBCA", &html).unwrap(); + let query = BrokerQuery { + from: day, + to: day, + investor, + board: BrokerBoard::All, + }; + crate::broker::summary_from_raw("BBCA.JK", &query, raw) + }; + let all = summary(BrokerInvestor::All, "ipot_brokersummary_bbca_20261016.html"); + let foreign = summary( + BrokerInvestor::Foreign, + "ipot_brokersummary_bbca_20261016_foreign_all.html", + ); + let flow = ipot_flow_day(day, &all, &foreign); + assert_eq!(flow.value, Some(6_600_000_000_000)); + assert_eq!(flow.volume, Some(1_260_000_000)); + assert_eq!( + ( + flow.foreign_buy_value, + flow.foreign_sell_value, + flow.foreign_net_value + ), + ( + Some(1_700_000_000_000), + Some(4_000_000_000_000), + Some(-2_300_000_000_000) + ) + ); + // (1.7 T + 4.0 T) / (2 * 6.6 T). + assert_eq!(flow.foreign_share_bps, Some(4318)); + assert_eq!((flow.close, flow.foreign_net), (None, None)); + } + #[test] fn test_check_range_bounds() { let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); diff --git a/crates/idx-core/src/flow/types.rs b/crates/idx-core/src/flow/types.rs index 1e01423..28fdc45 100644 --- a/crates/idx-core/src/flow/types.rs +++ b/crates/idx-core/src/flow/types.rs @@ -1,5 +1,7 @@ //! Foreign flow types: what foreign investors bought and sold of a stock, -//! session by session (IDX trading summary, end of day). +//! session by session (end of day), from IPOT or IDX's trading summary. + +use std::str::FromStr; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; @@ -9,6 +11,47 @@ use crate::error::IdxError; /// Sessions shown when a frontend gives neither days nor dates. pub const DEFAULT_SESSIONS: usize = 10; +/// Where a foreign flow comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum FlowSource { + /// Indo Premier's broker summary: foreign buy, sell and net value across + /// all boards, two plain requests per session. Figures of a million and + /// more are rounded to one decimal. + #[default] + Ipot, + /// IDX's own trading summary: exact foreign share counts, close and the + /// non-regular share, regular board. Needs curl-impersonate and a + /// network IDX's Cloudflare lets through. + Idx, +} + +impl FlowSource { + pub const ALL: [Self; 2] = [Self::Ipot, Self::Idx]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Ipot => "ipot", + Self::Idx => "idx", + } + } +} + +impl FromStr for FlowSource { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "ipot" => Ok(Self::Ipot), + "idx" => Ok(Self::Idx), + _ => Err(IdxError::InvalidInput(format!( + "invalid source '{value}'; use ipot or idx" + ))), + } + } +} + /// One stock's trading summary for one session, as IDX publishes it. /// Prices are whole IDR, volumes are shares. Volume, value and the foreign /// figures cover the regular board only (across a week of every stock, no @@ -77,27 +120,41 @@ impl FlowRange { } } +/// One session. Which figures are present depends on the source: IPOT gives +/// values across all boards, IDX gives exact shares on the regular board (and +/// values estimated from them) with the close and the non-regular share. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForeignFlowDay { pub date: NaiveDate, + /// Close, whole IDR (IDX only). pub close: Option, - /// Close against the previous close, basis points. + /// Close against the previous close, basis points (IDX only). pub change_bps: Option, - /// Shares traded on the regular board. + /// Shares traded: the regular board (IDX), or all boards from lots + /// (IPOT). pub volume: Option, - /// Value traded on the regular board, whole IDR. + /// Value traded, whole IDR: the regular board (IDX) or all boards (IPOT). pub value: Option, - /// Shares foreign investors bought (regular board). + /// Shares foreign investors bought (IDX only). pub foreign_buy: Option, - /// Shares foreign investors sold. + /// Shares foreign investors sold (IDX only). pub foreign_sell: Option, - /// `foreign_buy - foreign_sell`, shares. + /// `foreign_buy - foreign_sell`, shares (IDX only). pub foreign_net: Option, - /// `foreign_net` at the session's average price (value over volume), - /// whole IDR: an estimate. + /// Value foreign investors bought, whole IDR: as published (IPOT), or + /// estimated at the session's average price (IDX). + pub foreign_buy_value: Option, + /// Value foreign investors sold, whole IDR: their buying less their net + /// (IPOT), or estimated at the session's average price (IDX). + pub foreign_sell_value: Option, + /// Foreign buy value minus sell value, whole IDR: as published (IPOT), or + /// net shares at the session's average price (IDX, an estimate). pub foreign_net_value: Option, + /// Foreign buying and selling over twice the session's trading: foreign + /// investors' share of it, basis points. + pub foreign_share_bps: Option, /// Value traded outside the regular board (negotiated and cash), basis - /// points of all value traded (regular plus non-regular). + /// points of all value traded (IDX only). pub non_regular_bps: Option, } @@ -105,16 +162,23 @@ pub struct ForeignFlowDay { pub struct ForeignFlow { /// Resolved ticker, e.g. `BBCA.JK`. pub symbol: String, + pub source: FlowSource, /// First and last session covered. pub from: NaiveDate, pub to: NaiveDate, /// Sessions the stock traded, oldest first. pub days: Vec, - /// Net shares over the sessions; `None` when a session lacks a figure. + /// Net shares over the sessions (IDX only); `None` when a session lacks + /// a figure. pub foreign_net: Option, - /// Sum of the sessions' estimated net values, whole IDR. + /// Foreign buy value over the sessions, whole IDR. + pub foreign_buy_value: Option, + /// Foreign sell value over the sessions, whole IDR. + pub foreign_sell_value: Option, + /// Foreign net value over the sessions, whole IDR. pub foreign_net_value: Option, - /// Foreign buys and sells over twice the volume: foreign investors' - /// share of regular-board trading, basis points. + /// Foreign investors' share of trading over the sessions, basis points. pub foreign_share_bps: Option, + /// The source rounded figures of a million and more to one decimal. + pub rounded: bool, } diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html new file mode 100644 index 0000000..542bf59 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP319,263 191.3 B5,993 1CC248,149 148.6 B5,988
CC91,178 54.7 B5,998 2KZ169,689 101.8 B6,000
RX82,364 49.3 B5,991 3ZP142,270 85.1 B5,980
SQ70,119 41.9 B5,973 4AK109,896 65.7 B5,978
XL66,085 39.5 B5,982 5YU51,291 30.7 B5,977
BB46,535 27.9 B5,989 6BK35,962 21.5 B5,988
YP31,891 19.1 B5,983 7XL33,257 19.9 B5,987
XC31,403 18.8 B5,983 8CP29,928 17.8 B5,962
AK24,560 14.7 B5,967 9PD29,063 17.5 B6,023
BK20,056 12.0 B5,970 10SQ23,464 14.1 B6,002
+
+ T. Val : 587.1 B + F. NVal : -55.9 B + T.Lot : 980,415 + Avg : 5,988 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html new file mode 100644 index 0000000..fe1ce60 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP316,144 189.5 B5,993 1CC201,030 120.4 B5,992
RX82,364 49.3 B5,991 2ZP137,297 82.1 B5,981
BB25,000 15.0 B5,989 3KZ123,872 74.4 B6,007
AK24,560 14.7 B5,967 4AK89,396 53.5 B5,980
BK20,056 12.0 B5,970 5BK25,685 15.4 B5,985
CC19,945 12.1 B6,069 6RX20,942 12.5 B5,950
KZ8,553 5.2 B6,075 7YU7,476 4.5 B5,989
BQ5,000 3.0 B5,950 8HD1,084 647.7 M5,975
AG4,315 2.6 B5,951 9BB782 465.3 M5,950
YU3,957 2.4 B5,979 10AG696 417.3 M5,995
+
+ T. Val : 308.7 B + F. NVal : -55.9 B + T.Lot : 514,995 + Avg : 5,994 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html new file mode 100644 index 0000000..bf6e224 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP353,553 209.7 B5,931 1ZP362,463 215.0 B5,933
CC123,885 73.3 B5,919 2CC170,087 100.6 B5,914
BK119,389 70.6 B5,915 3KZ131,973 78.2 B5,922
TP114,823 68.1 B5,931 4AG113,249 67.1 B5,923
RX90,525 53.7 B5,931 5AK99,863 59.0 B5,904
AK84,572 50.1 B5,920 6BK87,628 51.8 B5,915
YU54,296 32.2 B5,928 7YU58,039 34.4 B5,926
XL44,062 26.0 B5,907 8XL31,516 18.6 B5,905
SQ38,109 22.5 B5,901 9SQ21,672 12.8 B5,917
YP22,957 13.5 B5,898 10BQ19,658 11.7 B5,937
+
+ T. Val : 738.2 B + F. NVal : 30.1 B + T.Lot : 1.2 M + Avg : 5,921 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html new file mode 100644 index 0000000..f46eb58 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP347,552 206.1 B5,931 1ZP361,884 214.7 B5,933
BK119,389 70.6 B5,915 2CC134,566 79.6 B5,918
TP112,063 66.5 B5,932 3AG113,249 67.1 B5,923
RX90,525 53.7 B5,931 4AK99,863 59.0 B5,904
AK84,572 50.1 B5,920 5YU57,345 34.0 B5,927
CC82,117 48.7 B5,925 6BK49,256 29.1 B5,918
YU48,065 28.5 B5,932 7DP12,747 7.5 B5,917
HD12,342 7.3 B5,905 8KZ11,973 7.1 B5,924
KZ3,683 2.2 B5,929 9HD8,420 5.0 B5,925
EP3,000 1.8 B5,925 10BQ5,000 3.0 B5,925
+
+ T. Val : 537.3 B + F. NVal : 30.1 B + T.Lot : 906,459 + Avg : 5,927 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html new file mode 100644 index 0000000..8785c56 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ554,786 338.9 B6,109 1AK525,906 319.8 B6,081
AK485,138 294.7 B6,075 2ZP377,446 230.0 B6,094
YU187,056 113.8 B6,085 3KZ164,627 100.4 B6,096
ZP160,092 97.1 B6,062 4SQ103,273 62.8 B6,082
RX79,012 48.0 B6,071 5BK98,516 59.8 B6,070
BK70,651 43.1 B6,096 6YU73,085 44.6 B6,098
BB65,000 39.5 B6,073 7YP51,697 31.4 B6,075
BQ31,865 19.2 B6,027 8CC48,169 29.3 B6,082
CC30,758 18.7 B6,066 9XL46,536 28.3 B6,074
YP27,270 16.6 B6,085 10BQ39,660 24.1 B6,070
+
+ T. Val : 1.1 T + F. NVal : 216.6 B + T.Lot : 1.8 M + Avg : 6,084 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html new file mode 100644 index 0000000..57c9f36 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ554,012 338.5 B6,109 1AK525,906 319.8 B6,081
AK485,138 294.7 B6,075 2ZP368,595 224.6 B6,094
YU187,042 113.8 B6,085 3KZ163,864 99.9 B6,096
ZP156,660 95.0 B6,063 4BK98,516 59.8 B6,070
RX79,012 48.0 B6,071 5YU59,747 36.5 B6,103
BK70,651 43.1 B6,096 6BB20,374 12.4 B6,074
BB65,000 39.5 B6,073 7HD17,554 10.6 B6,056
CC17,593 10.7 B6,067 8CC12,729 7.8 B6,094
HD13,632 8.2 B6,050 9EP3,000 1.8 B6,075
DP474 288.2 M6,080 10IU1,200 729.0 M6,075
+
+ T. Val : 991.8 B + F. NVal : 216.6 B + T.Lot : 1.6 M + Avg : 6,087 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html new file mode 100644 index 0000000..7e51a42 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
YU447,791 271.5 B6,064 1CC335,078 203.1 B6,062
ZP284,818 172.7 B6,062 2YU229,539 139.1 B6,061
DX194,491 117.8 B6,059 3ZP225,482 136.6 B6,059
KZ138,619 84.0 B6,058 4AK179,064 108.5 B6,059
AK123,061 74.5 B6,057 5BB143,526 86.9 B6,053
CC78,820 47.7 B6,050 6BK68,285 41.3 B6,049
RX72,951 44.2 B6,055 7SQ63,839 38.6 B6,050
BK36,383 22.1 B6,066 8CP49,275 29.9 B6,059
SQ26,718 16.1 B6,039 9XL34,325 20.8 B6,068
XL24,529 14.8 B6,052 10PD27,152 16.4 B6,053
+
+ T. Val : 912.0 B + F. NVal : 286.1 B + T.Lot : 1.5 M + Avg : 6,060 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html new file mode 100644 index 0000000..2ddb65b --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
YU445,640 270.2 B6,064 1YU224,007 135.8 B6,061
ZP283,676 172.0 B6,062 2ZP197,862 119.9 B6,058
DX192,771 116.8 B6,059 3CC185,436 112.5 B6,066
KZ138,619 84.0 B6,058 4BB118,526 71.7 B6,053
AK122,365 74.1 B6,057 5AK109,064 66.1 B6,064
RX72,951 44.2 B6,055 6KZ25,617 15.5 B6,068
CC65,609 39.7 B6,051 7BK15,460 9.3 B6,044
BK36,383 22.1 B6,066 8RX11,033 6.7 B6,069
HD6,018 3.6 B6,053 9HD4,283 2.6 B6,058
AI200 120.5 M6,025 10YP550 332.9 M6,052
+
+ T. Val : 826.9 B + F. NVal : 286.1 B + T.Lot : 1.4 M + Avg : 6,061 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 35cc91d..5e3c4a1 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -3,6 +3,7 @@ use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; +use idx_core::flow::types::FlowSource; use idx_core::market::{ScreenFilter, ScreenRegion}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Deserialize; @@ -170,6 +171,12 @@ pub struct FlowParams { /// Last session of the range, YYYY-MM-DD (default today). #[serde(default)] pub to: Option, + /// Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net + /// value across all boards; "idx" gives exact share counts, the close and the non-regular + /// share, but needs curl-impersonate and may be blocked (HTTP 403). + #[serde(default)] + #[schemars(schema_with = "flow_source_schema")] + pub source: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -244,3 +251,7 @@ fn investor_schema(_: &mut SchemaGenerator) -> Schema { fn board_schema(_: &mut SchemaGenerator) -> Schema { string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec()) } + +fn flow_source_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec()) +} diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index f858838..9127c85 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -246,11 +246,12 @@ foreign or domestic, shares are of that side's own buying (`buy_value`) and sell #[tool( description = "Foreign investors' buying and selling of one IDX ticker, session by \ -session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in \ -shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), \ -`change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). \ -Volume, value and foreign figures are the regular board. Totals cover the whole range; \ -`foreign_share_bps` is foreign investors' share of regular-board trading. End of day." +session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): \ +`foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, \ +`value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and \ +`non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \ +\"idx\": exact foreign share counts on the regular board, values estimated at the session's \ +average price, the close, `change_bps` and `non_regular_bps`." )] async fn get_foreign_flow(&self, params: Parameters) -> ToolResult { let FlowParams { @@ -258,11 +259,13 @@ Volume, value and foreign figures are the regular board. Totals cover the whole days, from, to, + source, } = params.0; self.live(move |idx| { let flow = idx.flow(); let range = FlowRange::from_args(days, from.as_deref(), to.as_deref(), flow.today())?; - flow.foreign(&symbol, range, CachePolicy::Default) + let source = source.as_deref().unwrap_or("ipot").parse()?; + flow.foreign(&symbol, range, source, CachePolicy::Default) }) .await } diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 84d48b4..25cb886 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -236,7 +236,7 @@ "name": "get_company" }, { - "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day.", + "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -258,6 +258,15 @@ "null" ] }, + "source": { + "default": null, + "description": "Where the figures come from (default \"ipot\"): \"ipot\" gives foreign buy, sell and net\nvalue across all boards; \"idx\" gives exact share counts, the close and the non-regular\nshare, but needs curl-impersonate and may be blocked (HTTP 403).", + "enum": [ + "ipot", + "idx" + ], + "type": "string" + }, "symbol": { "description": "IDX ticker, e.g. \"BBCA\".", "type": "string" diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index af0fb70..928b160 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -281,8 +281,18 @@ fn live_tools_wrap_data_with_freshness() { json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}), ); assert_fetched(&flow); - assert_eq!(flow["source"], "idx"); + assert_eq!(flow["source"], "ipot"); assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5); + assert_eq!( + flow["data"]["days"][4]["foreign_sell_value"], + 4_000_000_000_000_i64 + ); + let idx_flow = client.ok( + "get_foreign_flow", + json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16", "source": "idx"}), + ); + assert_eq!(idx_flow["source"], "idx"); + assert_eq!(idx_flow["data"]["days"][4]["close"], 5175); let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); @@ -321,6 +331,10 @@ fn errors_carry_cli_codes() { ("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})), ("analyze_bandar", json!({"symbols": []})), ("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})), + ( + "get_foreign_flow", + json!({"symbol": "BBCA", "source": "rti"}), + ), ( "get_foreign_flow", json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5b2a72..17e408f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,8 +53,8 @@ command parses arguments, makes one idx-core call, and renders the result. - **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`. - **IPOT (Indo Premier)** — broker summaries, whichever provider is selected (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). -- **IDX (idx.co.id)** — the daily trading summary behind foreign flow - (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). +- **IDX (idx.co.id)** — the daily trading summary behind `stocks flow + --source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -116,7 +116,13 @@ WIB as the fallback. never summed as zero. A change to the rules gets a new method name. ### Foreign Flow -- `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` +- The default source is IPOT: per session, the all-investor broker summary + gives the value traded and the foreign one (`fd=F`) gives foreign buying + (its footer), the foreign net, and selling (buying less the net), all + boards, through the broker summary cache. IPOT's foreign net matched IDX's + estimate within 0.3 B on BBCA 2026-10-12 to 16. An empty all-investor + summary counts as no session. +- `--source idx`: `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` `?length=9999&start=0&date=YYYYMMDD` answers one JSON row per stock for the session: prices, volume, value, `ForeignBuy`/`ForeignSell` (shares) and non-regular volume and value. `Volume`, `Value` and the foreign figures are diff --git a/docs/SMOKE.md b/docs/SMOKE.md index ce9f4bd..5f56439 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -54,8 +54,8 @@ nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B again scripts/live-smoke.sh --dry-run ``` -- `stocks`: every `stocks` command for one ticker (`broker`, `bandar` and - `flow` on the new binary only, so an older `--compare` build that predates +- `stocks`: every `stocks` command for one ticker (`broker`, `bandar`, + `flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates them is fine), plus `compare BUMI,ADRO,AIMS`, whose MSN key ratios contain non-finite values. - `ownership`: `discover`, then imports the newest supported report into a temp diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 48b16ce..98dbfb9 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -127,8 +127,10 @@ contract() { echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; "stocks bandar "*) echo 'type == "array" and length > 0 and all(.[]; (.method | type == "string") and (.tiers | length == 4) and (.imbalance_bps | type == "number"))' ;; + "stocks flow "*"--source idx"*) + echo '(.source == "idx") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;; "stocks flow "*) - echo '(.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;; + echo '(.source == "ipot") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy_value | type == "number") and (.foreign_sell_value | type == "number")) and (.foreign_net_value | type == "number")' ;; "ownership discover"*) echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;; "ownership releases") @@ -229,10 +231,11 @@ if selected stocks; then run_case compare "stocks compare $SYMBOL,BBRI" # BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN). run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS" - # Broker summaries (IPOT) and foreign flow (IDX, via curl-impersonate). + # Broker summaries and foreign flow (IPOT; --source idx via curl-impersonate). run_new_case broker "stocks broker $SYMBOL" run_new_case bandar "stocks bandar $SYMBOL,BBRI,TLKM" run_new_case flow "stocks flow $SYMBOL --days 3" + run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx" fi if selected ownership; then diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 524eb97..5a89111 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -433,7 +433,7 @@ Examples: ### `idx stocks flow` ```text -Foreign buying and selling of a stock, session by session, from IDX's daily trading summary: foreign buy, sell and net volume, the net value at the session's average price, and the share of value traded outside the regular board. End of day. Each session is one request to idx.co.id, which needs curl-impersonate. +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. Usage: idx stocks flow [OPTIONS] @@ -462,6 +462,15 @@ Options: --to Last session of the range, YYYY-MM-DD. Default: today + --source + Where the figures come from + + Possible values: + - ipot: Indo Premier's broker summary: foreign buy, sell and net value across all boards, two plain requests per session. Figures of a million and more are rounded to one decimal + - idx: IDX's own trading summary: exact foreign share counts, close and the non-regular share, regular board. Needs curl-impersonate and a network IDX's Cloudflare lets through + + [default: ipot] + -v, --verbose... Increase verbosity (-v, -vv) @@ -478,7 +487,8 @@ Examples: idx stocks flow BBCA idx stocks flow BBCA --days 20 idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 - idx -o json stocks flow BBCA | jq '.foreign_net' + idx stocks flow BBCA --source idx + idx -o json stocks flow BBCA | jq '.foreign_net_value' ``` ### `idx stocks profile` diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index a09a9e5..30049c8 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -55,18 +55,25 @@ million and more to one decimal, and the output says so. idx stocks flow BBCA idx stocks flow BBCA --days 20 idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 +idx stocks flow BBCA --source idx ``` -What foreign investors bought and sold, session by session: buy, sell and -net volume in shares, the net value at each session's average price (an -estimate), and the share of all value traded outside the regular board. -Volume, value and the foreign figures are IDX's regular-board figures. The -footer gives the net over the range and foreign investors' share of trading. -Without dates it shows the latest 10 sessions (`--days`, at most 60). +What foreign investors bought and sold, session by session, with the totals +over the range. Without dates it shows the latest 10 sessions (`--days`, at +most 60). -The data is IDX's own daily trading summary. Each session is one request to -idx.co.id, cached afterwards for every stock, and needs -[curl-impersonate](/start/install/) to pass the site's bot protection. +By default the figures come from Indo Premier (IPOT), like `broker`: foreign +buy, sell and net value across all boards and foreign investors' share of +trading. It needs nothing installed, takes two requests per session, and +rounds figures of a million and more to one decimal. + +`--source idx` reads IDX's own daily trading summary instead: exact foreign +share counts, the close and day change, and the share of value traded +outside the regular board. Those figures are the regular board, and values +are estimated at each session's average price. Each session is one request +to idx.co.id, cached for every stock, and needs +[curl-impersonate](/start/install/) and a network IDX's Cloudflare lets +through; even then it can answer 403. ## Bandarmology diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index f6a0f9c..7287341 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -65,12 +65,13 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys ## `get_foreign_flow` -Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day. +Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`. | Input | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | no | Latest sessions (default 10, at most 60). Omit when giving from/to. | | `from` | string | no | First session of a range, YYYY-MM-DD (at most 92 days). | +| `source` | `ipot` / `idx` | no | Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net value across all boards; "idx" gives exact share counts, the close and the non-regular share, but needs curl-impersonate and may be blocked (HTTP 403). | | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | | `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | diff --git a/web/src/content/docs/start/install.mdx b/web/src/content/docs/start/install.mdx index 01ce32f..8a98b41 100644 --- a/web/src/content/docs/start/install.mdx +++ b/web/src/content/docs/start/install.mdx @@ -74,13 +74,14 @@ idx version ## Helper tools Everyday use needs nothing else: stock data with the default MSN provider, -price history, broker summaries and bandarmology, `ownership sync` and every +price history, broker summaries, bandarmology and foreign flow, `ownership sync` +and every local ownership query. A few commands call external tools: | Tool | Needed for | | --- | --- | | `curl_chrome*` from [curl-impersonate](https://github.com/lexiforest/curl-impersonate), or `IDX_CURL_IMPERSONATE_BIN` | `ownership discover` and `ownership import --url` (IDX sits behind Cloudflare), and Yahoo fundamentals with `IDX_PROVIDER=yahoo` | -| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile | +| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow --source idx`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile (the default source, IPOT, needs nothing) | | `mutool` from MuPDF | importing legacy PDF ownership reports | ## The MCP server