mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
feat: foreign flow (idx stocks flow, get_foreign_flow)
`idx stocks flow SYMBOL` shows what foreign investors bought and sold of a stock, session by session, from IDX's own daily trading summary: foreign buy, sell and net volume, net value at the session's average price, and the share of value traded outside the regular board, with range totals. Latest 10 sessions by default (--days, at most 60) or --from/--to. The MCP server exposes it as get_foreign_flow. - api/idx: pure parser for GetStockSummary (rows dated another session are dropped) and a provider over curl-impersonate's profile fallback with a 30 s limit; Cloudflare passes only some fingerprints. - flow: each session is cached whole by date, so other stocks' flows for the same sessions need no request. Weekends are never requested. - Idx::idx_code is shared by broker and flow for the .JK-only check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
4c2f0acff3
commit
29f143fa1b
40 changed files with 1278 additions and 29 deletions
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -11,6 +11,13 @@ history lives in git.
|
||||||
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
||||||
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
||||||
- Multi-period financial statements (today `financials` is single-period)
|
- 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
|
- News beyond MSN in `news` / `get_company` (`docs/IDX_NEWS.md`): Google News
|
||||||
RSS per ticker, and IDX disclosures and exchange news through a
|
RSS per ticker, and IDX disclosures and exchange news through a
|
||||||
maintainer-published feed
|
maintainer-published feed
|
||||||
|
|
@ -26,6 +33,11 @@ history lives in git.
|
||||||
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
|
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
|
||||||
|
|
||||||
## Known limits
|
## 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` depends on IDX's Cloudflare passing a curl-impersonate
|
||||||
|
profile (only `curl_safari184` in October 2026).
|
||||||
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
|
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
|
||||||
can still fail.
|
can still fail.
|
||||||
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use clap::{Args, Subcommand};
|
||||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
||||||
render_fundamental, render_growth, render_history, render_insights, render_news,
|
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
||||||
render_profile, render_quotes, render_risk, render_screener, render_sentiment,
|
render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
|
||||||
render_technical, render_valuation,
|
render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
|
|
@ -15,6 +15,7 @@ use idx_core::api::types::{Interval, Period};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
||||||
|
use idx_core::flow::types::FlowRange;
|
||||||
use idx_core::market::ScreenQuery;
|
use idx_core::market::ScreenQuery;
|
||||||
use idx_core::{CachePolicy, Idx, runtime};
|
use idx_core::{CachePolicy, Idx, runtime};
|
||||||
|
|
||||||
|
|
@ -220,6 +221,27 @@ heuristic over end-of-day broker codes, not advice.",
|
||||||
#[arg(long, default_value_t = DEFAULT_TOP)]
|
#[arg(long, default_value_t = DEFAULT_TOP)]
|
||||||
top: u8,
|
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, 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'"
|
||||||
|
)]
|
||||||
|
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<usize>,
|
||||||
|
/// First session of a range, YYYY-MM-DD (at most 92 days).
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Last session of the range, YYYY-MM-DD. Default: today.
|
||||||
|
#[arg(long, requires = "from")]
|
||||||
|
to: Option<String>,
|
||||||
|
},
|
||||||
#[command(about = "Get company profile")]
|
#[command(about = "Get company profile")]
|
||||||
Profile { symbol: String },
|
Profile { symbol: String },
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -361,6 +383,17 @@ pub fn handle(
|
||||||
let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect();
|
let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect();
|
||||||
render_bandar(&reports, symbols.len() > 1, format, no_color)
|
render_bandar(&reports, symbols.len() > 1, format, no_color)
|
||||||
}
|
}
|
||||||
|
StocksSubcommand::Flow {
|
||||||
|
symbol,
|
||||||
|
days,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
} => {
|
||||||
|
let range =
|
||||||
|
FlowRange::from_args(*days, from.as_deref(), to.as_deref(), idx.flow().today())?;
|
||||||
|
let flow = idx.flow().foreign(symbol, range, policy)?;
|
||||||
|
render_foreign_flow(&flow.data, format)
|
||||||
|
}
|
||||||
StocksSubcommand::Profile { symbol } => {
|
StocksSubcommand::Profile { symbol } => {
|
||||||
let profile = idx.financial().profile(symbol, policy)?;
|
let profile = idx.financial().profile(symbol, policy)?;
|
||||||
render_profile(&profile.data, format)
|
render_profile(&profile.data, format)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use idx_core::api::types::{
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::BrokerSummary;
|
use idx_core::broker::types::BrokerSummary;
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
|
use idx_core::flow::types::ForeignFlow;
|
||||||
|
|
||||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||||
|
|
@ -100,6 +101,13 @@ pub fn render_bandar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
pub fn render_history(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
history: &[Ohlc],
|
history: &[Ohlc],
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use idx_core::api::types::{
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
|
use idx_core::flow::types::ForeignFlow;
|
||||||
|
|
||||||
pub fn format_idr(value: i64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let sign = if value.is_negative() { "-" } else { "" };
|
let sign = if value.is_negative() { "-" } else { "" };
|
||||||
|
|
@ -1186,6 +1187,79 @@ pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_signed_compact(value: Option<i64>) -> 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" }
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
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!(
|
||||||
|
"Volumes are shares. Net value is estimated at each session's average price; \
|
||||||
|
NON-REG is the share of value traded outside the regular board (negotiated and cash)."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|
|
||||||
|
|
@ -2726,3 +2726,134 @@ fn bandar_errors_follow_the_output_contract() {
|
||||||
run_error_json(&mut cmd, code);
|
run_error_json(&mut cmd, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_flow_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"]),
|
||||||
|
);
|
||||||
|
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",
|
||||||
|
"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_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",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"TLKM",
|
||||||
|
"--from",
|
||||||
|
"2026-10-12",
|
||||||
|
"--to",
|
||||||
|
"2026-10-16",
|
||||||
|
]),
|
||||||
|
"CACHEMISS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ name = "yahoo-fundamental"
|
||||||
args = ["stocks", "fundamental", "BBCA"]
|
args = ["stocks", "fundamental", "BBCA"]
|
||||||
env = { IDX_PROVIDER = "yahoo" }
|
env = { IDX_PROVIDER = "yahoo" }
|
||||||
|
|
||||||
# --- stocks: broker summaries (IPOT) -------------------------------------------
|
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "stocks-broker"
|
name = "stocks-broker"
|
||||||
|
|
@ -267,6 +267,16 @@ name = "stocks-bandar-screen"
|
||||||
formats = ["table", "json", "jsonl"]
|
formats = ["table", "json", "jsonl"]
|
||||||
args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"]
|
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-latest"
|
||||||
|
args = ["stocks", "flow", "TLKM", "--days", "3"]
|
||||||
|
env = { IDX_MOCK_TODAY = "2026-10-18" }
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "error-broker-unknown-symbol"
|
name = "error-broker-unknown-symbol"
|
||||||
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,9 @@ _idx() {
|
||||||
idx__help__stocks,financials)
|
idx__help__stocks,financials)
|
||||||
cmd="idx__help__stocks__financials"
|
cmd="idx__help__stocks__financials"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,flow)
|
||||||
|
cmd="idx__help__stocks__flow"
|
||||||
|
;;
|
||||||
idx__help__stocks,fundamental)
|
idx__help__stocks,fundamental)
|
||||||
cmd="idx__help__stocks__fundamental"
|
cmd="idx__help__stocks__fundamental"
|
||||||
;;
|
;;
|
||||||
|
|
@ -357,6 +360,9 @@ _idx() {
|
||||||
idx__stocks,financials)
|
idx__stocks,financials)
|
||||||
cmd="idx__stocks__financials"
|
cmd="idx__stocks__financials"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,flow)
|
||||||
|
cmd="idx__stocks__flow"
|
||||||
|
;;
|
||||||
idx__stocks,fundamental)
|
idx__stocks,fundamental)
|
||||||
cmd="idx__stocks__fundamental"
|
cmd="idx__stocks__fundamental"
|
||||||
;;
|
;;
|
||||||
|
|
@ -411,6 +417,9 @@ _idx() {
|
||||||
idx__stocks__help,financials)
|
idx__stocks__help,financials)
|
||||||
cmd="idx__stocks__help__financials"
|
cmd="idx__stocks__help__financials"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,flow)
|
||||||
|
cmd="idx__stocks__help__flow"
|
||||||
|
;;
|
||||||
idx__stocks__help,fundamental)
|
idx__stocks__help,fundamental)
|
||||||
cmd="idx__stocks__help__fundamental"
|
cmd="idx__stocks__help__fundamental"
|
||||||
;;
|
;;
|
||||||
|
|
@ -1209,7 +1218,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__stocks)
|
idx__help__stocks)
|
||||||
opts="quote history technical growth valuation risk fundamental broker bandar 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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1292,6 +1301,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__help__stocks__fundamental)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2239,7 +2262,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks)
|
idx__stocks)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar 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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2418,6 +2441,40 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__flow)
|
||||||
|
opts="-o -q -v -h --days --from --to --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
--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)
|
idx__stocks__fundamental)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2463,7 +2520,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks__help)
|
idx__stocks__help)
|
||||||
opts="quote history technical growth valuation risk fundamental broker bandar 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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2546,6 +2603,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__stocks__help__fundamental)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Commands:
|
||||||
fundamental Run full fundamental analysis on a stock
|
fundamental Run full fundamental analysis on a stock
|
||||||
broker Which brokers bought and sold a stock
|
broker Which brokers bought and sold a stock
|
||||||
bandar Bandarmology: is buying more concentrated than selling
|
bandar Bandarmology: is buying more concentrated than selling
|
||||||
|
flow Foreign buying and selling of a stock, session by session
|
||||||
profile Get company profile
|
profile Get company profile
|
||||||
financials Get financial statements
|
financials Get financial statements
|
||||||
earnings Get earnings report
|
earnings Get earnings report
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
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":1085},{"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":1744}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213}
|
||||||
|
--- stderr
|
||||||
15
crates/idx-cli/tests/golden/stocks-flow-latest.table.golden
Normal file
15
crates/idx-cli/tests/golden/stocks-flow-latest.table.golden
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions)
|
||||||
|
┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐
|
||||||
|
│ 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 ┆ 10.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 ┆ 17.4% │
|
||||||
|
└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘
|
||||||
|
Net foreign: -54.2M shares, about -106.0B IDR; foreign investors made up 72.1% of trading.
|
||||||
|
Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash).
|
||||||
|
--- stderr
|
||||||
4
crates/idx-cli/tests/golden/stocks-flow.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-flow.json.golden
Normal file
|
|
@ -0,0 +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":162},{"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":3178},{"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":1168},{"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":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050}
|
||||||
|
--- stderr
|
||||||
4
crates/idx-cli/tests/golden/stocks-flow.jsonl.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-flow.jsonl.golden
Normal file
|
|
@ -0,0 +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":162},{"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":3178},{"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":1168},{"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":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050}
|
||||||
|
--- stderr
|
||||||
19
crates/idx-cli/tests/golden/stocks-flow.table.golden
Normal file
19
crates/idx-cli/tests/golden/stocks-flow.table.golden
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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 ┆ 31.8% │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 11.7% │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.6% │
|
||||||
|
└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘
|
||||||
|
Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading.
|
||||||
|
Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash).
|
||||||
|
--- stderr
|
||||||
73
crates/idx-core/src/api/idx/mod.rs
Normal file
73
crates/idx-core/src/api/idx/mod.rs
Normal file
|
|
@ -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<Vec<StockDay>, IdxError> {
|
||||||
|
let args: Vec<String> = [
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
crates/idx-core/src/api/idx/parse.rs
Normal file
114
crates/idx-core/src/api/idx/parse.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
//! 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<StockSummaryRawRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct StockSummaryRawRow {
|
||||||
|
date: String,
|
||||||
|
stock_code: String,
|
||||||
|
previous: Option<f64>,
|
||||||
|
high: Option<f64>,
|
||||||
|
low: Option<f64>,
|
||||||
|
close: Option<f64>,
|
||||||
|
volume: Option<f64>,
|
||||||
|
value: Option<f64>,
|
||||||
|
frequency: Option<f64>,
|
||||||
|
foreign_buy: Option<f64>,
|
||||||
|
foreign_sell: Option<f64>,
|
||||||
|
non_regular_volume: Option<f64>,
|
||||||
|
non_regular_value: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(value: Option<f64>) -> Option<i64> {
|
||||||
|
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.
|
||||||
|
pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result<Vec<StockDay>, IdxError> {
|
||||||
|
let response: StockSummaryResponse = serde_json::from_str(raw)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?;
|
||||||
|
Ok(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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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!(
|
||||||
|
parse_stock_summary(DAY, date(2026, 10, 15))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_stock_summary("<html>Just a moment...</html>", date(2026, 10, 16)),
|
||||||
|
Err(IdxError::ParseError(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
use super::types::{Bar, Fundamentals, Interval, Period, Quote};
|
use super::types::{Bar, Fundamentals, Interval, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, ipot, msn, yahoo,
|
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider,
|
||||||
|
TradingSummaryProvider, idx, ipot, msn, yahoo,
|
||||||
};
|
};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||||
use crate::config::ProviderKind;
|
use crate::config::ProviderKind;
|
||||||
|
|
@ -164,3 +165,26 @@ impl BrokerSummaryProvider for MockBrokerProvider {
|
||||||
ipot::summary_from_html(code, &html)
|
ipot::summary_from_html(code, &html)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trading summaries from `tests/fixtures/idx_stocksummary_<date>.json`; a
|
||||||
|
/// date without a fixture had no trading.
|
||||||
|
pub struct MockTradingSummaryProvider;
|
||||||
|
|
||||||
|
impl TradingSummaryProvider for MockTradingSummaryProvider {
|
||||||
|
fn stock_summary(
|
||||||
|
&self,
|
||||||
|
date: chrono::NaiveDate,
|
||||||
|
) -> Result<Vec<crate::flow::types::StockDay>, IdxError> {
|
||||||
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||||
|
return Err(IdxError::ProviderUnavailable);
|
||||||
|
}
|
||||||
|
let path = format!(
|
||||||
|
"tests/fixtures/idx_stocksummary_{}.json",
|
||||||
|
date.format("%Y%m%d")
|
||||||
|
);
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(raw) => idx::parse_stock_summary(&raw, date),
|
||||||
|
Err(_) => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod idx;
|
||||||
pub mod ipot;
|
pub mod ipot;
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
|
|
@ -54,6 +55,15 @@ pub trait BrokerSummaryProvider {
|
||||||
) -> Result<ipot::BrokerSummaryRaw, IdxError>;
|
) -> Result<ipot::BrokerSummaryRaw, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every stock's trading summary for one session (IDX's own site).
|
||||||
|
pub trait TradingSummaryProvider {
|
||||||
|
/// Empty for a day without trading.
|
||||||
|
fn stock_summary(
|
||||||
|
&self,
|
||||||
|
date: chrono::NaiveDate,
|
||||||
|
) -> Result<Vec<crate::flow::types::StockDay>, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FundamentalsProvider {
|
pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +289,14 @@ pub fn broker_provider() -> Box<dyn BrokerSummaryProvider> {
|
||||||
Box::new(ipot::IpotProvider::new())
|
Box::new(ipot::IpotProvider::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn trading_summary_provider() -> Box<dyn TradingSummaryProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockTradingSummaryProvider);
|
||||||
|
}
|
||||||
|
Box::new(idx::IdxSiteProvider)
|
||||||
|
}
|
||||||
|
|
||||||
/// Today in WIB, the date IDX trades on.
|
/// Today in WIB, the date IDX trades on.
|
||||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ impl Broker<'_> {
|
||||||
query: &BrokerQuery,
|
query: &BrokerQuery,
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||||
let (resolved, code) = self.idx_code(symbol)?;
|
let (resolved, code) = self.idx.idx_code(symbol, "a broker summary")?;
|
||||||
let today = today_wib();
|
let today = today_wib();
|
||||||
check_range(query, today)?;
|
check_range(query, today)?;
|
||||||
let key = format!(
|
let key = format!(
|
||||||
|
|
@ -123,7 +123,7 @@ impl Broker<'_> {
|
||||||
board: BrokerBoard,
|
board: BrokerBoard,
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||||
let (resolved, _) = self.idx_code(symbol)?;
|
let (resolved, _) = self.idx.idx_code(symbol, "a broker summary")?;
|
||||||
let today = today_wib();
|
let today = today_wib();
|
||||||
for back in 0..LATEST_LOOKBACK_DAYS {
|
for back in 0..LATEST_LOOKBACK_DAYS {
|
||||||
let day = today - Duration::days(back);
|
let day = today - Duration::days(back);
|
||||||
|
|
@ -153,17 +153,6 @@ impl Broker<'_> {
|
||||||
"{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days"
|
"{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days"
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The resolved symbol and the bare IDX code IPOT takes.
|
|
||||||
fn idx_code(&self, symbol: &str) -> Result<(String, String), IdxError> {
|
|
||||||
let resolved = self.idx.resolve(symbol)?;
|
|
||||||
match resolved.strip_suffix(".JK") {
|
|
||||||
Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())),
|
|
||||||
_ => Err(IdxError::Unsupported(format!(
|
|
||||||
"{resolved}: broker summaries cover IDX (.JK) stocks only"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The public summary of one parsed page. With one investor side selected,
|
/// The public summary of one parsed page. With one investor side selected,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@ impl Idx {
|
||||||
crate::broker::Broker { idx: self }
|
crate::broker::Broker { idx: self }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn flow(&self) -> crate::flow::Flow<'_> {
|
||||||
|
crate::flow::Flow { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||||
crate::financial::Financial { idx: self }
|
crate::financial::Financial { idx: self }
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +111,18 @@ impl Idx {
|
||||||
crate::api::resolve_symbol(symbol, &self.config.exchange)
|
crate::api::resolve_symbol(symbol, &self.config.exchange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The resolved symbol and its bare IDX code, for sources that cover
|
||||||
|
/// IDX (`.JK`) stocks only; `what` names the data in the error.
|
||||||
|
pub(crate) fn idx_code(&self, symbol: &str, what: &str) -> Result<(String, String), IdxError> {
|
||||||
|
let resolved = self.resolve(symbol)?;
|
||||||
|
match resolved.strip_suffix(".JK") {
|
||||||
|
Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())),
|
||||||
|
_ => Err(IdxError::Unsupported(format!(
|
||||||
|
"{resolved}: {what} covers IDX (.JK) stocks only"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn bucket(&self, key: &str) -> String {
|
pub(crate) fn bucket(&self, key: &str) -> String {
|
||||||
format!("{}-{key}", self.provider.kind().as_str())
|
format!("{}-{key}", self.provider.kind().as_str())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
291
crates/idx-core/src/flow/mod.rs
Normal file
291
crates/idx-core/src/flow/mod.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||||
|
|
||||||
|
use crate::api::{today_wib, trading_summary_provider};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay};
|
||||||
|
|
||||||
|
const SOURCE: &str = "idx";
|
||||||
|
/// Most sessions `FlowRange::Latest` reads, one request each.
|
||||||
|
pub const MAX_SESSIONS: usize = 60;
|
||||||
|
/// Longest `FlowRange::Dates` range, calendar days.
|
||||||
|
pub const MAX_RANGE_DAYS: i64 = 92;
|
||||||
|
/// A past session no longer changes.
|
||||||
|
const TTL_CLOSED_SECS: u64 = 30 * 24 * 3600;
|
||||||
|
const TTL_TODAY_SECS: u64 = 5 * 60;
|
||||||
|
|
||||||
|
pub struct Flow<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flow<'_> {
|
||||||
|
/// Today in WIB.
|
||||||
|
pub fn today(&self) -> NaiveDate {
|
||||||
|
today_wib()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foreign buying and selling of `symbol` over `range`.
|
||||||
|
pub fn foreign(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
range: FlowRange,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<ForeignFlow>, IdxError> {
|
||||||
|
let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?;
|
||||||
|
let today = today_wib();
|
||||||
|
let (dates, wanted, skip_misses) = match range {
|
||||||
|
FlowRange::Latest { sessions } => {
|
||||||
|
if !(1..=MAX_SESSIONS).contains(&sessions) {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"days must be 1 to {MAX_SESSIONS} (got {sessions})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let lookback = 2 * sessions as i64 + 10;
|
||||||
|
let dates: Vec<NaiveDate> = (0..lookback)
|
||||||
|
.map(|back| today - Duration::days(back))
|
||||||
|
.collect();
|
||||||
|
// Offline, an uncached day says nothing; look further back.
|
||||||
|
(dates, sessions, policy == CachePolicy::Offline)
|
||||||
|
}
|
||||||
|
FlowRange::Dates { from, to } => {
|
||||||
|
check_range(from, to, today)?;
|
||||||
|
let dates = (0..=(to - from).num_days())
|
||||||
|
.rev()
|
||||||
|
.map(|offset| from + Duration::days(offset))
|
||||||
|
.collect();
|
||||||
|
(dates, usize::MAX, false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut sessions = 0;
|
||||||
|
let mut fetched_at: Option<DateTime<Utc>> = None;
|
||||||
|
let mut stale = false;
|
||||||
|
for date in dates {
|
||||||
|
if sessions == wanted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let day = match self.session(date, today, policy) {
|
||||||
|
Ok(day) => day,
|
||||||
|
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
if day.data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessions == 0 {
|
||||||
|
return Err(if policy == CachePolicy::Offline {
|
||||||
|
IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)"))
|
||||||
|
} else {
|
||||||
|
IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.reverse();
|
||||||
|
let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?;
|
||||||
|
Ok(Fetched {
|
||||||
|
data: flow,
|
||||||
|
source: SOURCE,
|
||||||
|
fetched_at: fetched_at.unwrap_or_else(Utc::now),
|
||||||
|
stale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stock's summary for one session; empty without trading.
|
||||||
|
fn session(
|
||||||
|
&self,
|
||||||
|
date: NaiveDate,
|
||||||
|
today: NaiveDate,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<StockDay>>, IdxError> {
|
||||||
|
let provider = trading_summary_provider();
|
||||||
|
let key = date.to_string();
|
||||||
|
cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: "idx-stocksummary",
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: if date < today {
|
||||||
|
TTL_CLOSED_SECS
|
||||||
|
} else {
|
||||||
|
TTL_TODAY_SECS
|
||||||
|
},
|
||||||
|
subject: &key,
|
||||||
|
source: SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| provider.stock_summary(date),
|
||||||
|
Ok,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_range(from: NaiveDate, to: NaiveDate, today: NaiveDate) -> Result<(), IdxError> {
|
||||||
|
if from > to {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"--from {from} is after --to {to}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if to > today {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"{to} is in the future (today is {today} in WIB)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if (to - from).num_days() >= MAX_RANGE_DAYS {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"a foreign flow covers at most {MAX_RANGE_DAYS} days"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `part` in basis points of `whole`, rounded; `None` without a positive whole.
|
||||||
|
fn bps(part: i64, whole: i64) -> Option<i64> {
|
||||||
|
(whole > 0).then(|| (part as f64 * 10_000.0 / whole as f64).round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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,
|
||||||
|
change_bps: row
|
||||||
|
.close
|
||||||
|
.zip(row.previous)
|
||||||
|
.and_then(|(close, previous)| bps(close - previous, previous)),
|
||||||
|
volume: row.volume,
|
||||||
|
value: row.value,
|
||||||
|
foreign_buy: row.foreign_buy,
|
||||||
|
foreign_sell: row.foreign_sell,
|
||||||
|
foreign_net,
|
||||||
|
foreign_net_value,
|
||||||
|
non_regular_bps: row
|
||||||
|
.non_regular_value
|
||||||
|
.zip(row.value)
|
||||||
|
.and_then(|(part, whole)| bps(part, whole)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option<i64>) -> Option<i64> {
|
||||||
|
days.iter()
|
||||||
|
.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<ForeignFlow> {
|
||||||
|
let (first, last) = (rows.first()?, rows.last()?);
|
||||||
|
let days: Vec<ForeignFlowDay> = 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,
|
||||||
|
};
|
||||||
|
Some(ForeignFlow {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
from: first.date,
|
||||||
|
to: last.date,
|
||||||
|
foreign_net: sum(&days, |day| day.foreign_net),
|
||||||
|
foreign_net_value: sum(&days, |day| day.foreign_net_value),
|
||||||
|
foreign_share_bps,
|
||||||
|
days,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn row(day: u32, close: i64, buy: i64, sell: Option<i64>) -> StockDay {
|
||||||
|
StockDay {
|
||||||
|
code: "TEST".to_string(),
|
||||||
|
date: NaiveDate::from_ymd_opt(2026, 10, day).unwrap(),
|
||||||
|
previous: Some(1000),
|
||||||
|
high: None,
|
||||||
|
low: None,
|
||||||
|
close: Some(close),
|
||||||
|
volume: Some(1_000),
|
||||||
|
value: Some(1_100_000),
|
||||||
|
frequency: None,
|
||||||
|
foreign_buy: Some(buy),
|
||||||
|
foreign_sell: sell,
|
||||||
|
non_regular_volume: None,
|
||||||
|
non_regular_value: Some(110_000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 first = &flow.days[0];
|
||||||
|
assert_eq!(
|
||||||
|
(first.foreign_net, first.change_bps),
|
||||||
|
(Some(400), Some(1000))
|
||||||
|
);
|
||||||
|
// 400 shares at 1,100 (value over volume).
|
||||||
|
assert_eq!(first.foreign_net_value, Some(440_000));
|
||||||
|
assert_eq!(first.non_regular_bps, Some(1000));
|
||||||
|
assert_eq!(flow.days[1].change_bps, Some(-500));
|
||||||
|
assert_eq!(
|
||||||
|
(flow.foreign_net, flow.foreign_net_value),
|
||||||
|
(Some(200), Some(220_000))
|
||||||
|
);
|
||||||
|
// (700 + 500) / (2 * 2,000).
|
||||||
|
assert_eq!(flow.foreign_share_bps, Some(3000));
|
||||||
|
assert_eq!((flow.from.day(), flow.to.day()), (15, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_range_bounds() {
|
||||||
|
let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap();
|
||||||
|
let today = day(18);
|
||||||
|
assert!(check_range(day(1), day(16), today).is_ok());
|
||||||
|
assert!(check_range(day(16), day(12), today).is_err());
|
||||||
|
assert!(check_range(day(16), day(19), today).is_err());
|
||||||
|
let long_ago = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
||||||
|
assert!(check_range(long_ago, day(16), today).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/idx-core/src/flow/types.rs
Normal file
117
crates/idx-core/src/flow/types.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! Foreign flow types: what foreign investors bought and sold of a stock,
|
||||||
|
//! session by session (IDX trading summary, end of day).
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// Sessions shown when a frontend gives neither days nor dates.
|
||||||
|
pub const DEFAULT_SESSIONS: usize = 10;
|
||||||
|
|
||||||
|
/// One stock's trading summary for one session, as IDX publishes it.
|
||||||
|
/// Prices are whole IDR, volumes are shares.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StockDay {
|
||||||
|
/// Bare IDX code, e.g. `BBCA`.
|
||||||
|
pub code: String,
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub previous: Option<i64>,
|
||||||
|
pub high: Option<i64>,
|
||||||
|
pub low: Option<i64>,
|
||||||
|
pub close: Option<i64>,
|
||||||
|
pub volume: Option<i64>,
|
||||||
|
/// Value traded, whole IDR, all boards.
|
||||||
|
pub value: Option<i64>,
|
||||||
|
pub frequency: Option<i64>,
|
||||||
|
/// Shares bought by foreign investors.
|
||||||
|
pub foreign_buy: Option<i64>,
|
||||||
|
/// Shares sold by foreign investors.
|
||||||
|
pub foreign_sell: Option<i64>,
|
||||||
|
/// Shares traded outside the regular board (negotiated and cash).
|
||||||
|
pub non_regular_volume: Option<i64>,
|
||||||
|
/// Value traded outside the regular board, whole IDR.
|
||||||
|
pub non_regular_value: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which sessions a foreign flow covers.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FlowRange {
|
||||||
|
/// The latest `sessions` sessions with trades.
|
||||||
|
Latest { sessions: usize },
|
||||||
|
/// Every session in `from..=to` (WIB dates).
|
||||||
|
Dates { from: NaiveDate, to: NaiveDate },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowRange {
|
||||||
|
/// From a frontend's `days`, or `from` with an optional `to` (default
|
||||||
|
/// today) as `YYYY-MM-DD`; neither means the latest
|
||||||
|
/// [`DEFAULT_SESSIONS`].
|
||||||
|
pub fn from_args(
|
||||||
|
days: Option<usize>,
|
||||||
|
from: Option<&str>,
|
||||||
|
to: Option<&str>,
|
||||||
|
today: NaiveDate,
|
||||||
|
) -> Result<Self, IdxError> {
|
||||||
|
let parse = |name: &str, value: &str| {
|
||||||
|
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||||
|
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match (days, from, to) {
|
||||||
|
(days, None, None) => Ok(Self::Latest {
|
||||||
|
sessions: days.unwrap_or(DEFAULT_SESSIONS),
|
||||||
|
}),
|
||||||
|
(None, Some(from), to) => Ok(Self::Dates {
|
||||||
|
from: parse("from", from)?,
|
||||||
|
to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today),
|
||||||
|
}),
|
||||||
|
(None, None, Some(_)) => Err(IdxError::InvalidInput("to needs from".to_string())),
|
||||||
|
(Some(_), _, _) => Err(IdxError::InvalidInput(
|
||||||
|
"days cannot be combined with from or to".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ForeignFlowDay {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub close: Option<i64>,
|
||||||
|
/// Close against the previous close, basis points.
|
||||||
|
pub change_bps: Option<i64>,
|
||||||
|
/// Shares traded.
|
||||||
|
pub volume: Option<i64>,
|
||||||
|
/// Value traded, whole IDR.
|
||||||
|
pub value: Option<i64>,
|
||||||
|
/// Shares foreign investors bought.
|
||||||
|
pub foreign_buy: Option<i64>,
|
||||||
|
/// Shares foreign investors sold.
|
||||||
|
pub foreign_sell: Option<i64>,
|
||||||
|
/// `foreign_buy - foreign_sell`, shares.
|
||||||
|
pub foreign_net: Option<i64>,
|
||||||
|
/// `foreign_net` at the session's average price (value over volume),
|
||||||
|
/// whole IDR: an estimate.
|
||||||
|
pub foreign_net_value: Option<i64>,
|
||||||
|
/// Value traded outside the regular board (negotiated and cash), basis
|
||||||
|
/// points of the session's value.
|
||||||
|
pub non_regular_bps: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ForeignFlow {
|
||||||
|
/// Resolved ticker, e.g. `BBCA.JK`.
|
||||||
|
pub symbol: String,
|
||||||
|
/// First and last session covered.
|
||||||
|
pub from: NaiveDate,
|
||||||
|
pub to: NaiveDate,
|
||||||
|
/// Sessions the stock traded, oldest first.
|
||||||
|
pub days: Vec<ForeignFlowDay>,
|
||||||
|
/// Net shares over the sessions; `None` when a session lacks a figure.
|
||||||
|
pub foreign_net: Option<i64>,
|
||||||
|
/// Sum of the sessions' estimated net values, whole IDR.
|
||||||
|
pub foreign_net_value: Option<i64>,
|
||||||
|
/// Foreign buys and sells over twice the volume: foreign investors'
|
||||||
|
/// share of trading, basis points.
|
||||||
|
pub foreign_share_bps: Option<i64>,
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ pub mod config;
|
||||||
pub mod curl_impersonate;
|
pub mod curl_impersonate;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod financial;
|
pub mod financial;
|
||||||
|
pub mod flow;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod investor;
|
pub mod investor;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
|
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4111663,"Date":"2026-10-12T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6050.0,"OpenPrice":6050.0,"FirstTrade":6050.0,"High":6100.0,"Low":5950.0,"Close":5950.0,"Change":-100.0,"Volume":97965100.0,"Value":586491892500.0,"Frequency":25910.0,"IndexIndividual":16988.4,"Offer":5950.0,"OfferVolume":2201900.0,"Bid":5925.0,"BidVolume":3571200.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":60883100.0,"ForeignBuy":51499200.0,"DelistingDate":"","NonRegularVolume":110246.0,"NonRegularValue":798569400.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4111669,"Date":"2026-10-12T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3100.0,"OpenPrice":3100.0,"FirstTrade":3100.0,"High":3140.0,"Low":3070.0,"Close":3110.0,"Change":10.0,"Volume":129810600.0,"Value":402372587000.0,"Frequency":22542.0,"IndexIndividual":3647.6,"Offer":3110.0,"OfferVolume":668400.0,"Bid":3100.0,"BidVolume":247900.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":55331400.0,"ForeignBuy":81277900.0,"DelistingDate":"","NonRegularVolume":29917536.0,"NonRegularValue":92714443560.0,"NonRegularFrequency":6.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4111915,"Date":"2026-10-12T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":30.0,"OpenPrice":29.0,"FirstTrade":29.0,"High":30.0,"Low":26.0,"Close":27.0,"Change":-3.0,"Volume":28057826200.0,"Value":773057901000.0,"Frequency":49329.0,"IndexIndividual":8.0,"Offer":27.0,"OfferVolume":1907171500.0,"Bid":26.0,"BidVolume":4019082600.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":5861571800.0,"ForeignBuy":10748736800.0,"DelistingDate":"","NonRegularVolume":700900700.0,"NonRegularValue":20301801400.0,"NonRegularFrequency":2.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4112442,"Date":"2026-10-12T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2320.0,"OpenPrice":2330.0,"FirstTrade":2330.0,"High":2340.0,"Low":2260.0,"Close":2280.0,"Change":-40.0,"Volume":149200000.0,"Value":342019043000.0,"Frequency":14057.0,"IndexIndividual":1198.2,"Offer":2290.0,"OfferVolume":973500.0,"Bid":2280.0,"BidVolume":194400.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":118523800.0,"ForeignBuy":106560700.0,"DelistingDate":"","NonRegularVolume":15000273.0,"NonRegularValue":34245625670.0,"NonRegularFrequency":5.0,"persen":null,"percentage":null}]}
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4112626,"Date":"2026-10-13T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":5950.0,"OpenPrice":5925.0,"FirstTrade":5950.0,"High":5950.0,"Low":5850.0,"Close":5950.0,"Change":0.0,"Volume":122716900.0,"Value":726575115000.0,"Frequency":23340.0,"IndexIndividual":16988.4,"Offer":5950.0,"OfferVolume":994700.0,"Bid":5925.0,"BidVolume":177900.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":83738900.0,"ForeignBuy":88772100.0,"DelistingDate":"","NonRegularVolume":1988532.0,"NonRegularValue":11743985775.0,"NonRegularFrequency":5.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4112632,"Date":"2026-10-13T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3110.0,"OpenPrice":3100.0,"FirstTrade":3090.0,"High":3100.0,"Low":3030.0,"Close":3080.0,"Change":-30.0,"Volume":102010600.0,"Value":312155170000.0,"Frequency":22096.0,"IndexIndividual":3612.4,"Offer":3080.0,"OfferVolume":2006200.0,"Bid":3070.0,"BidVolume":1886500.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":58087200.0,"ForeignBuy":36737800.0,"DelistingDate":"","NonRegularVolume":38643164.0,"NonRegularValue":118209441300.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4112878,"Date":"2026-10-13T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":27.0,"FirstTrade":26.0,"High":27.0,"Low":25.0,"Close":27.0,"Change":0.0,"Volume":11323075500.0,"Value":298601974900.0,"Frequency":21340.0,"IndexIndividual":8.0,"Offer":27.0,"OfferVolume":1668009300.0,"Bid":26.0,"BidVolume":2243966200.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":5847077100.0,"ForeignBuy":3865517000.0,"DelistingDate":"","NonRegularVolume":208738.0,"NonRegularValue":1530356.0,"NonRegularFrequency":3.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4113405,"Date":"2026-10-13T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2280.0,"OpenPrice":2280.0,"FirstTrade":2270.0,"High":2280.0,"Low":2210.0,"Close":2240.0,"Change":-40.0,"Volume":109424100.0,"Value":244244887000.0,"Frequency":15860.0,"IndexIndividual":1177.2,"Offer":2250.0,"OfferVolume":408200.0,"Bid":2240.0,"BidVolume":24800.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":94827700.0,"ForeignBuy":61323200.0,"DelistingDate":"","NonRegularVolume":308339.0,"NonRegularValue":922008120.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null}]}
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4113589,"Date":"2026-10-14T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":5950.0,"OpenPrice":6000.0,"FirstTrade":6000.0,"High":6125.0,"Low":5950.0,"Close":6125.0,"Change":175.0,"Volume":136303100.0,"Value":828862772500.0,"Frequency":18178.0,"IndexIndividual":17488.0,"Offer":6125.0,"OfferVolume":1376900.0,"Bid":6100.0,"BidVolume":41700.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":84298000.0,"ForeignBuy":119876900.0,"DelistingDate":"","NonRegularVolume":43235191.0,"NonRegularValue":263446538075.0,"NonRegularFrequency":15.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4113595,"Date":"2026-10-14T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3080.0,"OpenPrice":3090.0,"FirstTrade":3090.0,"High":3140.0,"Low":3070.0,"Close":3090.0,"Change":10.0,"Volume":109152100.0,"Value":338481482000.0,"Frequency":18138.0,"IndexIndividual":3624.1,"Offer":3090.0,"OfferVolume":4530000.0,"Bid":3080.0,"BidVolume":3641200.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":68198900.0,"ForeignBuy":49831700.0,"DelistingDate":"","NonRegularVolume":55577571.0,"NonRegularValue":172327653286.0,"NonRegularFrequency":13.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4113841,"Date":"2026-10-14T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":27.0,"FirstTrade":27.0,"High":28.0,"Low":26.0,"Close":28.0,"Change":1.0,"Volume":9369136400.0,"Value":256667124200.0,"Frequency":19390.0,"IndexIndividual":8.3,"Offer":28.0,"OfferVolume":2173468100.0,"Bid":27.0,"BidVolume":1340613300.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":3475737500.0,"ForeignBuy":3444546600.0,"DelistingDate":"","NonRegularVolume":700006960.0,"NonRegularValue":20300235380.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4114368,"Date":"2026-10-14T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2240.0,"OpenPrice":2260.0,"FirstTrade":2260.0,"High":2310.0,"Low":2250.0,"Close":2250.0,"Change":10.0,"Volume":87159900.0,"Value":197725738000.0,"Frequency":13661.0,"IndexIndividual":1182.4,"Offer":2260.0,"OfferVolume":1297300.0,"Bid":2250.0,"BidVolume":331200.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":60811200.0,"ForeignBuy":63030200.0,"DelistingDate":"","NonRegularVolume":9457362.0,"NonRegularValue":21449294102.0,"NonRegularFrequency":6.0,"persen":null,"percentage":null}]}
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4114552,"Date":"2026-10-15T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6125.0,"OpenPrice":6075.0,"FirstTrade":6075.0,"High":6125.0,"Low":6025.0,"Close":6075.0,"Change":-50.0,"Volume":134763200.0,"Value":816554920000.0,"Frequency":17286.0,"IndexIndividual":17345.2,"Offer":6075.0,"OfferVolume":135400.0,"Bid":6050.0,"BidVolume":47300.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":73638900.0,"ForeignBuy":120850000.0,"DelistingDate":"","NonRegularVolume":15720537.0,"NonRegularValue":95408071050.0,"NonRegularFrequency":38.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4114558,"Date":"2026-10-15T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3090.0,"OpenPrice":3080.0,"FirstTrade":3080.0,"High":3110.0,"Low":3060.0,"Close":3100.0,"Change":10.0,"Volume":107210100.0,"Value":330799545000.0,"Frequency":16751.0,"IndexIndividual":3635.9,"Offer":3100.0,"OfferVolume":175700.0,"Bid":3090.0,"BidVolume":71000.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":47516000.0,"ForeignBuy":72896800.0,"DelistingDate":"","NonRegularVolume":6266786.0,"NonRegularValue":19574409750.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4114804,"Date":"2026-10-15T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":28.0,"OpenPrice":27.0,"FirstTrade":27.0,"High":27.0,"Low":26.0,"Close":27.0,"Change":-1.0,"Volume":5443007700.0,"Value":146026601200.0,"Frequency":15162.0,"IndexIndividual":8.0,"Offer":28.0,"OfferVolume":1445696200.0,"Bid":27.0,"BidVolume":374663700.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":1724301600.0,"ForeignBuy":882307700.0,"DelistingDate":"","NonRegularVolume":43.0,"NonRegularValue":1204.0,"NonRegularFrequency":1.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4115331,"Date":"2026-10-15T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2250.0,"OpenPrice":2250.0,"FirstTrade":2260.0,"High":2260.0,"Low":2170.0,"Close":2230.0,"Change":-20.0,"Volume":150485300.0,"Value":333287991000.0,"Frequency":21026.0,"IndexIndividual":1171.9,"Offer":2230.0,"OfferVolume":1787000.0,"Bid":2220.0,"BidVolume":1084100.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":105521800.0,"ForeignBuy":97385100.0,"DelistingDate":"","NonRegularVolume":1177287.0,"NonRegularValue":2606515710.0,"NonRegularFrequency":3.0,"persen":null,"percentage":null}]}
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4115515,"Date":"2026-10-16T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6075.0,"OpenPrice":5175.0,"FirstTrade":5225.0,"High":5825.0,"Low":5175.0,"Close":5175.0,"Change":-900.0,"Volume":1241098200.0,"Value":6474934315000.0,"Frequency":185072.0,"IndexIndividual":14775.6,"Offer":5175.0,"OfferVolume":183825200.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":757227900.0,"ForeignBuy":309392800.0,"DelistingDate":"","NonRegularVolume":16501628.0,"NonRegularValue":100183151475.0,"NonRegularFrequency":15.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4115521,"Date":"2026-10-16T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3100.0,"OpenPrice":2640.0,"FirstTrade":2730.0,"High":2890.0,"Low":2640.0,"Close":2640.0,"Change":-460.0,"Volume":1193916400.0,"Value":3234746482000.0,"Frequency":219483.0,"IndexIndividual":3096.3,"Offer":2640.0,"OfferVolume":20839200.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":687811000.0,"ForeignBuy":262656800.0,"DelistingDate":"","NonRegularVolume":23371386.0,"NonRegularValue":62017203290.0,"NonRegularFrequency":12.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4115767,"Date":"2026-10-16T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":23.0,"FirstTrade":23.0,"High":27.0,"Low":23.0,"Close":25.0,"Change":-2.0,"Volume":42657972100.0,"Value":1026997626200.0,"Frequency":61645.0,"IndexIndividual":7.4,"Offer":25.0,"OfferVolume":186996300.0,"Bid":24.0,"BidVolume":362311000.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":10011994300.0,"ForeignBuy":21910169500.0,"DelistingDate":"","NonRegularVolume":49.0,"NonRegularValue":1127.0,"NonRegularFrequency":1.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4116294,"Date":"2026-10-16T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2230.0,"OpenPrice":1900.0,"FirstTrade":1970.0,"High":1985.0,"Low":1900.0,"Close":1900.0,"Change":-330.0,"Volume":963614200.0,"Value":1855341292500.0,"Frequency":68042.0,"IndexIndividual":998.5,"Offer":1900.0,"OfferVolume":860400.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":727289800.0,"ForeignBuy":679004400.0,"DelistingDate":"","NonRegularVolume":165000429.0,"NonRegularValue":323596857244.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null}]}
|
||||||
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"draw":0,"recordsTotal":0,"recordsFiltered":0,"data":[]}
|
||||||
|
|
@ -157,6 +157,21 @@ pub struct BandarParams {
|
||||||
pub top: Option<u8>,
|
pub top: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct FlowParams {
|
||||||
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
pub symbol: String,
|
||||||
|
/// Latest sessions (default 10, at most 60). Omit when giving from/to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub days: Option<usize>,
|
||||||
|
/// First session of a range, YYYY-MM-DD (at most 92 days).
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last session of the range, YYYY-MM-DD (default today).
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ShareholdersParams {
|
pub struct ShareholdersParams {
|
||||||
/// IDX ticker, e.g. "BBCA".
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use idx_core::broker::types::BrokerRange;
|
||||||
use idx_core::config::CoreConfig;
|
use idx_core::config::CoreConfig;
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter};
|
use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||||
|
use idx_core::flow::types::FlowRange;
|
||||||
use idx_core::investor::{HolderSource, Investor};
|
use idx_core::investor::{HolderSource, Investor};
|
||||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||||
use idx_core::ownership::types::ChangeRow;
|
use idx_core::ownership::types::ChangeRow;
|
||||||
|
|
@ -25,8 +26,8 @@ use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::params::{
|
use crate::params::{
|
||||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||||
CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams,
|
CompanySection, EntityParams, FlowParams, HistoryParams, ScreenParams, SearchParams,
|
||||||
SymbolsParams, SyncParams,
|
ShareholdersParams, SymbolsParams, SyncParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_TOOL: &str = "ownership_sync";
|
const SYNC_TOOL: &str = "ownership_sync";
|
||||||
|
|
@ -239,6 +240,28 @@ gross figures overstate their direction. `method` names the rules applied. `inve
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 value traded outside the regular board). Totals \
|
||||||
|
cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day."
|
||||||
|
)]
|
||||||
|
async fn get_foreign_flow(&self, params: Parameters<FlowParams>) -> ToolResult {
|
||||||
|
let FlowParams {
|
||||||
|
symbol,
|
||||||
|
days,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
} = 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)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,49 @@
|
||||||
},
|
},
|
||||||
"name": "get_company"
|
"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 value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day.",
|
||||||
|
"inputSchema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"days": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Latest sessions (default 10, at most 60). Omit when giving from/to.",
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"default": null,
|
||||||
|
"description": "First session of a range, YYYY-MM-DD (at most 92 days).",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"description": "IDX ticker, e.g. \"BBCA\".",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Last session of the range, YYYY-MM-DD (default today).",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"symbol"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "get_foreign_flow"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Every IDX holding (above 1%) of one investor, matched by name.",
|
"description": "Every IDX holding (above 1%) of one investor, matched by name.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ use idx_core::config::CoreConfig;
|
||||||
use idx_core::investor::{ImportSource, Investor};
|
use idx_core::investor::{ImportSource, Investor};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const TOOLS: [&str; 13] = [
|
const TOOLS: [&str; 14] = [
|
||||||
"analyze_bandar",
|
"analyze_bandar",
|
||||||
"analyze_stock",
|
"analyze_stock",
|
||||||
"compare_stocks",
|
"compare_stocks",
|
||||||
"get_broker_summary",
|
"get_broker_summary",
|
||||||
"get_company",
|
"get_company",
|
||||||
|
"get_foreign_flow",
|
||||||
"get_investor_holdings",
|
"get_investor_holdings",
|
||||||
"get_ownership_changes",
|
"get_ownership_changes",
|
||||||
"get_price_history",
|
"get_price_history",
|
||||||
|
|
@ -275,6 +276,14 @@ fn live_tools_wrap_data_with_freshness() {
|
||||||
assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK");
|
assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK");
|
||||||
assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND");
|
assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND");
|
||||||
|
|
||||||
|
let flow = client.ok(
|
||||||
|
"get_foreign_flow",
|
||||||
|
json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}),
|
||||||
|
);
|
||||||
|
assert_fetched(&flow);
|
||||||
|
assert_eq!(flow["source"], "idx");
|
||||||
|
assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5);
|
||||||
|
|
||||||
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
||||||
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
||||||
assert_eq!(compared["failures"], json!([]));
|
assert_eq!(compared["failures"], json!([]));
|
||||||
|
|
@ -311,6 +320,11 @@ fn errors_carry_cli_codes() {
|
||||||
),
|
),
|
||||||
("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})),
|
("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})),
|
||||||
("analyze_bandar", json!({"symbols": []})),
|
("analyze_bandar", json!({"symbols": []})),
|
||||||
|
("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})),
|
||||||
|
(
|
||||||
|
"get_foreign_flow",
|
||||||
|
json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let (is_error, body) = client.call(tool, arguments);
|
let (is_error, body) = client.call(tool, arguments);
|
||||||
assert!(is_error, "{tool}: {body}");
|
assert!(is_error, "{tool}: {body}");
|
||||||
|
|
|
||||||
|
|
@ -53,6 +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/`.
|
- **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`.
|
||||||
- **IPOT (Indo Premier)** — broker summaries, whichever provider is selected
|
- **IPOT (Indo Premier)** — broker summaries, whichever provider is selected
|
||||||
(`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries).
|
(`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries).
|
||||||
|
- **IDX (idx.co.id)** — the daily trading summary behind foreign flow
|
||||||
|
(`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow).
|
||||||
|
|
||||||
### MSN Endpoints
|
### MSN Endpoints
|
||||||
- `https://assets.msn.com/service/`: quotes, charts, equities, earnings,
|
- `https://assets.msn.com/service/`: quotes, charts, equities, earnings,
|
||||||
|
|
@ -109,6 +111,22 @@ WIB as the fallback.
|
||||||
fzn0x/idx. A tier with a missing figure has no reading; missing values are
|
fzn0x/idx. A tier with a missing figure has no reading; missing values are
|
||||||
never summed as zero. A change to the rules gets a new method name.
|
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`
|
||||||
|
`?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. A day without trading has no rows; rows dated
|
||||||
|
another day are dropped. `api/idx/parse.rs` parses it purely.
|
||||||
|
- Cloudflare rejects most TLS fingerprints (in October 2026 only
|
||||||
|
`curl_safari184` passed), so the request uses curl-impersonate's profile
|
||||||
|
fallback with a 30 s limit. A challenge page fails that profile, not the
|
||||||
|
parse.
|
||||||
|
- Each session is cached whole (every stock) by date: 30 days once past,
|
||||||
|
5 minutes for today. `Flow::foreign` walks weekdays without requesting
|
||||||
|
weekends; offline, `--days` skips uncached sessions while a `--from/--to`
|
||||||
|
range fails on one. `foreign_net_value` is net shares at the session's
|
||||||
|
average price (value over volume), an estimate.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
MarketDataProvider = QuoteProvider + FundamentalsProvider
|
MarketDataProvider = QuoteProvider + FundamentalsProvider
|
||||||
|
|
@ -118,6 +136,7 @@ Factory functions:
|
||||||
default_provider(kind) → Box<dyn MarketDataProvider>
|
default_provider(kind) → Box<dyn MarketDataProvider>
|
||||||
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
|
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
|
||||||
broker_provider() → Box<dyn BrokerSummaryProvider>
|
broker_provider() → Box<dyn BrokerSummaryProvider>
|
||||||
|
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ownership Module (SQLite-backed)
|
## Ownership Module (SQLite-backed)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B again
|
||||||
scripts/live-smoke.sh --dry-run
|
scripts/live-smoke.sh --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
- `stocks`: every `stocks` command for one ticker, plus `compare BUMI,ADRO,AIMS`,
|
- `stocks`: every `stocks` command for one ticker (`broker`, `bandar` and
|
||||||
|
`flow` 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.
|
whose MSN key ratios contain non-finite values.
|
||||||
- `ownership`: `discover`, then imports the newest supported report into a temp
|
- `ownership`: `discover`, then imports the newest supported report into a temp
|
||||||
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
|
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||||
sleep 2
|
sleep 2
|
||||||
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
||||||
jq -se 'map(select(.id == 2))[0].result.tools | length == 13' out.jsonl
|
jq -se 'map(select(.id == 2))[0].result.tools | length == 14' out.jsonl
|
||||||
touch "$out"
|
touch "$out"
|
||||||
'';
|
'';
|
||||||
# The website's generated pages match the CLI help and the MCP
|
# The website's generated pages match the CLI help and the MCP
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ contract() {
|
||||||
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
||||||
"stocks news "*)
|
"stocks news "*)
|
||||||
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
|
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
|
||||||
|
"stocks broker "*)
|
||||||
|
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 "*)
|
||||||
|
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")' ;;
|
||||||
"ownership discover"*)
|
"ownership discover"*)
|
||||||
echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;;
|
echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;;
|
||||||
"ownership releases")
|
"ownership releases")
|
||||||
|
|
@ -194,6 +200,12 @@ run_case() {
|
||||||
echo " PASS"
|
echo " PASS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A command the --compare binary may predate runs on the new binary only.
|
||||||
|
run_new_case() {
|
||||||
|
local COMPARE_BIN=""
|
||||||
|
run_case "$@"
|
||||||
|
}
|
||||||
|
|
||||||
if (( ! DRY_RUN )); then
|
if (( ! DRY_RUN )); then
|
||||||
command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; }
|
command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; }
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
@ -217,6 +229,10 @@ if selected stocks; then
|
||||||
run_case compare "stocks compare $SYMBOL,BBRI"
|
run_case compare "stocks compare $SYMBOL,BBRI"
|
||||||
# BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN).
|
# BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN).
|
||||||
run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS"
|
run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS"
|
||||||
|
# Broker summaries (IPOT) and foreign flow (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"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if selected ownership; then
|
if selected ownership; then
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ Commands:
|
||||||
fundamental Run full fundamental analysis on a stock
|
fundamental Run full fundamental analysis on a stock
|
||||||
broker Which brokers bought and sold a stock
|
broker Which brokers bought and sold a stock
|
||||||
bandar Bandarmology: is buying more concentrated than selling
|
bandar Bandarmology: is buying more concentrated than selling
|
||||||
|
flow Foreign buying and selling of a stock, session by session
|
||||||
profile Get company profile
|
profile Get company profile
|
||||||
financials Get financial statements
|
financials Get financial statements
|
||||||
earnings Get earnings report
|
earnings Get earnings report
|
||||||
|
|
@ -429,6 +430,57 @@ Examples:
|
||||||
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
|
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
Usage: idx stocks flow [OPTIONS] <SYMBOL>
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<SYMBOL>
|
||||||
|
Single ticker symbol (e.g. BBCA)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--days <DAYS>
|
||||||
|
Latest sessions to show (default 10, at most 60)
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--from <FROM>
|
||||||
|
First session of a range, YYYY-MM-DD (at most 92 days)
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
--to <TO>
|
||||||
|
Last session of the range, YYYY-MM-DD. Default: today
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
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 profile`
|
### `idx stocks profile`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,24 @@ codes during trading. Buyers and sellers are ranked separately on gross
|
||||||
lots, so a broker can appear on both sides. The source rounds figures of a
|
lots, so a broker can appear on both sides. The source rounds figures of a
|
||||||
million and more to one decimal, and the output says so.
|
million and more to one decimal, and the output says so.
|
||||||
|
|
||||||
|
## Foreign flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idx stocks flow BBCA
|
||||||
|
idx stocks flow BBCA --days 20
|
||||||
|
idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16
|
||||||
|
```
|
||||||
|
|
||||||
|
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 value traded outside the regular board. 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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Bandarmology
|
## Bandarmology
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -108,7 +126,7 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
MSN is the default for quotes, company data and screening; Yahoo serves
|
MSN is the default for quotes, company data and screening; Yahoo serves
|
||||||
price history, and Indo Premier (IPOT) serves broker summaries whichever
|
price history. Indo Premier (IPOT) serves broker summaries and IDX's own site
|
||||||
provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and
|
serves foreign flow, whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and
|
||||||
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching
|
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching
|
||||||
[config keys](/start/config/).
|
[config keys](/start/config/).
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys
|
||||||
| `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). |
|
| `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). |
|
||||||
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||||
|
|
||||||
|
## `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 value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day.
|
||||||
|
|
||||||
|
| 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). |
|
||||||
|
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||||
|
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
|
||||||
|
|
||||||
## `get_investor_holdings`
|
## `get_investor_holdings`
|
||||||
|
|
||||||
Every IDX holding (above 1%) of one investor, matched by name.
|
Every IDX holding (above 1%) of one investor, matched by name.
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,13 @@ idx version
|
||||||
## Helper tools
|
## Helper tools
|
||||||
|
|
||||||
Everyday use needs nothing else: stock data with the default MSN provider,
|
Everyday use needs nothing else: stock data with the default MSN provider,
|
||||||
price history, `ownership sync` and every local ownership query. A few
|
price history, broker summaries and bandarmology, `ownership sync` and every
|
||||||
commands call external tools:
|
local ownership query. A few commands call external tools:
|
||||||
|
|
||||||
| Tool | Needed for |
|
| 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` |
|
| `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 |
|
||||||
| `mutool` from MuPDF | importing legacy PDF ownership reports |
|
| `mutool` from MuPDF | importing legacy PDF ownership reports |
|
||||||
|
|
||||||
## The MCP server
|
## The MCP server
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue