mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #71 from 0xrsydn/feat/quote-behind-session
feat: flag quotes behind the latest IDX session (#50)
This commit is contained in:
commit
64c25ad0a4
98 changed files with 9648 additions and 55 deletions
266
Cargo.lock
generated
266
Cargo.lock
generated
|
|
@ -380,6 +380,29 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
|
|
@ -444,6 +467,27 @@ dependencies = [
|
|||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
|
|
@ -501,12 +545,33 @@ dependencies = [
|
|||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
|
|
@ -732,6 +797,16 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
|
|
@ -910,6 +985,7 @@ dependencies = [
|
|||
"fastrand",
|
||||
"quick-xml",
|
||||
"rusqlite",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -1025,6 +1101,17 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -1047,6 +1134,12 @@ dependencies = [
|
|||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
|
|
@ -1149,6 +1242,59 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1176,6 +1322,12 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.4"
|
||||
|
|
@ -1377,6 +1529,21 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
|
|
@ -1469,6 +1636,45 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -1532,6 +1738,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -1555,6 +1770,12 @@ version = "0.3.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1573,6 +1794,30 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
|
@ -1618,6 +1863,15 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
|
|
@ -2005,6 +2259,18 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
|
|
|||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -11,6 +11,13 @@ history lives in git.
|
|||
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
||||
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
||||
- Multi-period financial statements (today `financials` is single-period)
|
||||
- Order book (bid/offer depth): no free, login-free source exists. IDX sells
|
||||
delayed depth to vendors and Stockbit's needs a user login. Revisit if a
|
||||
permitted source appears; do not ship the closing best bid/offer as one.
|
||||
- Bandarmology follow-ups: a daily series (per-session readings and the
|
||||
cumulative top-N net) and a price-move phase (quiet accumulation, markup),
|
||||
calibration of the `top_concentration_v1` thresholds across more sessions,
|
||||
and a market-wide foreign flow screen from one IDX trading summary
|
||||
- News beyond MSN in `news` / `get_company` (`docs/IDX_NEWS.md`): Google News
|
||||
RSS per ticker, and IDX disclosures and exchange news through a
|
||||
maintainer-published feed
|
||||
|
|
@ -26,6 +33,12 @@ history lives in git.
|
|||
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
|
||||
|
||||
## Known limits
|
||||
- `stocks broker` and `stocks bandar` show only IPOT's top 10 buyers and
|
||||
sellers, ranked by gross lots, with figures of a million and more rounded
|
||||
to one decimal.
|
||||
- `stocks flow --source idx` depends on IDX's Cloudflare passing a
|
||||
curl-impersonate profile (only `curl_safari184` in October 2026) and on the
|
||||
network; the default IPOT source rounds to one decimal.
|
||||
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
|
||||
can still fail.
|
||||
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
||||
|
|
|
|||
|
|
@ -4,18 +4,26 @@ use clap::{Args, Subcommand};
|
|||
|
||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||
use crate::output::{
|
||||
render_compare, render_earnings, render_financials, render_fundamental, render_growth,
|
||||
render_history, render_insights, render_news, render_profile, render_quotes, render_risk,
|
||||
render_screener, render_sentiment, render_technical, render_valuation,
|
||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
||||
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
||||
render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
|
||||
render_technical, render_valuation,
|
||||
};
|
||||
use idx_core::analysis::BandarQuery;
|
||||
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||
use idx_core::api::types::{Interval, Period};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
||||
use idx_core::flow::types::{FlowRange, FlowSource};
|
||||
use idx_core::market::ScreenQuery;
|
||||
use idx_core::{CachePolicy, Idx, runtime};
|
||||
|
||||
/// Splits comma-separated symbol arguments; `-` reads symbols from stdin,
|
||||
/// separated by whitespace or commas.
|
||||
/// Most symbols one `bandar` screen fetches, one request each.
|
||||
const MAX_BANDAR_SYMBOLS: usize = 100;
|
||||
|
||||
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
||||
let mut symbols = Vec::new();
|
||||
for arg in args {
|
||||
|
|
@ -76,6 +84,37 @@ impl From<EarningsFilterArgs> for EarningsFilter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Which sessions and trades a broker command covers.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct BrokerScopeArgs {
|
||||
/// One session, YYYY-MM-DD. Default: the latest session with trades.
|
||||
#[arg(long, conflicts_with_all = ["from", "to"])]
|
||||
date: Option<String>,
|
||||
/// First session of a range, YYYY-MM-DD (at most 366 days).
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// Last session of the range, YYYY-MM-DD. Default: today.
|
||||
#[arg(long, requires = "from")]
|
||||
to: Option<String>,
|
||||
/// Whose trades to count.
|
||||
#[arg(long, value_enum, default_value_t = BrokerInvestor::All)]
|
||||
investor: BrokerInvestor,
|
||||
/// Which board's trades to count; `regular` leaves out negotiated block trades.
|
||||
#[arg(long, value_enum, default_value_t = BrokerBoard::All)]
|
||||
board: BrokerBoard,
|
||||
}
|
||||
|
||||
impl BrokerScopeArgs {
|
||||
fn range(&self, idx: &Idx) -> Result<BrokerRange, IdxError> {
|
||||
BrokerRange::from_args(
|
||||
self.date.as_deref(),
|
||||
self.from.as_deref(),
|
||||
self.to.as_deref(),
|
||||
idx.broker().today(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(about = "Stock data and analysis")]
|
||||
pub struct StocksCmd {
|
||||
|
|
@ -150,6 +189,65 @@ pub enum StocksSubcommand {
|
|||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Which brokers bought and sold a stock",
|
||||
long_about = "Which brokers bought and sold a stock: the top 10 buyers and top 10 \
|
||||
sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX \
|
||||
does not show broker codes during trading. Without dates, the latest session with trades.",
|
||||
after_help = "Examples:\n idx stocks broker BBCA\n idx stocks broker BBCA --date 2026-10-16\n idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular\n idx -o json stocks broker BBCA --investor foreign"
|
||||
)]
|
||||
Broker {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
#[command(flatten)]
|
||||
scope: BrokerScopeArgs,
|
||||
},
|
||||
#[command(
|
||||
about = "Bandarmology: is buying more concentrated than selling",
|
||||
long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \
|
||||
against the same number of sellers', as a share of value traded, with a signal from big \
|
||||
accumulation to big distribution. Several symbols give a screen ranked by the headline tier \
|
||||
(at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers \
|
||||
against its buying and its sellers against its selling (needs `--board all`). A \
|
||||
heuristic over end-of-day broker codes, not advice.",
|
||||
after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -"
|
||||
)]
|
||||
Bandar {
|
||||
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
|
||||
#[arg(required = true)]
|
||||
symbols: Vec<String>,
|
||||
#[command(flatten)]
|
||||
scope: BrokerScopeArgs,
|
||||
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side.
|
||||
#[arg(long, default_value_t = DEFAULT_TOP)]
|
||||
top: u8,
|
||||
},
|
||||
#[command(
|
||||
about = "Foreign buying and selling of a stock, session by session",
|
||||
long_about = "Foreign buying and selling of a stock, session by session, end of day. By \
|
||||
default from Indo Premier (IPOT): foreign buy, sell and net value across all boards and \
|
||||
foreign investors' share of trading, two plain requests per session. `--source idx` reads \
|
||||
IDX's own trading summary instead: exact foreign share counts, the close and the share \
|
||||
traded outside the regular board, one request per session to idx.co.id, which needs \
|
||||
curl-impersonate and a network IDX's Cloudflare lets through.",
|
||||
after_help = "Examples:\n idx stocks flow BBCA\n idx stocks flow BBCA --days 20\n idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16\n idx stocks flow BBCA --source idx\n idx -o json stocks flow BBCA | jq '.foreign_net_value'"
|
||||
)]
|
||||
Flow {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
/// Latest sessions to show (default 10, at most 60).
|
||||
#[arg(long, conflicts_with_all = ["from", "to"])]
|
||||
days: Option<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>,
|
||||
/// Where the figures come from.
|
||||
#[arg(long, value_enum, default_value_t = FlowSource::Ipot)]
|
||||
source: FlowSource,
|
||||
},
|
||||
#[command(about = "Get company profile")]
|
||||
Profile { symbol: String },
|
||||
#[command(
|
||||
|
|
@ -256,6 +354,53 @@ pub fn handle(
|
|||
let report = idx.analysis().fundamental(symbol, policy)?;
|
||||
render_fundamental(&report.data, format, no_color)
|
||||
}
|
||||
StocksSubcommand::Broker { symbol, scope } => {
|
||||
let summary = idx.broker().fetch(
|
||||
symbol,
|
||||
scope.range(&idx)?,
|
||||
scope.investor,
|
||||
scope.board,
|
||||
policy,
|
||||
)?;
|
||||
render_broker_summary(&summary.data, format)
|
||||
}
|
||||
StocksSubcommand::Bandar {
|
||||
symbols,
|
||||
scope,
|
||||
top,
|
||||
} => {
|
||||
let symbols = expand_symbols(symbols)?;
|
||||
if symbols.len() > MAX_BANDAR_SYMBOLS {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})",
|
||||
symbols.len()
|
||||
)));
|
||||
}
|
||||
let query = BandarQuery {
|
||||
range: scope.range(&idx)?,
|
||||
investor: scope.investor,
|
||||
board: scope.board,
|
||||
top: *top,
|
||||
};
|
||||
let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?;
|
||||
for (resolved, err) in screen.failures {
|
||||
runtime::warn(format!("failed to read brokers for {resolved}: {err}"));
|
||||
}
|
||||
let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect();
|
||||
render_bandar(&reports, symbols.len() > 1, format, no_color)
|
||||
}
|
||||
StocksSubcommand::Flow {
|
||||
symbol,
|
||||
days,
|
||||
from,
|
||||
to,
|
||||
source,
|
||||
} => {
|
||||
let range =
|
||||
FlowRange::from_args(*days, from.as_deref(), to.as_deref(), idx.flow().today())?;
|
||||
let flow = idx.flow().foreign(symbol, range, *source, policy)?;
|
||||
render_foreign_flow(&flow.data, format)
|
||||
}
|
||||
StocksSubcommand::Profile { symbol } => {
|
||||
let profile = idx.financial().profile(symbol, policy)?;
|
||||
render_profile(&profile.data, format)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use std::io::IsTerminal;
|
|||
use clap::ValueEnum;
|
||||
use serde::Serialize;
|
||||
|
||||
use idx_core::analysis::bandar::BandarReport;
|
||||
use idx_core::analysis::fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||
};
|
||||
|
|
@ -16,7 +17,9 @@ use idx_core::api::types::{
|
|||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
|
||||
SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::BrokerSummary;
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::flow::types::ForeignFlow;
|
||||
|
||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||
|
|
@ -73,6 +76,38 @@ pub fn render_quotes(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_broker_summary(
|
||||
summary: &BrokerSummary,
|
||||
format: &OutputFormat,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_broker_summary(summary),
|
||||
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(summary),
|
||||
}
|
||||
}
|
||||
|
||||
/// One symbol gets the detailed table, several the ranked screen (even when
|
||||
/// only one of them succeeded); JSON is always the array.
|
||||
pub fn render_bandar(
|
||||
reports: &[BandarReport],
|
||||
screen: bool,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match (format, reports) {
|
||||
(OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color),
|
||||
(OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color),
|
||||
(OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_foreign_flow(flow: &ForeignFlow, format: &OutputFormat) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_foreign_flow(flow),
|
||||
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(flow),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_history(
|
||||
symbol: &str,
|
||||
history: &[Ohlc],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ use crate::output::style::Paint;
|
|||
use crate::output::text::{cell, painted, terminal_safe, truncate_chars};
|
||||
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||
|
||||
use idx_core::analysis::bandar::{
|
||||
ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD,
|
||||
};
|
||||
use idx_core::analysis::fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||
};
|
||||
|
|
@ -11,7 +14,9 @@ use idx_core::api::types::{
|
|||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
|
||||
Quote, SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
||||
|
||||
pub fn format_idr(value: i64) -> String {
|
||||
let sign = if value.is_negative() { "-" } else { "" };
|
||||
|
|
@ -89,19 +94,29 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
|||
}
|
||||
|
||||
/// A quote whose last trade is over a week old is almost always a suspended
|
||||
/// stock; say so under the table instead of letting it pass as current.
|
||||
/// stock; say so under the table instead of letting it pass as current. One
|
||||
/// from before the latest session is usually the provider lagging the close.
|
||||
fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
|
||||
quotes
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let as_of = q.as_of?;
|
||||
(now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| {
|
||||
format!(
|
||||
if now.signed_duration_since(as_of) > chrono::Duration::days(7) {
|
||||
Some(format!(
|
||||
"Note: {} last traded on {}; this price is not current.",
|
||||
q.symbol,
|
||||
as_of.date_naive()
|
||||
)
|
||||
})
|
||||
))
|
||||
} else if q.behind_session {
|
||||
Some(format!(
|
||||
"Note: {} last traded on {}, before the latest IDX session; \
|
||||
this price may be out of date.",
|
||||
q.symbol,
|
||||
as_of.date_naive()
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -893,6 +908,483 @@ fn truncate_url(url: &str) -> String {
|
|||
truncate_chars(url, 72)
|
||||
}
|
||||
|
||||
/// A figure at the precision the broker summary source publishes:
|
||||
/// `841.2B`, `1.6M`, exact below a million.
|
||||
fn format_compact(value: i64) -> String {
|
||||
let abs = value.unsigned_abs();
|
||||
let (unit, suffix) = match abs {
|
||||
1_000_000_000_000.. => (1e12, "T"),
|
||||
1_000_000_000.. => (1e9, "B"),
|
||||
1_000_000.. => (1e6, "M"),
|
||||
_ => return format_idr(value),
|
||||
};
|
||||
format!("{:.1}{suffix}", value as f64 / unit)
|
||||
}
|
||||
|
||||
fn format_compact_option(value: Option<i64>) -> String {
|
||||
value.map(format_compact).unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn broker_cells(flow: Option<&BrokerFlow>) -> [Cell; 4] {
|
||||
match flow {
|
||||
Some(flow) => [
|
||||
cell(&flow.code),
|
||||
cell(format_compact_option(flow.lots)),
|
||||
cell(format_compact_option(flow.value)),
|
||||
cell(
|
||||
flow.average_price
|
||||
.map(format_idr)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
],
|
||||
None => [cell(""), cell(""), cell(""), cell("")],
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-16`, or `2026-10-12 to 2026-10-16`, plus any filters.
|
||||
pub(crate) fn broker_scope(
|
||||
from: chrono::NaiveDate,
|
||||
to: chrono::NaiveDate,
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
) -> String {
|
||||
let mut scope = if from == to {
|
||||
from.to_string()
|
||||
} else {
|
||||
format!("{from} to {to}")
|
||||
};
|
||||
if investor != BrokerInvestor::All {
|
||||
scope.push_str(&format!(", {} investors", investor.as_str()));
|
||||
}
|
||||
if board != BrokerBoard::All {
|
||||
scope.push_str(&format!(", {} board", board.as_str()));
|
||||
}
|
||||
scope
|
||||
}
|
||||
|
||||
/// What one investor side bought and sold, when a summary covers one side.
|
||||
fn side_totals_line(
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
buy_value: Option<i64>,
|
||||
sell_value: Option<i64>,
|
||||
foreign_net: Option<i64>,
|
||||
) -> Option<String> {
|
||||
let side = match investor {
|
||||
BrokerInvestor::All => return None,
|
||||
BrokerInvestor::Foreign => "Foreign",
|
||||
BrokerInvestor::Domestic => "Domestic",
|
||||
};
|
||||
let buy = format_compact_option(buy_value);
|
||||
Some(match sell_value {
|
||||
Some(sell) => {
|
||||
let net = foreign_net
|
||||
.map(|net| match investor {
|
||||
BrokerInvestor::Domestic => -net,
|
||||
_ => net,
|
||||
})
|
||||
.map(|net| format!(" (net {:+})", format_compact(net)))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{side} investors bought {buy} IDR and sold {} IDR{net}.",
|
||||
format_compact(sell)
|
||||
)
|
||||
}
|
||||
None => format!(
|
||||
"{side} investors bought {buy} IDR on the {} board; the source gives one side's \
|
||||
selling only across all boards.",
|
||||
board.as_str()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"Broker summary for {}, {}",
|
||||
terminal_safe(&summary.symbol),
|
||||
broker_scope(summary.from, summary.to, summary.investor, summary.board)
|
||||
)
|
||||
.bold()
|
||||
);
|
||||
if summary.is_empty() {
|
||||
println!("No trades.");
|
||||
return Ok(());
|
||||
}
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"#", "BUYER", "LOTS", "VALUE", "AVG", "SELLER", "LOTS", "VALUE", "AVG",
|
||||
]);
|
||||
let rows = summary.buyers.len().max(summary.sellers.len());
|
||||
for index in 0..rows {
|
||||
let mut row = vec![cell(index + 1)];
|
||||
row.extend(broker_cells(summary.buyers.get(index)));
|
||||
row.extend(broker_cells(summary.sellers.get(index)));
|
||||
table.add_row(row);
|
||||
}
|
||||
println!("{table}");
|
||||
|
||||
if summary.total_value.is_some() {
|
||||
let mut totals = format!(
|
||||
"Total {} IDR, {} lots",
|
||||
format_compact_option(summary.total_value),
|
||||
format_compact_option(summary.total_lots)
|
||||
);
|
||||
if let Some(average) = summary.average_price {
|
||||
totals.push_str(&format!(", average {}", format_idr(average)));
|
||||
}
|
||||
if let Some(net) = summary.foreign_net_value {
|
||||
totals.push_str(&format!("; foreign net {:+}", format_compact(net)));
|
||||
}
|
||||
println!("{totals}.");
|
||||
} else if let Some(line) = side_totals_line(
|
||||
summary.investor,
|
||||
summary.board,
|
||||
summary.buy_value,
|
||||
summary.sell_value,
|
||||
summary.foreign_net_value,
|
||||
) {
|
||||
println!("{line}");
|
||||
}
|
||||
for (kind, label) in [
|
||||
(BrokerKind::Foreign, "Foreign brokers"),
|
||||
(BrokerKind::StateOwned, "State-owned brokers"),
|
||||
] {
|
||||
let mut codes: Vec<&str> = summary
|
||||
.buyers
|
||||
.iter()
|
||||
.chain(&summary.sellers)
|
||||
.filter(|flow| flow.kind == Some(kind))
|
||||
.map(|flow| flow.code.as_str())
|
||||
.collect();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
if !codes.is_empty() {
|
||||
println!("{label}: {}.", terminal_safe(&codes.join(", ")));
|
||||
}
|
||||
}
|
||||
println!("Buyers and sellers are each ranked by lots; a broker can be on both sides.");
|
||||
if summary.rounded {
|
||||
println!("Note: the source rounds figures of a million and more to one decimal.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_bps_pct(bps: Option<i64>) -> String {
|
||||
bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0))
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn format_bps_share(bps: Option<i64>) -> String {
|
||||
bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0))
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn format_bandar_signal(signal: Option<BandarSignal>, no_color: bool) -> String {
|
||||
let Some(signal) = signal else {
|
||||
return "-".to_string();
|
||||
};
|
||||
let label = signal.as_str().replace('_', " ");
|
||||
if no_color {
|
||||
return label;
|
||||
}
|
||||
match signal {
|
||||
BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(),
|
||||
BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(),
|
||||
BandarSignal::Neutral => label,
|
||||
}
|
||||
}
|
||||
|
||||
fn bandar_method_note(top: u8) -> String {
|
||||
format!(
|
||||
"Method {BANDAR_METHOD}: the top {top} buyers' share of the value bought minus the top \
|
||||
{top} sellers' share of the value sold; {}% or more reads accumulation, {}% or more big \
|
||||
(negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.",
|
||||
ACCUMULATION_BPS / 100,
|
||||
BIG_BPS / 100
|
||||
)
|
||||
}
|
||||
|
||||
pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"Bandar reading for {}, {}",
|
||||
terminal_safe(&report.symbol),
|
||||
broker_scope(report.from, report.to, report.investor, report.board)
|
||||
)
|
||||
.bold()
|
||||
);
|
||||
// Brokers on either side mean trades, even when a total is unavailable.
|
||||
if report
|
||||
.tiers
|
||||
.iter()
|
||||
.all(|tier| tier.buy_value == Some(0) && tier.sell_value == Some(0))
|
||||
{
|
||||
println!("No trades.");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"Signal (top {}): {}, {} of value",
|
||||
report.top,
|
||||
format_bandar_signal(report.signal, no_color),
|
||||
format_bps_pct(report.imbalance_bps)
|
||||
);
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL",
|
||||
]);
|
||||
for tier in &report.tiers {
|
||||
table.add_row(vec![
|
||||
cell(tier.top),
|
||||
cell(format_compact_option(tier.buy_value)),
|
||||
cell(format_compact_option(tier.sell_value)),
|
||||
cell(format_compact_option(tier.imbalance_value)),
|
||||
cell(format_bps_pct(tier.imbalance_bps)),
|
||||
cell(
|
||||
tier.buy_average
|
||||
.map(format_idr)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
cell(
|
||||
tier.sell_average
|
||||
.map(format_idr)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
painted(format_bandar_signal(tier.signal, no_color)),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
if let Some(line) = side_totals_line(
|
||||
report.investor,
|
||||
report.board,
|
||||
report.buy_value,
|
||||
report.sell_value,
|
||||
report.foreign_net_value,
|
||||
) {
|
||||
println!("{line}");
|
||||
} else {
|
||||
let mut totals = format!("Total {} IDR", format_compact_option(report.total_value));
|
||||
if let Some(average) = report.average_price {
|
||||
totals.push_str(&format!(", average {}", format_idr(average)));
|
||||
}
|
||||
if let Some(net) = report.foreign_net_value {
|
||||
totals.push_str(&format!(
|
||||
"; foreign net {:+} ({} of value)",
|
||||
format_compact(net),
|
||||
format_bps_pct(report.foreign_net_bps)
|
||||
));
|
||||
}
|
||||
println!("{totals}.");
|
||||
}
|
||||
if !report.both_sides.is_empty() {
|
||||
println!(
|
||||
"On both sides: {}; their gross figures overstate which way they traded.",
|
||||
terminal_safe(&report.both_sides.join(", "))
|
||||
);
|
||||
}
|
||||
println!("{}", bandar_method_note(report.top));
|
||||
if report.rounded {
|
||||
println!("Note: the source rounds figures of a million and more to one decimal.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> {
|
||||
let top = reports.first().map(|report| report.top).unwrap_or_default();
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"SYMBOL".to_string(),
|
||||
"DATES".to_string(),
|
||||
"SIGNAL".to_string(),
|
||||
format!("TOP {top} NET %"),
|
||||
"BUY SHARE".to_string(),
|
||||
"SELL SHARE".to_string(),
|
||||
"VALUE".to_string(),
|
||||
"FOREIGN NET".to_string(),
|
||||
]);
|
||||
for report in reports {
|
||||
let headline = report.tiers.iter().find(|tier| tier.top == report.top);
|
||||
table.add_row(vec![
|
||||
cell(&report.symbol),
|
||||
cell(broker_scope(
|
||||
report.from,
|
||||
report.to,
|
||||
BrokerInvestor::All,
|
||||
BrokerBoard::All,
|
||||
)),
|
||||
painted(format_bandar_signal(report.signal, no_color)),
|
||||
cell(format_bps_pct(report.imbalance_bps)),
|
||||
cell(format_bps_share(
|
||||
headline.and_then(|tier| tier.buy_share_bps),
|
||||
)),
|
||||
cell(format_bps_share(
|
||||
headline.and_then(|tier| tier.sell_share_bps),
|
||||
)),
|
||||
cell(format_compact_option(report.total_value)),
|
||||
cell(
|
||||
report
|
||||
.foreign_net_value
|
||||
.map(|net| format!("{:+}", format_compact(net)))
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
if let Some(first) = reports.first()
|
||||
&& (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All)
|
||||
{
|
||||
println!(
|
||||
"Counting {} investors on the {} board.",
|
||||
first.investor.as_str(),
|
||||
first.board.as_str()
|
||||
);
|
||||
}
|
||||
println!("{}", bandar_method_note(top));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_signed_compact(value: Option<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" },
|
||||
match flow.source {
|
||||
FlowSource::Ipot => "IPOT",
|
||||
FlowSource::Idx => "IDX",
|
||||
}
|
||||
)
|
||||
.bold()
|
||||
);
|
||||
match flow.source {
|
||||
FlowSource::Ipot => print_ipot_flow(flow),
|
||||
FlowSource::Idx => print_idx_flow(flow),
|
||||
}
|
||||
if flow.rounded {
|
||||
println!("Note: the source rounds figures of a million and more to one decimal.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_ipot_flow(flow: &ForeignFlow) {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"DATE",
|
||||
"VALUE",
|
||||
"FOREIGN BUY",
|
||||
"FOREIGN SELL",
|
||||
"FOREIGN NET",
|
||||
"FOREIGN %",
|
||||
]);
|
||||
for day in &flow.days {
|
||||
table.add_row(vec![
|
||||
cell(day.date),
|
||||
cell(format_compact_option(day.value)),
|
||||
cell(format_compact_option(day.foreign_buy_value)),
|
||||
cell(format_compact_option(day.foreign_sell_value)),
|
||||
cell(format_signed_compact(day.foreign_net_value)),
|
||||
cell(format_bps_share(day.foreign_share_bps)),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
let mut summary = format!(
|
||||
"Foreign investors bought {} IDR and sold {} IDR: net {}",
|
||||
format_compact_option(flow.foreign_buy_value),
|
||||
format_compact_option(flow.foreign_sell_value),
|
||||
format_signed_compact(flow.foreign_net_value)
|
||||
);
|
||||
if let Some(share) = flow.foreign_share_bps {
|
||||
summary.push_str(&format!(
|
||||
"; they made up {} of trading",
|
||||
format_bps_share(Some(share))
|
||||
));
|
||||
}
|
||||
println!("{summary}.");
|
||||
println!(
|
||||
"Values in IDR across all boards. For exact share counts, the close and the non-regular \
|
||||
share, use --source idx (needs curl-impersonate)."
|
||||
);
|
||||
}
|
||||
|
||||
fn print_idx_flow(flow: &ForeignFlow) {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"DATE",
|
||||
"CLOSE",
|
||||
"CHG%",
|
||||
"VALUE",
|
||||
"FOREIGN BUY",
|
||||
"FOREIGN SELL",
|
||||
"FOREIGN NET",
|
||||
"NET VALUE",
|
||||
"NON-REG",
|
||||
]);
|
||||
for day in &flow.days {
|
||||
table.add_row(vec![
|
||||
cell(day.date),
|
||||
cell(day.close.map(format_idr).unwrap_or_else(|| "-".to_string())),
|
||||
cell(format_bps_pct(day.change_bps)),
|
||||
cell(format_compact_option(day.value)),
|
||||
cell(format_compact_option(day.foreign_buy)),
|
||||
cell(format_compact_option(day.foreign_sell)),
|
||||
cell(format_signed_compact(day.foreign_net)),
|
||||
cell(format_signed_compact(day.foreign_net_value)),
|
||||
cell(format_bps_share(day.non_regular_bps)),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
let mut summary = format!(
|
||||
"Net foreign: {} shares, about {} IDR",
|
||||
format_signed_compact(flow.foreign_net),
|
||||
format_signed_compact(flow.foreign_net_value)
|
||||
);
|
||||
if let Some(share) = flow.foreign_share_bps {
|
||||
summary.push_str(&format!(
|
||||
"; foreign investors made up {} of trading",
|
||||
format_bps_share(Some(share))
|
||||
));
|
||||
}
|
||||
println!("{summary}.");
|
||||
println!(
|
||||
"Volume, value and foreign figures are the regular board, volumes in shares. Net value \
|
||||
is estimated at each session's average price; NON-REG is the share of all value traded \
|
||||
outside the regular board (negotiated and cash)."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
|
@ -917,6 +1409,7 @@ mod tests {
|
|||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
|
||||
behind_session: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -936,6 +1429,30 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_quotes_behind_the_latest_session() {
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2026-10-16T16:53:00+07:00")
|
||||
.unwrap()
|
||||
.to_utc();
|
||||
let mut behind = quote_as_of("BBCA.JK", "2026-10-15T16:15:00+07:00");
|
||||
behind.behind_session = true;
|
||||
let mut suspended = quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00");
|
||||
suspended.behind_session = true;
|
||||
let quotes = [
|
||||
behind,
|
||||
suspended,
|
||||
quote_as_of("BBRI.JK", "2026-10-16T16:15:00+07:00"),
|
||||
];
|
||||
assert_eq!(
|
||||
stale_quote_notes("es, now),
|
||||
vec![
|
||||
"Note: BBCA.JK last traded on 2026-10-15, before the latest IDX session; \
|
||||
this price may be out of date.",
|
||||
"Note: WIKA.JK last traded on 2025-02-17; this price is not current.",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_idr_numbers() {
|
||||
assert_eq!(format_idr(9875), "9,875");
|
||||
|
|
|
|||
|
|
@ -2407,6 +2407,68 @@ fn msn_screen_ranks_complete_candidates_and_caches_before_limit() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msn_screen_flags_idx_rows_behind_the_session() {
|
||||
let root = test_env_dir("screen-behind-session");
|
||||
let fixture = write_large_screener_fixture(&root, Some(503));
|
||||
let behind = |rows: &serde_json::Value| -> Vec<bool> {
|
||||
rows.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row["behind_session"].as_bool().unwrap())
|
||||
.collect()
|
||||
};
|
||||
// Every fixture row last traded on 2026-01-02, long before any session now.
|
||||
let fresh = run_success_json(&mut screener_fixture_command(
|
||||
&root,
|
||||
&fixture,
|
||||
"top-performers",
|
||||
3,
|
||||
));
|
||||
assert_eq!(behind(&fresh), [true; 3]);
|
||||
let mut offline = screener_fixture_command(&root, &fixture, "top-performers", 3);
|
||||
offline.env("IDX_MOCK_ERROR", "1").arg("--offline");
|
||||
assert_eq!(behind(&run_success_json(&mut offline)), [true; 3]);
|
||||
// IDX's session rule says nothing about other exchanges.
|
||||
let mut us = screener_fixture_command(&root, &fixture, "top-performers", 3);
|
||||
us.args(["--region", "us"]);
|
||||
assert_eq!(behind(&run_success_json(&mut us)), [false; 3]);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_read_from_a_0_3_3_cache_entry_is_behind_the_session() {
|
||||
// IDX's session rule says nothing about other exchanges.
|
||||
for (symbol, file, behind) in [
|
||||
("BBCA", "BBCA.JK.json", true),
|
||||
("AAPL.US", "AAPL.US.json", false),
|
||||
] {
|
||||
let root = test_env_dir(&format!("quote-behind-session-{symbol}"));
|
||||
let mut warm = mock_bin_with_root(&root);
|
||||
run_success_json(warm.args(["-o", "json", "stocks", "quote", symbol]));
|
||||
let entry = fs::read_dir(root.join("cache"))
|
||||
.unwrap()
|
||||
.flat_map(|dir| fs::read_dir(dir.unwrap().path()).unwrap())
|
||||
.flat_map(|bucket| fs::read_dir(bucket.unwrap().path()).unwrap())
|
||||
.map(|file| file.unwrap().path())
|
||||
.find(|path| path.ends_with(file))
|
||||
.expect("cached quote");
|
||||
// 0.3.3 wrote quotes without the field; this one last traded long ago.
|
||||
let mut cached: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&entry).unwrap()).unwrap();
|
||||
let data = cached["data"].as_object_mut().unwrap();
|
||||
data.remove("behind_session")
|
||||
.expect("field is cached unmarked");
|
||||
data.insert("as_of".into(), "2026-01-02T16:15:00+07:00".into());
|
||||
fs::write(&entry, cached.to_string()).unwrap();
|
||||
let mut offline = mock_bin_with_root(&root);
|
||||
let quotes =
|
||||
run_success_json(offline.args(["--offline", "-o", "json", "stocks", "quote", symbol]));
|
||||
assert_eq!(quotes[0]["behind_session"], behind, "{symbol}");
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msn_screen_rejects_invalid_totals_without_populating_cache() {
|
||||
for (name, count) in [("missing", None), ("incomplete", Some(504))] {
|
||||
|
|
@ -2434,3 +2496,629 @@ fn msn_screen_rejects_invalid_totals_without_populating_cache() {
|
|||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A command on the fixture-backed mock provider, never the network.
|
||||
fn mock_bin(name: &str) -> Command {
|
||||
let mut cmd = test_bin(name);
|
||||
cmd.env("IDX_USE_MOCK_PROVIDER", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn mock_bin_with_root(root: &Path) -> Command {
|
||||
let mut cmd = bin_with_root(root);
|
||||
cmd.env("IDX_USE_MOCK_PROVIDER", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_json_names_brokers_and_keeps_source_totals() {
|
||||
let summary = run_success_json(mock_bin("broker-json").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"bbca",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
assert_eq!(summary["symbol"], "BBCA.JK");
|
||||
assert_eq!(
|
||||
(summary["from"].as_str(), summary["to"].as_str()),
|
||||
(Some("2026-10-16"), Some("2026-10-16"))
|
||||
);
|
||||
assert_eq!(summary["buyers"].as_array().unwrap().len(), 10);
|
||||
let top = &summary["buyers"][0];
|
||||
assert_eq!(top["code"], "MG");
|
||||
assert_eq!(top["name"], "SEMESTA INDOVEST SEKURITAS");
|
||||
assert_eq!(top["kind"], "local");
|
||||
assert_eq!(top["lots"], 1_600_000);
|
||||
assert_eq!(top["value"], 841_200_000_000_i64);
|
||||
assert_eq!(top["average_price"], 5_191);
|
||||
assert_eq!(summary["sellers"][0]["kind"], "state_owned");
|
||||
assert_eq!(summary["total_value"], 6_600_000_000_000_i64);
|
||||
assert_eq!(summary["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
assert_eq!(summary["rounded"], true);
|
||||
|
||||
// The source does not filter its foreign net figure, so a filtered
|
||||
// summary leaves it out rather than passing it off as filtered.
|
||||
let filtered = run_success_json(mock_bin("broker-filtered").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
"--board",
|
||||
"regular",
|
||||
]));
|
||||
assert_eq!(filtered["investor"], "foreign");
|
||||
assert_eq!(filtered["board"], "regular");
|
||||
assert_eq!(filtered["buyers"][0]["code"], "KZ");
|
||||
for field in [
|
||||
"total_value",
|
||||
"total_lots",
|
||||
"average_price",
|
||||
"sell_value",
|
||||
"foreign_net_value",
|
||||
] {
|
||||
assert!(filtered[field].is_null(), "{field}: {filtered}");
|
||||
}
|
||||
assert_eq!(filtered["buy_value"], 1_600_000_000_000_i64);
|
||||
let table = run_success_stdout(mock_bin("broker-filtered-table").args([
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
"--board",
|
||||
"regular",
|
||||
]));
|
||||
assert!(!table.contains("Total "), "{table}");
|
||||
assert!(
|
||||
table.contains("Foreign investors bought 1.6T IDR on the regular board"),
|
||||
"{table}"
|
||||
);
|
||||
|
||||
// On all boards, the foreign net gives foreign selling.
|
||||
let foreign = run_success_json(mock_bin("broker-foreign").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
]));
|
||||
assert!(foreign["total_value"].is_null());
|
||||
assert_eq!(foreign["buy_value"], 1_700_000_000_000_i64);
|
||||
assert_eq!(foreign["sell_value"], 4_000_000_000_000_i64);
|
||||
assert_eq!(foreign["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_without_dates_finds_the_latest_session_offline() {
|
||||
let root = test_env_dir("broker-latest");
|
||||
run_success_json(mock_bin_with_root(&root).args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
// Monday 2026-10-19 is not cached and the weekend is skipped, so the
|
||||
// offline lookup lands on Friday's cached session.
|
||||
let latest = run_success_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-19")
|
||||
.args(["-o", "json", "--offline", "stocks", "broker", "BBCA"]),
|
||||
);
|
||||
assert_eq!(latest["from"], "2026-10-16");
|
||||
assert_eq!(latest["buyers"][0]["code"], "MG");
|
||||
run_error_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-19")
|
||||
.args(["-o", "json", "--offline", "stocks", "broker", "BBRI"]),
|
||||
"CACHEMISS",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_session_without_trades_is_empty_not_an_error() {
|
||||
let summary = run_success_json(mock_bin("broker-empty").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-17",
|
||||
]));
|
||||
assert_eq!(summary["buyers"], serde_json::json!([]));
|
||||
assert_eq!(summary["total_value"], 0);
|
||||
assert!(summary["average_price"].is_null());
|
||||
|
||||
let table = run_success_stdout(mock_bin("broker-empty-table").args([
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-17",
|
||||
]));
|
||||
assert!(table.contains("No trades."), "{table}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_errors_follow_the_output_contract() {
|
||||
for (args, code) in [
|
||||
(vec!["ZZZZ", "--date", "2026-10-16"], "SYMBOLNOTFOUND"),
|
||||
(vec!["BBCA.US", "--date", "2026-10-16"], "UNSUPPORTED"),
|
||||
(vec!["BBCA", "--date", "16/10/2026"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["BBCA", "--from", "2026-10-16", "--to", "2026-10-12"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec!["BBCA", "--to", "2026-10-16"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["BBCA", "--date", "2026-10-16", "--from", "2026-10-12"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec!["BBCA", "--date", "2999-01-01"], "INVALIDINPUT"),
|
||||
] {
|
||||
let mut cmd = mock_bin("broker-errors");
|
||||
cmd.args(["-o", "json", "stocks", "broker"]).args(&args);
|
||||
run_error_json(&mut cmd, code);
|
||||
}
|
||||
let mut failing = mock_bin("broker-provider-error");
|
||||
failing.env("IDX_MOCK_ERROR", "1").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]);
|
||||
run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandar_reads_the_broker_summary_tiers() {
|
||||
let reports = run_success_json(mock_bin("bandar-json").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
let report = &reports[0];
|
||||
assert_eq!(reports.as_array().unwrap().len(), 1);
|
||||
assert_eq!(report["symbol"], "BBCA.JK");
|
||||
assert_eq!(report["method"], "top_concentration_v1");
|
||||
let tiers: Vec<i64> = report["tiers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tier| tier["top"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(tiers, [1, 3, 5, 10]);
|
||||
// Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded.
|
||||
let top5 = &report["tiers"][2];
|
||||
assert_eq!(top5["buy_value"], 2_939_500_000_000_i64);
|
||||
assert_eq!(top5["sell_value"], 4_540_400_000_000_i64);
|
||||
assert_eq!(top5["imbalance_bps"], -2425);
|
||||
assert_eq!(report["top"], 5);
|
||||
assert_eq!(report["imbalance_bps"], -2425);
|
||||
assert_eq!(report["signal"], "big_distribution");
|
||||
assert_eq!(
|
||||
report["both_sides"],
|
||||
serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() {
|
||||
let output = mock_bin("bandar-screen")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA,ZZZZ",
|
||||
"TLKM",
|
||||
"BBRI",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--top",
|
||||
"3",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let reports: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let ranked: Vec<(&str, i64)> = reports
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r["symbol"].as_str().unwrap(),
|
||||
r["imbalance_bps"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ranked.len(), 3);
|
||||
assert!(
|
||||
ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1),
|
||||
"{ranked:?}"
|
||||
);
|
||||
assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3));
|
||||
let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line");
|
||||
assert_eq!(warning["level"], "warning");
|
||||
assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK"));
|
||||
|
||||
// A one-symbol screen that loses a symbol still prints the screen table.
|
||||
let table = run_success_stdout(mock_bin("bandar-screen-table").args([
|
||||
"--no-color",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA,ZZZZ",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
assert!(table.contains("TOP 5 NET %"), "{table}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandar_errors_follow_the_output_contract() {
|
||||
let many: Vec<String> = (0..101).map(|i| format!("S{i}")).collect();
|
||||
for (args, code) in [
|
||||
(
|
||||
vec!["BBCA".to_string(), "--top".into(), "4".into()],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec![many.join(",")], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()],
|
||||
"SYMBOLNOTFOUND",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"BBCA,BBRI".to_string(),
|
||||
"--date".into(),
|
||||
"2999-01-01".into(),
|
||||
],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"BBCA".to_string(),
|
||||
"--date".into(),
|
||||
"2026-10-16".into(),
|
||||
"--investor".into(),
|
||||
"foreign".into(),
|
||||
"--board".into(),
|
||||
"regular".into(),
|
||||
],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
] {
|
||||
let mut cmd = mock_bin("bandar-errors");
|
||||
cmd.args(["-o", "json", "stocks", "bandar"]).args(&args);
|
||||
run_error_json(&mut cmd, code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_flow_from_idx_reads_sessions_oldest_first() {
|
||||
let flow = run_success_json(
|
||||
mock_bin("flow-json")
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o", "json", "stocks", "flow", "BBCA", "--days", "3", "--source", "idx",
|
||||
]),
|
||||
);
|
||||
assert_eq!(flow["symbol"], "BBCA.JK");
|
||||
let dates: Vec<&str> = flow["days"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|day| day["date"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(dates, ["2026-10-14", "2026-10-15", "2026-10-16"]);
|
||||
let last = &flow["days"][2];
|
||||
assert_eq!(last["close"], 5175);
|
||||
assert_eq!(last["change_bps"], -1481);
|
||||
assert_eq!(last["foreign_net"], 309_392_800_i64 - 757_227_900_i64);
|
||||
assert_eq!(flow["from"], "2026-10-14");
|
||||
|
||||
// A range skips the weekend and the Saturday without trading.
|
||||
let range = run_success_json(
|
||||
mock_bin("flow-range")
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"flow",
|
||||
"--source",
|
||||
"idx",
|
||||
"BBCA",
|
||||
"--from",
|
||||
"2026-10-12",
|
||||
"--to",
|
||||
"2026-10-17",
|
||||
]),
|
||||
);
|
||||
assert_eq!(range["days"].as_array().unwrap().len(), 5);
|
||||
let net: i64 = range["days"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|day| day["foreign_net"].as_i64().unwrap())
|
||||
.sum();
|
||||
assert_eq!(range["foreign_net"], net);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_flow_from_idx_offline_uses_cached_sessions_only() {
|
||||
let root = test_env_dir("flow-offline");
|
||||
run_success_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"flow",
|
||||
"--source",
|
||||
"idx",
|
||||
"BBCA",
|
||||
"--from",
|
||||
"2026-10-15",
|
||||
"--to",
|
||||
"2026-10-16",
|
||||
]),
|
||||
);
|
||||
// Another stock's flow for the same sessions needs no new request.
|
||||
let offline = run_success_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"--offline",
|
||||
"stocks",
|
||||
"flow",
|
||||
"--source",
|
||||
"idx",
|
||||
"TLKM",
|
||||
"--days",
|
||||
"5",
|
||||
]),
|
||||
);
|
||||
assert_eq!(offline["from"], "2026-10-15");
|
||||
assert_eq!(offline["days"].as_array().unwrap().len(), 2);
|
||||
run_error_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"--offline",
|
||||
"stocks",
|
||||
"flow",
|
||||
"--source",
|
||||
"idx",
|
||||
"TLKM",
|
||||
"--from",
|
||||
"2026-10-12",
|
||||
"--to",
|
||||
"2026-10-16",
|
||||
]),
|
||||
"CACHEMISS",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_flow_from_ipot_by_default_gives_values_across_all_boards() {
|
||||
let flow = run_success_json(
|
||||
mock_bin("flow-ipot")
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"flow",
|
||||
"BBCA",
|
||||
"--from",
|
||||
"2026-10-12",
|
||||
"--to",
|
||||
"2026-10-17",
|
||||
]),
|
||||
);
|
||||
assert_eq!(flow["source"], "ipot");
|
||||
assert_eq!(flow["rounded"], true);
|
||||
let days = flow["days"].as_array().unwrap();
|
||||
assert_eq!(days.len(), 5, "the Saturday had no trading");
|
||||
let last = &days[4];
|
||||
assert_eq!(last["date"], "2026-10-16");
|
||||
assert_eq!(last["value"], 6_600_000_000_000_i64);
|
||||
assert_eq!(last["foreign_buy_value"], 1_700_000_000_000_i64);
|
||||
assert_eq!(last["foreign_sell_value"], 4_000_000_000_000_i64);
|
||||
assert_eq!(last["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
for field in ["close", "foreign_buy", "foreign_net", "non_regular_bps"] {
|
||||
assert!(last[field].is_null(), "{field}");
|
||||
}
|
||||
let net: i64 = days
|
||||
.iter()
|
||||
.map(|day| day["foreign_net_value"].as_i64().unwrap())
|
||||
.sum();
|
||||
assert_eq!(flow["foreign_net_value"], net);
|
||||
assert!(flow["foreign_net"].is_null());
|
||||
|
||||
let table = run_success_stdout(
|
||||
mock_bin("flow-ipot-table")
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args(["stocks", "flow", "BBCA", "--days", "1"]),
|
||||
);
|
||||
assert!(table.contains("(1 session, IPOT)"), "{table}");
|
||||
assert!(table.contains("FOREIGN %"), "{table}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_flow_errors_follow_the_output_contract() {
|
||||
for (args, code) in [
|
||||
(vec!["ZZZZ", "--days", "2"], "SYMBOLNOTFOUND"),
|
||||
(vec!["BBCA.US"], "UNSUPPORTED"),
|
||||
(vec!["BBCA", "--days", "61"], "INVALIDINPUT"),
|
||||
(vec!["BBCA", "--days", "0"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["BBCA", "--days", "3", "--from", "2026-10-12"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(
|
||||
vec!["BBCA", "--from", "2026-06-01", "--to", "2026-10-16"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec!["BBCA", "--source", "stockbit"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["ZZZZ", "--days", "2", "--source", "idx"],
|
||||
"SYMBOLNOTFOUND",
|
||||
),
|
||||
] {
|
||||
let mut cmd = mock_bin("flow-errors");
|
||||
cmd.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args(["-o", "json", "stocks", "flow"])
|
||||
.args(&args);
|
||||
run_error_json(&mut cmd, code);
|
||||
}
|
||||
let mut failing = mock_bin("flow-provider-error");
|
||||
failing
|
||||
.env("IDX_MOCK_ERROR", "1")
|
||||
.env("IDX_MOCK_TODAY", "2026-10-18")
|
||||
.args(["-o", "json", "stocks", "flow", "BBCA"]);
|
||||
run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandar_reads_one_investor_side_against_its_own_trading() {
|
||||
let reports = run_success_json(mock_bin("bandar-foreign").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
]));
|
||||
let report = &reports[0];
|
||||
assert_eq!(report["investor"], "foreign");
|
||||
assert_eq!(report["buy_value"], 1_700_000_000_000_i64);
|
||||
assert_eq!(report["sell_value"], 4_000_000_000_000_i64);
|
||||
let top5 = &report["tiers"][2];
|
||||
// Shares of foreign buying and of foreign selling, each within 100%.
|
||||
let (buy, sell) = (
|
||||
top5["buy_share_bps"].as_i64().unwrap(),
|
||||
top5["sell_share_bps"].as_i64().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
(0..=10_000).contains(&buy) && (0..=10_000).contains(&sell),
|
||||
"{top5}"
|
||||
);
|
||||
assert!((top5["imbalance_bps"].as_i64().unwrap() - (buy - sell)).abs() <= 1);
|
||||
assert!(report["signal"].is_string());
|
||||
|
||||
let table = run_success_stdout(mock_bin("bandar-foreign-table").args([
|
||||
"--no-color",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
]));
|
||||
assert!(
|
||||
table.contains("Foreign investors bought 1.7T IDR and sold 4.0T IDR (net -2.3T)."),
|
||||
"{table}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() {
|
||||
// A summary cached before buy_value/sell_value existed: fresh for a
|
||||
// week, with trades, but no denominators for a reading.
|
||||
let root = test_env_dir("bandar-legacy-cache");
|
||||
let bucket = root.join("cache").join("idx").join("ipot-broker");
|
||||
fs::create_dir_all(&bucket).unwrap();
|
||||
let flow = |code: &str, value: i64| {
|
||||
serde_json::json!({"rank": 1, "code": code, "name": null, "kind": null,
|
||||
"lots": value / 100, "value": value, "average_price": 1})
|
||||
};
|
||||
let legacy = serde_json::json!({
|
||||
"fetched_at": chrono::Utc::now().to_rfc3339(),
|
||||
"ttl_secs": 7 * 24 * 3600,
|
||||
"schema_version": 1,
|
||||
"data": {
|
||||
"symbol": "BBCA.JK", "from": "2026-10-16", "to": "2026-10-16",
|
||||
"investor": "all", "board": "all",
|
||||
"buyers": [flow("AA", 60_000)], "sellers": [flow("BB", 40_000)],
|
||||
"total_value": 100_000, "total_lots": 1_000, "average_price": 100,
|
||||
"foreign_net_value": null, "rounded": false
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
bucket.join("BBCA-2026-10-16-2026-10-16-all-all.json"),
|
||||
legacy.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Offline, the old entry is not served as a complete summary.
|
||||
run_error_json(
|
||||
mock_bin_with_root(&root).args([
|
||||
"-o",
|
||||
"json",
|
||||
"--offline",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]),
|
||||
"CACHEMISS",
|
||||
);
|
||||
// Online, the summary is fetched again and read in full.
|
||||
let reports = run_success_json(mock_bin_with_root(&root).args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
assert_eq!(reports[0]["imbalance_bps"], -2425);
|
||||
let table = run_success_stdout(mock_bin_with_root(&root).args([
|
||||
"--no-color",
|
||||
"stocks",
|
||||
"bandar",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
assert!(!table.contains("No trades."), "{table}");
|
||||
assert!(table.contains("big distribution"), "{table}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -244,6 +244,48 @@ name = "yahoo-fundamental"
|
|||
args = ["stocks", "fundamental", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker"
|
||||
args = ["stocks", "broker", "BBCA", "--date", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker-range"
|
||||
args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker-no-trades"
|
||||
args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-bandar"
|
||||
args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-bandar-screen"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-flow"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-flow-idx"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16", "--source", "idx"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-flow-idx-latest"
|
||||
args = ["stocks", "flow", "TLKM", "--days", "3", "--source", "idx"]
|
||||
env = { IDX_MOCK_TODAY = "2026-10-18" }
|
||||
|
||||
[[case]]
|
||||
name = "error-broker-unknown-symbol"
|
||||
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
||||
|
||||
# --- errors ---------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
|
|
|
|||
|
|
@ -174,6 +174,12 @@ _idx() {
|
|||
idx__help__ownership__resolve,unresolved)
|
||||
cmd="idx__help__ownership__resolve__unresolved"
|
||||
;;
|
||||
idx__help__stocks,bandar)
|
||||
cmd="idx__help__stocks__bandar"
|
||||
;;
|
||||
idx__help__stocks,broker)
|
||||
cmd="idx__help__stocks__broker"
|
||||
;;
|
||||
idx__help__stocks,compare)
|
||||
cmd="idx__help__stocks__compare"
|
||||
;;
|
||||
|
|
@ -183,6 +189,9 @@ _idx() {
|
|||
idx__help__stocks,financials)
|
||||
cmd="idx__help__stocks__financials"
|
||||
;;
|
||||
idx__help__stocks,flow)
|
||||
cmd="idx__help__stocks__flow"
|
||||
;;
|
||||
idx__help__stocks,fundamental)
|
||||
cmd="idx__help__stocks__fundamental"
|
||||
;;
|
||||
|
|
@ -336,6 +345,12 @@ _idx() {
|
|||
idx__ownership__resolve__help,unresolved)
|
||||
cmd="idx__ownership__resolve__help__unresolved"
|
||||
;;
|
||||
idx__stocks,bandar)
|
||||
cmd="idx__stocks__bandar"
|
||||
;;
|
||||
idx__stocks,broker)
|
||||
cmd="idx__stocks__broker"
|
||||
;;
|
||||
idx__stocks,compare)
|
||||
cmd="idx__stocks__compare"
|
||||
;;
|
||||
|
|
@ -345,6 +360,9 @@ _idx() {
|
|||
idx__stocks,financials)
|
||||
cmd="idx__stocks__financials"
|
||||
;;
|
||||
idx__stocks,flow)
|
||||
cmd="idx__stocks__flow"
|
||||
;;
|
||||
idx__stocks,fundamental)
|
||||
cmd="idx__stocks__fundamental"
|
||||
;;
|
||||
|
|
@ -384,6 +402,12 @@ _idx() {
|
|||
idx__stocks,valuation)
|
||||
cmd="idx__stocks__valuation"
|
||||
;;
|
||||
idx__stocks__help,bandar)
|
||||
cmd="idx__stocks__help__bandar"
|
||||
;;
|
||||
idx__stocks__help,broker)
|
||||
cmd="idx__stocks__help__broker"
|
||||
;;
|
||||
idx__stocks__help,compare)
|
||||
cmd="idx__stocks__help__compare"
|
||||
;;
|
||||
|
|
@ -393,6 +417,9 @@ _idx() {
|
|||
idx__stocks__help,financials)
|
||||
cmd="idx__stocks__help__financials"
|
||||
;;
|
||||
idx__stocks__help,flow)
|
||||
cmd="idx__stocks__help__flow"
|
||||
;;
|
||||
idx__stocks__help,fundamental)
|
||||
cmd="idx__stocks__help__fundamental"
|
||||
;;
|
||||
|
|
@ -1191,7 +1218,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__help__stocks)
|
||||
opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare"
|
||||
opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1204,6 +1231,34 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__bandar)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__broker)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__compare)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -1246,6 +1301,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__flow)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__fundamental)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -2193,7 +2262,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__stocks)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help"
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2214,6 +2283,94 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__bandar)
|
||||
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--date)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--from)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--to)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--investor)
|
||||
COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--board)
|
||||
COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--top)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__broker)
|
||||
opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--date)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--from)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--to)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--investor)
|
||||
COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--board)
|
||||
COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__compare)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
|
|
@ -2284,6 +2441,44 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__flow)
|
||||
opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --offline --no-cache --help <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
|
||||
;;
|
||||
--source)
|
||||
COMPREPLY=($(compgen -W "ipot idx" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__fundamental)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
|
|
@ -2329,7 +2524,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__stocks__help)
|
||||
opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help"
|
||||
opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2342,6 +2537,34 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__bandar)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__broker)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__compare)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -2384,6 +2607,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__flow)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__fundamental)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: symbol not found: ZZZZ
|
||||
|
|
@ -12,6 +12,9 @@ Commands:
|
|||
valuation Run valuation analysis on a stock
|
||||
risk Run risk analysis on a stock
|
||||
fundamental Run full fundamental analysis on a stock
|
||||
broker Which brokers bought and sold a stock
|
||||
bandar Bandarmology: is buying more concentrated than selling
|
||||
flow Foreign buying and selling of a stock, session by session
|
||||
profile Get company profile
|
||||
financials Get financial statements
|
||||
earnings Get earnings report
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true}
|
||||
{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true}
|
||||
{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐
|
||||
│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │
|
||||
╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡
|
||||
│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │
|
||||
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │
|
||||
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │
|
||||
└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘
|
||||
Method top_concentration_v1: the top 3 buyers' share of the value bought minus the top 3 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/stocks-bandar.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-bandar.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
|
||||
--- stderr
|
||||
20
crates/idx-cli/tests/golden/stocks-bandar.table.golden
Normal file
20
crates/idx-cli/tests/golden/stocks-bandar.table.golden
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Bandar reading for BBCA.JK, 2026-10-16
|
||||
Signal (top 5): big distribution, -24.2% of value
|
||||
┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐
|
||||
│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │
|
||||
╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡
|
||||
│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │
|
||||
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │
|
||||
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.2% ┆ 5,211 ┆ 5,229 ┆ big distribution │
|
||||
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │
|
||||
└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘
|
||||
Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.8% of value).
|
||||
On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded.
|
||||
Method top_concentration_v1: the top 5 buyers' share of the value bought minus the top 5 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"buy_value":0,"sell_value":0,"foreign_net_value":0,"rounded":false}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-17
|
||||
No trades.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"buy_value":9900000000000,"sell_value":9900000000000,"foreign_net_value":-4100000000000,"rounded":true}
|
||||
--- stderr
|
||||
32
crates/idx-cli/tests/golden/stocks-broker-range.table.golden
Normal file
32
crates/idx-cli/tests/golden/stocks-broker-range.table.golden
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-12 to 2026-10-16
|
||||
┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐
|
||||
│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │
|
||||
╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡
|
||||
│ 1 ┆ KZ ┆ 1.7M ┆ 963.0B ┆ 5,573 ┆ CC ┆ 5.1M ┆ 2.7T ┆ 5,336 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ MG ┆ 1.6M ┆ 842.0B ┆ 5,192 ┆ RX ┆ 1.8M ┆ 928.5B ┆ 5,295 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ ZP ┆ 1.6M ┆ 900.9B ┆ 5,780 ┆ ZP ┆ 1.6M ┆ 942.1B ┆ 5,756 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ YU ┆ 1.4M ┆ 781.7B ┆ 5,626 ┆ MG ┆ 1.5M ┆ 800.5B ┆ 5,239 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ CC ┆ 1.4M ┆ 737.9B ┆ 5,408 ┆ AK ┆ 1.4M ┆ 786.5B ┆ 5,785 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 6 ┆ XL ┆ 1.3M ┆ 687.2B ┆ 5,313 ┆ BB ┆ 852,704 ┆ 457.7B ┆ 5,368 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 7 ┆ AK ┆ 1.1M ┆ 643.9B ┆ 5,771 ┆ YU ┆ 830,940 ┆ 466.7B ┆ 5,617 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 8 ┆ SQ ┆ 973,921 ┆ 519.4B ┆ 5,333 ┆ BK ┆ 709,658 ┆ 393.3B ┆ 5,542 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 9 ┆ YP ┆ 919,655 ┆ 487.1B ┆ 5,296 ┆ KZ ┆ 621,425 ┆ 363.3B ┆ 5,847 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ PD ┆ 646,162 ┆ 341.2B ┆ 5,280 ┆ XL ┆ 441,903 ┆ 242.6B ┆ 5,490 │
|
||||
└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘
|
||||
Total 9.9T IDR, 18.1M lots, average 5,471; foreign net -4.1T.
|
||||
Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP.
|
||||
State-owned brokers: CC.
|
||||
Buyers and sellers are each ranked by lots; a broker can be on both sides.
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/stocks-broker.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-broker.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"buy_value":6600000000000,"sell_value":6600000000000,"foreign_net_value":-2300000000000,"rounded":true}
|
||||
--- stderr
|
||||
32
crates/idx-cli/tests/golden/stocks-broker.table.golden
Normal file
32
crates/idx-cli/tests/golden/stocks-broker.table.golden
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-16
|
||||
┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐
|
||||
│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │
|
||||
╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡
|
||||
│ 1 ┆ MG ┆ 1.6M ┆ 841.2B ┆ 5,191 ┆ CC ┆ 4.3M ┆ 2.2T ┆ 5,211 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ XL ┆ 1.1M ┆ 600.0B ┆ 5,229 ┆ RX ┆ 1.7M ┆ 908.2B ┆ 5,282 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ CC ┆ 1.0M ┆ 543.5B ┆ 5,227 ┆ MG ┆ 1.5M ┆ 799.4B ┆ 5,238 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ KZ ┆ 1.0M ┆ 522.4B ┆ 5,198 ┆ BB ┆ 687,067 ┆ 357.4B ┆ 5,202 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ SQ ┆ 828,163 ┆ 432.4B ┆ 5,221 ┆ ZP ┆ 529,223 ┆ 275.4B ┆ 5,203 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 6 ┆ YP ┆ 827,340 ┆ 431.7B ┆ 5,218 ┆ AK ┆ 444,696 ┆ 233.5B ┆ 5,251 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 7 ┆ YU ┆ 687,603 ┆ 356.6B ┆ 5,186 ┆ BK ┆ 419,266 ┆ 218.8B ┆ 5,219 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 8 ┆ PD ┆ 592,667 ┆ 309.2B ┆ 5,218 ┆ YU ┆ 418,986 ┆ 218.0B ┆ 5,202 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 9 ┆ ZP ┆ 441,000 ┆ 230.2B ┆ 5,220 ┆ NI ┆ 357,177 ┆ 186.1B ┆ 5,212 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ AK ┆ 398,474 ┆ 209.9B ┆ 5,268 ┆ XL ┆ 296,269 ┆ 155.0B ┆ 5,232 │
|
||||
└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘
|
||||
Total 6.6T IDR, 12.6M lots, average 5,228; foreign net -2.3T.
|
||||
Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP.
|
||||
State-owned brokers: CC, NI.
|
||||
Buyers and sellers are each ranked by lots; a broker can be on both sides.
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"TLKM.JK","source":"idx","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_buy_value":142986543253,"foreign_sell_value":137952652523,"foreign_net_value":5033890729,"foreign_share_bps":7104,"non_regular_bps":979},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_buy_value":215684085637,"foreign_sell_value":233704878341,"foreign_net_value":-18020792704,"foreign_share_bps":6742,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_buy_value":1307354023123,"foreign_sell_value":1400322657713,"foreign_net_value":-92968634589,"foreign_share_bps":7297,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_buy_value":1666024652013,"foreign_sell_value":1771980188577,"foreign_net_value":-105955536564,"foreign_share_bps":7213,"rounded":false}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions, IDX)
|
||||
┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐
|
||||
│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │
|
||||
╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡
|
||||
│ 2026-10-14 ┆ 2,250 ┆ +0.5% ┆ 197.7B ┆ 63.0M ┆ 60.8M ┆ +2.2M ┆ +5.0B ┆ 9.8% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-15 ┆ 2,230 ┆ -0.9% ┆ 333.3B ┆ 97.4M ┆ 105.5M ┆ -8.1M ┆ -18.0B ┆ 0.8% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-16 ┆ 1,900 ┆ -14.8% ┆ 1.9T ┆ 679.0M ┆ 727.3M ┆ -48.3M ┆ -93.0B ┆ 14.8% │
|
||||
└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘
|
||||
Net foreign: -54.2M shares, about -106.0B IDR; foreign investors made up 72.1% of trading.
|
||||
Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash).
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/stocks-flow-idx.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-flow-idx.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false}
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false}
|
||||
--- stderr
|
||||
19
crates/idx-cli/tests/golden/stocks-flow-idx.table.golden
Normal file
19
crates/idx-cli/tests/golden/stocks-flow-idx.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, IDX)
|
||||
┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐
|
||||
│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │
|
||||
╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡
|
||||
│ 2026-10-12 ┆ 5,950 ┆ -1.6% ┆ 586.5B ┆ 51.5M ┆ 60.9M ┆ -9.4M ┆ -56.2B ┆ 0.1% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 24.1% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 10.5% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.5% │
|
||||
└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘
|
||||
Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading.
|
||||
Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash).
|
||||
--- stderr
|
||||
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","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true}
|
||||
--- stderr
|
||||
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","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true}
|
||||
--- stderr
|
||||
20
crates/idx-cli/tests/golden/stocks-flow.table.golden
Normal file
20
crates/idx-cli/tests/golden/stocks-flow.table.golden
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions, IPOT)
|
||||
┌────────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┐
|
||||
│ DATE ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ FOREIGN % │
|
||||
╞════════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╡
|
||||
│ 2026-10-12 ┆ 587.1B ┆ 308.7B ┆ 364.6B ┆ -55.9B ┆ 57.3% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-13 ┆ 738.2B ┆ 537.3B ┆ 507.2B ┆ +30.1B ┆ 70.8% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-14 ┆ 1.1T ┆ 991.8B ┆ 775.2B ┆ +216.6B ┆ 80.3% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-15 ┆ 912.0B ┆ 826.9B ┆ 540.8B ┆ +286.1B ┆ 75.0% │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-10-16 ┆ 6.6T ┆ 1.7T ┆ 4.0T ┆ -2.3T ┆ 43.2% │
|
||||
└────────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┘
|
||||
Foreign investors bought 4.4T IDR and sold 6.2T IDR: net -1.8T; they made up 53.1% of trading.
|
||||
Values in IDR across all boards. For exact share counts, the close and the non-regular share, use --source idx (needs curl-impersonate).
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}
|
||||
{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}
|
||||
{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}
|
||||
{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ rusqlite = { workspace = true, optional = true }
|
|||
quick-xml = { version = "0.37", optional = true }
|
||||
sha2 = { workspace = true, optional = true }
|
||||
zip = { workspace = true, optional = true }
|
||||
scraper = { version = "0.27", default-features = false }
|
||||
|
|
|
|||
466
crates/idx-core/src/analysis/bandar.rs
Normal file
466
crates/idx-core/src/analysis/bandar.rs
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
//! Bandarmology over a broker summary: is buying more concentrated in a few
|
||||
//! brokers than selling? No network; see docs/ARCHITECTURE.md.
|
||||
//!
|
||||
//! The summary lists the top 10 buyers and the top 10 sellers by gross lots.
|
||||
//! For N in 1, 3, 5 and 10 the top N buyers' share of the value bought is set
|
||||
//! against the top N sellers' share of the value sold. For all investors both
|
||||
//! are the value traded, so this is the top N's buy value less their sell
|
||||
//! value as a share of it; for one investor side (foreign or domestic), each
|
||||
//! is that side's own buying or selling.
|
||||
//!
|
||||
//! | top-N buy share - sell share, % | signal |
|
||||
//! |------------------------------------|--------------------|
|
||||
//! | >= +15 | `big_accumulation` |
|
||||
//! | +5 to +15 | `accumulation` |
|
||||
//! | within +-5 | `neutral` |
|
||||
//! | -15 to -5 | `distribution` |
|
||||
//! | <= -15 | `big_distribution` |
|
||||
//!
|
||||
//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most
|
||||
//! traded IDX stocks in October 2026. They are heuristics: a broker code is
|
||||
//! not an investor, and gross figures overstate a broker that both bought
|
||||
//! and sold (listed in `both_sides`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary};
|
||||
use crate::error::IdxError;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// Names the rules below; bumped when they change.
|
||||
pub const METHOD: &str = "top_concentration_v1";
|
||||
/// Tiers every report carries.
|
||||
pub const TIERS: [u8; 4] = [1, 3, 5, 10];
|
||||
/// Tier behind the headline signal unless asked otherwise.
|
||||
pub const DEFAULT_TOP: u8 = 5;
|
||||
/// Imbalance, in basis points of value traded, from which a tier reads
|
||||
/// accumulation (or, negative, distribution).
|
||||
pub const ACCUMULATION_BPS: i64 = 500;
|
||||
/// Imbalance from which the reading is big.
|
||||
pub const BIG_BPS: i64 = 1500;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BandarSignal {
|
||||
BigAccumulation,
|
||||
Accumulation,
|
||||
Neutral,
|
||||
Distribution,
|
||||
BigDistribution,
|
||||
}
|
||||
|
||||
impl BandarSignal {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::BigAccumulation => "big_accumulation",
|
||||
Self::Accumulation => "accumulation",
|
||||
Self::Neutral => "neutral",
|
||||
Self::Distribution => "distribution",
|
||||
Self::BigDistribution => "big_distribution",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_imbalance_bps(bps: i64) -> Self {
|
||||
match bps {
|
||||
b if b >= BIG_BPS => Self::BigAccumulation,
|
||||
b if b >= ACCUMULATION_BPS => Self::Accumulation,
|
||||
b if b <= -BIG_BPS => Self::BigDistribution,
|
||||
b if b <= -ACCUMULATION_BPS => Self::Distribution,
|
||||
_ => Self::Neutral,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The top N buyers against the top N sellers. Every figure is `None` when
|
||||
/// a broker in the tier lacks it, or when nothing traded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BandarTier {
|
||||
pub top: u8,
|
||||
/// Value the top N buyers bought, whole IDR.
|
||||
pub buy_value: Option<i64>,
|
||||
/// Value the top N sellers sold, whole IDR.
|
||||
pub sell_value: Option<i64>,
|
||||
/// `buy_value - sell_value`, whole IDR.
|
||||
pub imbalance_value: Option<i64>,
|
||||
/// `buy_share_bps - sell_share_bps`, computed exactly: for all investors,
|
||||
/// `imbalance_value` in basis points of the value traded.
|
||||
pub imbalance_bps: Option<i64>,
|
||||
/// The top N buyers' share of the value bought, basis points.
|
||||
pub buy_share_bps: Option<i64>,
|
||||
/// The top N sellers' share of the value sold, basis points.
|
||||
pub sell_share_bps: Option<i64>,
|
||||
/// Volume-weighted average price the top N buyers paid, whole IDR.
|
||||
pub buy_average: Option<i64>,
|
||||
/// Volume-weighted average price the top N sellers got, whole IDR.
|
||||
pub sell_average: Option<i64>,
|
||||
pub signal: Option<BandarSignal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BandarReport {
|
||||
pub symbol: String,
|
||||
pub from: NaiveDate,
|
||||
pub to: NaiveDate,
|
||||
pub investor: BrokerInvestor,
|
||||
pub board: BrokerBoard,
|
||||
/// The rules applied, e.g. `top_concentration_v1`.
|
||||
pub method: String,
|
||||
/// Tier behind the headline `signal`.
|
||||
pub top: u8,
|
||||
/// The headline tier's signal; `None` when nothing traded or a figure
|
||||
/// is missing.
|
||||
pub signal: Option<BandarSignal>,
|
||||
/// The headline tier's imbalance, basis points of value traded.
|
||||
pub imbalance_bps: Option<i64>,
|
||||
pub tiers: Vec<BandarTier>,
|
||||
/// Value traded over the range, whole IDR; `None` for one investor side.
|
||||
pub total_value: Option<i64>,
|
||||
/// Value the selected investors bought, whole IDR.
|
||||
pub buy_value: Option<i64>,
|
||||
/// Value the selected investors sold, whole IDR.
|
||||
pub sell_value: Option<i64>,
|
||||
/// Average traded price, whole IDR.
|
||||
pub average_price: Option<i64>,
|
||||
/// Foreign net value, whole IDR; `None` for a filtered summary.
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// `foreign_net_value` in basis points of the value traded.
|
||||
pub foreign_net_bps: Option<i64>,
|
||||
/// Brokers among the top 10 on both sides, whose gross figures overstate
|
||||
/// which way they traded.
|
||||
pub both_sides: Vec<String>,
|
||||
/// The source rounded figures of a million and more to one decimal.
|
||||
pub rounded: bool,
|
||||
}
|
||||
|
||||
/// The headline tier must be one the report carries.
|
||||
pub fn check_top(top: u8) -> Result<u8, IdxError> {
|
||||
if TIERS.contains(&top) {
|
||||
Ok(top)
|
||||
} else {
|
||||
Err(IdxError::InvalidInput(format!(
|
||||
"top must be 1, 3, 5 or 10 (got {top})"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `part` in basis points of `whole`, truncated toward zero so a negative
|
||||
/// reading never reaches a threshold its magnitude has not; `None` without
|
||||
/// a positive whole.
|
||||
fn bps(part: i64, whole: Option<i64>) -> Option<i64> {
|
||||
let whole = whole.filter(|whole| *whole > 0)?;
|
||||
i64::try_from(i128::from(part) * 10_000 / i128::from(whole)).ok()
|
||||
}
|
||||
|
||||
/// Sum of `field` over `flows`; `None` when any flow lacks it.
|
||||
fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option<i64>) -> Option<i64> {
|
||||
flows
|
||||
.iter()
|
||||
.try_fold(0_i64, |total, flow| total.checked_add(field(flow)?))
|
||||
}
|
||||
|
||||
/// Value over shares, the shares taken from each broker's value and its
|
||||
/// exact published average (lots of a million and more are rounded,
|
||||
/// averages never are).
|
||||
fn vwap(flows: &[BrokerFlow]) -> Option<i64> {
|
||||
let (mut value, mut shares) = (0.0_f64, 0.0_f64);
|
||||
for flow in flows {
|
||||
let (flow_value, average) = (flow.value?, flow.average_price?);
|
||||
if average <= 0 {
|
||||
return None;
|
||||
}
|
||||
value += flow_value as f64;
|
||||
shares += flow_value as f64 / average as f64;
|
||||
}
|
||||
(shares > 0.0).then(|| (value / shares).round() as i64)
|
||||
}
|
||||
|
||||
pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier {
|
||||
let n = usize::from(top);
|
||||
let buyers = &summary.buyers[..n.min(summary.buyers.len())];
|
||||
let sellers = &summary.sellers[..n.min(summary.sellers.len())];
|
||||
let buy_value = sum(buyers, |flow| flow.value);
|
||||
let sell_value = sum(sellers, |flow| flow.value);
|
||||
let imbalance_value = buy_value
|
||||
.zip(sell_value)
|
||||
.and_then(|(buy, sell)| buy.checked_sub(sell));
|
||||
let imbalance_bps = match (buy_value, sell_value, summary.buy_value, summary.sell_value) {
|
||||
(Some(buy), Some(sell), Some(bought), Some(sold)) if bought > 0 && sold > 0 => {
|
||||
// buy/bought - sell/sold over one denominator, so equal totals
|
||||
// give exactly (buy - sell) / total.
|
||||
let (buy, sell, bought, sold) = (
|
||||
i128::from(buy),
|
||||
i128::from(sell),
|
||||
i128::from(bought),
|
||||
i128::from(sold),
|
||||
);
|
||||
// Checked: past i128 there is no reading, never a wrapped one.
|
||||
buy.checked_mul(sold)
|
||||
.zip(sell.checked_mul(bought))
|
||||
.and_then(|(buys, sells)| buys.checked_sub(sells))
|
||||
.and_then(|diff| diff.checked_mul(10_000))
|
||||
.zip(bought.checked_mul(sold))
|
||||
.and_then(|(diff, denominator)| i64::try_from(diff / denominator).ok())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
BandarTier {
|
||||
top,
|
||||
buy_value,
|
||||
sell_value,
|
||||
imbalance_value,
|
||||
imbalance_bps,
|
||||
buy_share_bps: buy_value.and_then(|value| bps(value, summary.buy_value)),
|
||||
sell_share_bps: sell_value.and_then(|value| bps(value, summary.sell_value)),
|
||||
buy_average: vwap(buyers),
|
||||
sell_average: vwap(sellers),
|
||||
signal: imbalance_bps.map(BandarSignal::from_imbalance_bps),
|
||||
}
|
||||
}
|
||||
|
||||
/// The full reading of one summary, headlined by the top `top` tier (one of
|
||||
/// [`TIERS`]; see [`check_top`]).
|
||||
pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport {
|
||||
let tiers: Vec<BandarTier> = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect();
|
||||
let headline = tiers
|
||||
.iter()
|
||||
.find(|tier| tier.top == top)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| bandar_tier(summary, top));
|
||||
let mut both_sides: Vec<String> = summary
|
||||
.buyers
|
||||
.iter()
|
||||
.filter(|buyer| {
|
||||
summary
|
||||
.sellers
|
||||
.iter()
|
||||
.any(|seller| seller.code == buyer.code)
|
||||
})
|
||||
.map(|buyer| buyer.code.clone())
|
||||
.collect();
|
||||
both_sides.sort_unstable();
|
||||
BandarReport {
|
||||
symbol: summary.symbol.clone(),
|
||||
from: summary.from,
|
||||
to: summary.to,
|
||||
investor: summary.investor,
|
||||
board: summary.board,
|
||||
method: METHOD.to_string(),
|
||||
top,
|
||||
signal: headline.signal,
|
||||
imbalance_bps: headline.imbalance_bps,
|
||||
tiers,
|
||||
total_value: summary.total_value,
|
||||
buy_value: summary.buy_value,
|
||||
sell_value: summary.sell_value,
|
||||
average_price: summary.average_price,
|
||||
foreign_net_value: summary.foreign_net_value,
|
||||
foreign_net_bps: summary
|
||||
.foreign_net_value
|
||||
.and_then(|value| bps(value, summary.total_value)),
|
||||
both_sides,
|
||||
rounded: summary.rounded,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flow(code: &str, value: Option<i64>, average: i64) -> BrokerFlow {
|
||||
BrokerFlow {
|
||||
rank: 0,
|
||||
code: code.to_string(),
|
||||
name: None,
|
||||
kind: None,
|
||||
lots: value.map(|value| value / average / 100),
|
||||
value,
|
||||
average_price: Some(average),
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(buyers: Vec<BrokerFlow>, sellers: Vec<BrokerFlow>, total: i64) -> BrokerSummary {
|
||||
let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
|
||||
BrokerSummary {
|
||||
symbol: "TEST.JK".to_string(),
|
||||
from: day,
|
||||
to: day,
|
||||
investor: BrokerInvestor::All,
|
||||
board: BrokerBoard::All,
|
||||
buyers,
|
||||
sellers,
|
||||
total_value: Some(total),
|
||||
total_lots: None,
|
||||
average_price: None,
|
||||
buy_value: Some(total),
|
||||
sell_value: Some(total),
|
||||
foreign_net_value: Some(-total / 10),
|
||||
rounded: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandar_signal_thresholds_are_inclusive() {
|
||||
use BandarSignal::*;
|
||||
for (bps, signal) in [
|
||||
(1500, BigAccumulation),
|
||||
(1499, Accumulation),
|
||||
(500, Accumulation),
|
||||
(499, Neutral),
|
||||
(-499, Neutral),
|
||||
(-500, Distribution),
|
||||
(-1499, Distribution),
|
||||
(-1500, BigDistribution),
|
||||
] {
|
||||
assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() {
|
||||
let s = summary(
|
||||
vec![
|
||||
flow("AA", Some(400), 100),
|
||||
flow("BB", Some(200), 100),
|
||||
flow("CC", Some(100), 50),
|
||||
],
|
||||
vec![
|
||||
flow("DD", Some(300), 110),
|
||||
flow("AA", Some(100), 110),
|
||||
flow("EE", Some(50), 110),
|
||||
],
|
||||
1000,
|
||||
);
|
||||
let report = analyze_bandar(&s, 3);
|
||||
let top1 = &report.tiers[0];
|
||||
assert_eq!((top1.top, top1.imbalance_value), (1, Some(100)));
|
||||
assert_eq!(top1.imbalance_bps, Some(1000));
|
||||
assert_eq!(top1.signal, Some(BandarSignal::Accumulation));
|
||||
|
||||
let top3 = &report.tiers[1];
|
||||
assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450)));
|
||||
assert_eq!(
|
||||
(top3.buy_share_bps, top3.sell_share_bps),
|
||||
(Some(7000), Some(4500))
|
||||
);
|
||||
assert_eq!(top3.imbalance_bps, Some(2500));
|
||||
// 700 IDR over 4 + 2 + 2 shares.
|
||||
assert_eq!(top3.buy_average, Some(88));
|
||||
assert_eq!(top3.sell_average, Some(110));
|
||||
|
||||
// Short lists: every broker on a side is listed, so the sums hold.
|
||||
assert_eq!(report.tiers[3].imbalance_value, Some(250));
|
||||
assert_eq!(
|
||||
(report.top, report.signal),
|
||||
(3, Some(BandarSignal::BigAccumulation))
|
||||
);
|
||||
assert_eq!(report.imbalance_bps, Some(2500));
|
||||
assert_eq!(report.foreign_net_bps, Some(-1000));
|
||||
assert_eq!(report.both_sides, vec!["AA".to_string()]);
|
||||
assert_eq!(report.method, METHOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandar_tier_thresholds_are_symmetric_for_sign() {
|
||||
let reading = |buy: i64, sell: i64| {
|
||||
let s = summary(
|
||||
vec![flow("AA", Some(buy), 100)],
|
||||
vec![flow("BB", Some(sell), 100)],
|
||||
2_100_000_000,
|
||||
);
|
||||
let tier = bandar_tier(&s, 1);
|
||||
(tier.imbalance_bps, tier.signal)
|
||||
};
|
||||
// -4.9952% and -14.9952% stay short of the thresholds, like their
|
||||
// mirror images.
|
||||
assert_eq!(
|
||||
reading(504_300_000, 609_200_000),
|
||||
(Some(-499), Some(BandarSignal::Neutral))
|
||||
);
|
||||
assert_eq!(
|
||||
reading(609_200_000, 504_300_000),
|
||||
(Some(499), Some(BandarSignal::Neutral))
|
||||
);
|
||||
assert_eq!(
|
||||
reading(504_300_000, 819_200_000),
|
||||
(Some(-1499), Some(BandarSignal::Distribution))
|
||||
);
|
||||
assert_eq!(
|
||||
reading(819_200_000, 504_300_000),
|
||||
(Some(1499), Some(BandarSignal::Accumulation))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandar_tier_weighs_one_side_against_its_own_buying_and_selling() {
|
||||
// Foreign investors bought 1,000 and sold 4,000: the top buyer took
|
||||
// half of the buying, the top seller a quarter of the selling.
|
||||
let mut s = summary(
|
||||
vec![flow("AA", Some(500), 100)],
|
||||
vec![flow("BB", Some(1_000), 100)],
|
||||
0,
|
||||
);
|
||||
s.investor = BrokerInvestor::Foreign;
|
||||
s.total_value = None;
|
||||
s.buy_value = Some(1_000);
|
||||
s.sell_value = Some(4_000);
|
||||
s.foreign_net_value = Some(-3_000);
|
||||
let report = analyze_bandar(&s, 1);
|
||||
let tier = &report.tiers[0];
|
||||
assert_eq!(
|
||||
(tier.buy_share_bps, tier.sell_share_bps),
|
||||
(Some(5000), Some(2500))
|
||||
);
|
||||
assert_eq!(tier.imbalance_bps, Some(2500));
|
||||
assert_eq!(tier.imbalance_value, Some(-500));
|
||||
assert_eq!(report.signal, Some(BandarSignal::BigAccumulation));
|
||||
assert_eq!(report.foreign_net_bps, None, "no total to weigh it against");
|
||||
|
||||
// Without the selling total (one side on one board) there is no reading.
|
||||
s.sell_value = None;
|
||||
assert_eq!(analyze_bandar(&s, 1).signal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() {
|
||||
let s = summary(
|
||||
vec![flow("AA", Some(400), 100), flow("BB", None, 100)],
|
||||
vec![flow("DD", Some(300), 100)],
|
||||
1000,
|
||||
);
|
||||
let report = analyze_bandar(&s, 3);
|
||||
assert_eq!(report.tiers[0].imbalance_bps, Some(1000));
|
||||
let top3 = &report.tiers[1];
|
||||
assert_eq!(
|
||||
(top3.buy_value, top3.imbalance_bps, top3.signal),
|
||||
(None, None, None)
|
||||
);
|
||||
assert_eq!(top3.buy_average, None);
|
||||
assert_eq!(report.signal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_bandar_without_trades_has_no_signal() {
|
||||
let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP);
|
||||
assert!(report.tiers.iter().all(|tier| tier.signal.is_none()));
|
||||
assert_eq!((report.signal, report.imbalance_bps), (None, None));
|
||||
assert_eq!(report.tiers[2].buy_value, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandar_tier_past_i128_has_no_reading_instead_of_a_wrapped_one() {
|
||||
let reading = |total: i64| {
|
||||
let buyers = vec![flow("AA", Some(total / 10 * 6), 1)];
|
||||
let sellers = vec![flow("BB", Some(total / 10 * 4), 1)];
|
||||
bandar_tier(&summary(buyers, sellers, total), 1).imbalance_bps
|
||||
};
|
||||
// A quadrillion rupiah, far past any session, is still exact.
|
||||
assert_eq!(reading(1_000_000_000_000_000), Some(2000));
|
||||
assert_eq!(reading(1_000_000_000_000_000_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_top_allows_only_report_tiers() {
|
||||
assert_eq!(check_top(5).unwrap(), 5);
|
||||
assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod bandar;
|
||||
pub mod fundamental;
|
||||
pub mod report;
|
||||
pub mod signals;
|
||||
|
|
@ -8,9 +9,11 @@ use serde::de::DeserializeOwned;
|
|||
|
||||
use crate::api::history_provider;
|
||||
use crate::api::types::{Fundamentals, Interval, Period};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use crate::market::HistorySource;
|
||||
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||
use fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
|
||||
analyze_growth, analyze_risk, analyze_valuation,
|
||||
|
|
@ -28,7 +31,85 @@ pub struct Comparison {
|
|||
pub failures: Vec<(String, IdxError)>,
|
||||
}
|
||||
|
||||
/// One investor side is weighed against its own selling, which the source
|
||||
/// gives only across all boards.
|
||||
fn check_scope(investor: BrokerInvestor, board: BrokerBoard) -> Result<(), IdxError> {
|
||||
if investor == BrokerInvestor::All || board == BrokerBoard::All {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdxError::InvalidInput(format!(
|
||||
"bandar for {} investors needs --board all: the broker summary gives one side's \
|
||||
selling only across all boards",
|
||||
investor.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bandar readings for several symbols, strongest accumulation first;
|
||||
/// symbols that failed are listed with their error.
|
||||
pub struct BandarScreen {
|
||||
pub reports: Vec<Fetched<BandarReport>>,
|
||||
pub failures: Vec<(String, IdxError)>,
|
||||
}
|
||||
|
||||
/// The sessions and trades a bandar reading covers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BandarQuery {
|
||||
pub range: BrokerRange,
|
||||
pub investor: BrokerInvestor,
|
||||
pub board: BrokerBoard,
|
||||
/// Headline tier, one of `bandar::TIERS`.
|
||||
pub top: u8,
|
||||
}
|
||||
|
||||
impl Analysis<'_> {
|
||||
/// Bandarmology over the broker summary `query` selects.
|
||||
pub fn bandar(
|
||||
&self,
|
||||
symbol: &str,
|
||||
query: &BandarQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<BandarReport>, IdxError> {
|
||||
let top = check_top(query.top)?;
|
||||
check_scope(query.investor, query.board)?;
|
||||
let summary =
|
||||
self.idx
|
||||
.broker()
|
||||
.fetch(symbol, query.range, query.investor, query.board, policy)?;
|
||||
Ok(summary.map(|summary| analyze_bandar(&summary, top)))
|
||||
}
|
||||
|
||||
/// [`Self::bandar`] for each symbol, ranked by the headline imbalance.
|
||||
/// Invalid input fails the whole screen; other errors fail one symbol.
|
||||
pub fn bandar_screen<S: AsRef<str>>(
|
||||
&self,
|
||||
symbols: &[S],
|
||||
query: &BandarQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<BandarScreen, IdxError> {
|
||||
check_top(query.top)?;
|
||||
check_scope(query.investor, query.board)?;
|
||||
let mut reports = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
for symbol in symbols {
|
||||
let resolved = self.idx.resolve(symbol.as_ref())?;
|
||||
match self.bandar(&resolved, query, policy) {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(err @ IdxError::InvalidInput(_)) => return Err(err),
|
||||
Err(err) => failures.push((resolved, err)),
|
||||
}
|
||||
}
|
||||
if reports.is_empty() {
|
||||
return Err(failures
|
||||
.pop()
|
||||
.map(|(_, err)| err)
|
||||
.unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string())));
|
||||
}
|
||||
// Strongest accumulation first; readings without an imbalance last.
|
||||
reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps));
|
||||
Ok(BandarScreen { reports, failures })
|
||||
}
|
||||
|
||||
/// Technical analysis over one year of daily history from `source`
|
||||
/// (see `Market::history_source`).
|
||||
pub fn technical(
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
121
crates/idx-core/src/api/idx/parse.rs
Normal file
121
crates/idx-core/src/api/idx/parse.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Pure parser for IDX's `GetStockSummary` JSON, tested against
|
||||
//! `tests/fixtures/idx_stocksummary_*.json`.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::IdxError;
|
||||
use crate::flow::types::StockDay;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StockSummaryResponse {
|
||||
data: Vec<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. Rows for other days only are an error,
|
||||
/// not an empty session, so they are never cached as one.
|
||||
pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result<Vec<StockDay>, IdxError> {
|
||||
let response: StockSummaryResponse = serde_json::from_str(raw)
|
||||
.map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?;
|
||||
let answered = response.data.len();
|
||||
let rows: Vec<StockDay> = response
|
||||
.data
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
row.date
|
||||
.get(..10)
|
||||
.and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok())
|
||||
== Some(date)
|
||||
})
|
||||
.map(|row| StockDay {
|
||||
code: row.stock_code.trim().to_ascii_uppercase(),
|
||||
date,
|
||||
previous: whole(row.previous),
|
||||
high: whole(row.high),
|
||||
low: whole(row.low),
|
||||
close: whole(row.close),
|
||||
volume: whole(row.volume),
|
||||
value: whole(row.value),
|
||||
frequency: whole(row.frequency),
|
||||
foreign_buy: whole(row.foreign_buy),
|
||||
foreign_sell: whole(row.foreign_sell),
|
||||
non_regular_volume: whole(row.non_regular_volume),
|
||||
non_regular_value: whole(row.non_regular_value),
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() && answered > 0 {
|
||||
return Err(IdxError::ParseError(format!(
|
||||
"idx stock summary: asked for {date}, got {answered} rows for other days"
|
||||
)));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261016.json");
|
||||
const SATURDAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261017.json");
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stock_summary_reads_foreign_and_non_regular_figures() {
|
||||
let rows = parse_stock_summary(DAY, date(2026, 10, 16)).unwrap();
|
||||
let bbca = rows.iter().find(|row| row.code == "BBCA").unwrap();
|
||||
assert_eq!((bbca.previous, bbca.close), (Some(6075), Some(5175)));
|
||||
assert_eq!(bbca.volume, Some(1_241_098_200));
|
||||
assert_eq!(bbca.value, Some(6_474_934_315_000));
|
||||
assert_eq!(
|
||||
(bbca.foreign_buy, bbca.foreign_sell),
|
||||
(Some(309_392_800), Some(757_227_900))
|
||||
);
|
||||
assert_eq!(bbca.non_regular_value, Some(100_183_151_475));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stock_summary_keeps_only_the_requested_session() {
|
||||
assert!(
|
||||
parse_stock_summary(SATURDAY, date(2026, 10, 17))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_stock_summary(DAY, date(2026, 10, 15)),
|
||||
Err(IdxError::ParseError(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_stock_summary("<html>Just a moment...</html>", date(2026, 10, 16)),
|
||||
Err(IdxError::ParseError(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
62
crates/idx-core/src/api/ipot/client.rs
Normal file
62
crates/idx-core/src/api/ipot/client.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
/// Tries per request. In some stretches of seconds the server answers its
|
||||
/// own nginx 404 page to a third or more of requests and then recovers, so a
|
||||
/// 404 is retried (an unknown ticker is a 200, not a 404).
|
||||
const ATTEMPTS: u32 = 5;
|
||||
const FIRST_PAUSE: Duration = Duration::from_millis(250);
|
||||
|
||||
pub(super) struct IpotClient {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl IpotClient {
|
||||
pub(super) fn new() -> Self {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
// A server that stalls before sending headers must not hold a
|
||||
// call (and an MCP call slot) forever.
|
||||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.build()
|
||||
.into();
|
||||
Self { agent }
|
||||
}
|
||||
|
||||
pub(super) fn fetch_html(&self, url: &str) -> Result<String, IdxError> {
|
||||
let mut pause = FIRST_PAUSE;
|
||||
let mut last = None;
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
if attempt > 1 {
|
||||
std::thread::sleep(pause);
|
||||
pause *= 2;
|
||||
}
|
||||
let response = self
|
||||
.agent
|
||||
.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html")
|
||||
.call();
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
return ok
|
||||
.into_body()
|
||||
.read_to_string()
|
||||
.map_err(|e| IdxError::Http(format!("ipot broker summary: {e}")));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(code)) if code == 404 || code >= 500 => {
|
||||
last = Some(code);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(429)) => return Err(IdxError::RateLimited),
|
||||
Err(err) => return Err(IdxError::Http(format!("ipot broker summary: {err}"))),
|
||||
}
|
||||
}
|
||||
Err(IdxError::Http(format!(
|
||||
"ipot broker summary: status {} after {ATTEMPTS} attempts",
|
||||
last.unwrap_or_default()
|
||||
)))
|
||||
}
|
||||
}
|
||||
101
crates/idx-core/src/api/ipot/mod.rs
Normal file
101
crates/idx-core/src/api/ipot/mod.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Indo Premier Sekuritas (IPOT) broker summary: which brokers bought and
|
||||
//! sold a stock. No login or key.
|
||||
//!
|
||||
//! `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php`
|
||||
//! `?code=BBCA&start=MM/DD/YYYY&end=MM/DD/YYYY&fd=all|F|D&board=all|RG|TN|NG`
|
||||
//! answers an HTML fragment with the top 10 buyers and the top 10 sellers,
|
||||
//! ranked by lots, and totals for the range. Figures of a million and more
|
||||
//! are abbreviated to one decimal (`1.6 M`). A range without trades gives
|
||||
//! blank rows and zero totals; an unknown code gives `Invalid stock code!`
|
||||
//! with HTTP 200. The endpoint's behavior was first mapped by fzn0x/idx
|
||||
//! (MIT).
|
||||
|
||||
mod client;
|
||||
pub mod parse;
|
||||
|
||||
use client::IpotClient;
|
||||
pub use parse::{BrokerSummaryRaw, IpotPage, parse_broker_summary, parse_figure};
|
||||
|
||||
use super::BrokerSummaryProvider;
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::error::IdxError;
|
||||
|
||||
const ENDPOINT: &str = "https://www.indopremier.com/module/saham/include/data-brokersummary.php";
|
||||
|
||||
/// The request URL for `code` (a bare IDX code such as `BBCA`).
|
||||
pub fn broker_summary_url(code: &str, query: &BrokerQuery) -> String {
|
||||
let investor = match query.investor {
|
||||
BrokerInvestor::All => "all",
|
||||
BrokerInvestor::Foreign => "F",
|
||||
BrokerInvestor::Domestic => "D",
|
||||
};
|
||||
let board = match query.board {
|
||||
BrokerBoard::All => "all",
|
||||
BrokerBoard::Regular => "RG",
|
||||
BrokerBoard::Cash => "TN",
|
||||
BrokerBoard::Negotiated => "NG",
|
||||
};
|
||||
format!(
|
||||
"{ENDPOINT}?code={code}&start={}&end={}&fd={investor}&board={board}",
|
||||
query.from.format("%m/%d/%Y"),
|
||||
query.to.format("%m/%d/%Y"),
|
||||
)
|
||||
}
|
||||
|
||||
/// A parsed page as a summary; an unknown code is `SymbolNotFound`.
|
||||
pub(crate) fn summary_from_html(code: &str, html: &str) -> Result<BrokerSummaryRaw, IdxError> {
|
||||
match parse_broker_summary(html)? {
|
||||
IpotPage::Invalid => Err(IdxError::SymbolNotFound(code.to_string())),
|
||||
IpotPage::Summary(raw) => Ok(raw),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IpotProvider {
|
||||
client: IpotClient,
|
||||
}
|
||||
|
||||
impl IpotProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: IpotClient::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IpotProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrokerSummaryProvider for IpotProvider {
|
||||
fn broker_summary(
|
||||
&self,
|
||||
code: &str,
|
||||
query: &BrokerQuery,
|
||||
) -> Result<BrokerSummaryRaw, IdxError> {
|
||||
let html = self.client.fetch_html(&broker_summary_url(code, query))?;
|
||||
summary_from_html(code, &html)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_broker_summary_url_maps_filters_to_source_codes() {
|
||||
let query = BrokerQuery {
|
||||
from: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(),
|
||||
to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
|
||||
investor: BrokerInvestor::Foreign,
|
||||
board: BrokerBoard::Regular,
|
||||
};
|
||||
assert_eq!(
|
||||
broker_summary_url("BBCA", &query),
|
||||
"https://www.indopremier.com/module/saham/include/data-brokersummary.php?code=BBCA&start=10/12/2026&end=10/16/2026&fd=F&board=RG"
|
||||
);
|
||||
}
|
||||
}
|
||||
1041
crates/idx-core/src/api/ipot/parse.rs
Normal file
1041
crates/idx-core/src/api/ipot/parse.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,11 @@
|
|||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||
|
||||
use super::types::{Bar, Fundamentals, Interval, Period, Quote};
|
||||
use super::{FundamentalsProvider, HistoryProvider, QuoteProvider, msn, yahoo};
|
||||
use super::{
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, yahoo,
|
||||
};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::config::ProviderKind;
|
||||
use crate::error::IdxError;
|
||||
|
||||
|
|
@ -10,6 +14,15 @@ pub(crate) fn enabled() -> bool {
|
|||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||
}
|
||||
|
||||
/// `IDX_MOCK_TODAY` (YYYY-MM-DD) pins today's date under the mock provider.
|
||||
pub(crate) fn today() -> Option<chrono::NaiveDate> {
|
||||
if !enabled() {
|
||||
return None;
|
||||
}
|
||||
let raw = std::env::var("IDX_MOCK_TODAY").ok()?;
|
||||
chrono::NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
pub struct MockProvider {
|
||||
quote: Result<Quote, IdxError>,
|
||||
fundamentals: Result<Fundamentals, IdxError>,
|
||||
|
|
@ -117,3 +130,61 @@ impl HistoryProvider for MockProvider {
|
|||
self.history.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
||||
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
||||
/// unknown code.
|
||||
pub struct MockBrokerProvider;
|
||||
|
||||
impl BrokerSummaryProvider for MockBrokerProvider {
|
||||
fn broker_summary(
|
||||
&self,
|
||||
code: &str,
|
||||
query: &BrokerQuery,
|
||||
) -> Result<ipot::BrokerSummaryRaw, IdxError> {
|
||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
}
|
||||
let mut name = format!(
|
||||
"ipot_brokersummary_{}_{}",
|
||||
code.to_ascii_lowercase(),
|
||||
query.from.format("%Y%m%d")
|
||||
);
|
||||
if query.to != query.from {
|
||||
name.push_str(&query.to.format("_%Y%m%d").to_string());
|
||||
}
|
||||
if (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All) {
|
||||
name.push_str(&format!(
|
||||
"_{}_{}",
|
||||
query.investor.as_str(),
|
||||
query.board.as_str()
|
||||
));
|
||||
}
|
||||
let html = std::fs::read_to_string(format!("tests/fixtures/{name}.html"))
|
||||
.unwrap_or_else(|_| "Invalid stock code!".to_string());
|
||||
ipot::summary_from_html(code, &html)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,5 @@
|
|||
pub mod idx;
|
||||
pub mod ipot;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod mock;
|
||||
pub mod msn;
|
||||
|
|
@ -6,6 +8,7 @@ pub mod yahoo;
|
|||
|
||||
use chrono::FixedOffset;
|
||||
|
||||
use crate::broker::types::BrokerQuery;
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
use types::{
|
||||
|
|
@ -41,6 +44,26 @@ pub trait HistoryProvider {
|
|||
) -> Result<Vec<Bar>, IdxError>;
|
||||
}
|
||||
|
||||
/// Which brokers bought and sold a stock: a separate capability, served by
|
||||
/// IPOT whichever market provider is selected.
|
||||
pub trait BrokerSummaryProvider {
|
||||
/// `code` is the bare IDX code, e.g. `BBCA`.
|
||||
fn broker_summary(
|
||||
&self,
|
||||
code: &str,
|
||||
query: &BrokerQuery,
|
||||
) -> 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 {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
|
@ -258,6 +281,31 @@ fn msn_capability_error(subject: &str) -> IdxError {
|
|||
IdxError::Unsupported(format!("{subject}: command requires --provider msn"))
|
||||
}
|
||||
|
||||
pub fn broker_provider() -> Box<dyn BrokerSummaryProvider> {
|
||||
#[cfg(feature = "testing")]
|
||||
if mock::enabled() {
|
||||
return Box::new(mock::MockBrokerProvider);
|
||||
}
|
||||
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.
|
||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||
#[cfg(feature = "testing")]
|
||||
if let Some(today) = mock::today() {
|
||||
return today;
|
||||
}
|
||||
chrono::Utc::now().with_timezone(&idx_offset()).date_naive()
|
||||
}
|
||||
|
||||
/// The provider that serves history for `provider` under `history_mode`.
|
||||
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
|
||||
match history_mode {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
|||
prev_close,
|
||||
avg_volume: round_u64(quote.average_volume),
|
||||
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
|
||||
behind_session: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -718,6 +719,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
|||
prev_close,
|
||||
avg_volume: round_u64(q.average_volume),
|
||||
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
||||
behind_session: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ pub struct Quote {
|
|||
/// Suspended stocks can carry a last trade months in the past.
|
||||
#[serde(default)]
|
||||
pub as_of: Option<DateTime<FixedOffset>>,
|
||||
/// True when `as_of` is before the latest IDX session that has ended: the
|
||||
/// provider has not caught up yet (MSN can lag hours after the close), the
|
||||
/// stock did not trade, or the day was an exchange holiday. False when
|
||||
/// `as_of` is unknown and for non-IDX symbols and regions. Set when the
|
||||
/// quote is returned, not when it is cached.
|
||||
#[serde(default)]
|
||||
pub behind_session: bool,
|
||||
}
|
||||
|
||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
|||
.regular_market_time
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
||||
behind_session: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
92
crates/idx-core/src/broker/codes.tsv
Normal file
92
crates/idx-core/src/broker/codes.tsv
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# IDX exchange members: code<TAB>name, from idx.co.id
|
||||
# primary/ExchangeMember/GetBrokerCodeList, fetched 2026-10-18 (89 members).
|
||||
# A code missing here is shown without a name.
|
||||
AD SUKADANA PRIMA SEKURITAS
|
||||
AF HARITA KENCANA SEKURITAS
|
||||
AG KIWOOM SEKURITAS INDONESIA
|
||||
AH SHINHAN SEKURITAS INDONESIA
|
||||
AI KAY HIAN SEKURITAS
|
||||
AK UBS SEKURITAS INDONESIA
|
||||
AO ERDIKHA ELIT SEKURITAS
|
||||
AP PACIFIC SEKURITAS INDONESIA
|
||||
AR BINAARTHA SEKURITAS
|
||||
AT PHINTRACO SEKURITAS
|
||||
AZ SUCOR SEKURITAS
|
||||
BB VERDHANA SEKURITAS INDONESIA
|
||||
BF INTI FIKASA SEKURITAS
|
||||
BK J.P. MORGAN SEKURITAS INDONESIA
|
||||
BQ KOREA INVESTMENT AND SEKURITAS INDONESIA
|
||||
BR TRUST SEKURITAS
|
||||
BS EQUITY SEKURITAS INDONESIA
|
||||
CC MANDIRI SEKURITAS
|
||||
CD MEGA CAPITAL SEKURITAS
|
||||
CP KB VALBURY SEKURITAS
|
||||
DD MAKINDO SEKURITAS
|
||||
DH SINARMAS SEKURITAS
|
||||
DP DBS VICKERS SEKURITAS INDONESIA
|
||||
DR RHB SEKURITAS INDONESIA
|
||||
DU KAF SEKURITAS INDONESIA
|
||||
DX BAHANA SEKURITAS
|
||||
EL EVERGREEN SEKURITAS INDONESIA
|
||||
EP MNC SEKURITAS
|
||||
ES EKOKAPITAL SEKURITAS
|
||||
FO FORTE GLOBAL SEKURITAS
|
||||
FS YUANTA SEKURITAS INDONESIA
|
||||
FZ WATERFRONT SEKURITAS INDONESIA
|
||||
GA BNC SEKURITAS INDONESIA
|
||||
GI WEBULL SEKURITAS INDONESIA
|
||||
GR PANIN SEKURITAS TBK.
|
||||
HD KGI SEKURITAS INDONESIA
|
||||
HP HENAN PUTIHRAI SEKURITAS
|
||||
IC INTEGRITY CAPITAL SEKURITAS
|
||||
ID ANUGERAH SEKURITAS INDONESIA
|
||||
IF SAMUEL SEKURITAS INDONESIA
|
||||
IH INDO HARVEST SEKURITAS
|
||||
II DANATAMA MAKMUR SEKURITAS
|
||||
IN INVESTINDO NUSANTARA SEKURITAS
|
||||
IT INTI TELADAN SEKURITAS
|
||||
IU INDO CAPITAL SEKURITAS
|
||||
KI CIPTADANA SEKURITAS ASIA
|
||||
KK PHILLIP SEKURITAS INDONESIA
|
||||
KZ CLSA SEKURITAS INDONESIA
|
||||
LG TRIMEGAH SEKURITAS INDONESIA TBK.
|
||||
LS RELIANCE SEKURITAS INDONESIA TBK.
|
||||
MG SEMESTA INDOVEST SEKURITAS
|
||||
MI VICTORIA SEKURITAS INDONESIA
|
||||
MU MINNA PADI INVESTAMA SEKURITAS TBK
|
||||
NI BNI SEKURITAS
|
||||
OD BRI DANAREKSA SEKURITAS
|
||||
OK NET SEKURITAS
|
||||
PC FAC SEKURITAS INDONESIA
|
||||
PD INDO PREMIER SEKURITAS
|
||||
PF DANASAKTI SEKURITAS INDONESIA
|
||||
PG PANCA GLOBAL SEKURITAS
|
||||
PI MAGENTA KAPITAL SEKURITAS INDONESIA
|
||||
PO PILARMAS INVESTINDO SEKURITAS
|
||||
PP ALDIRACITA SEKURITAS INDONESIA
|
||||
PS PARAMITRA ALFA SEKURITAS
|
||||
QA TUNTUN SEKURITAS INDONESIA
|
||||
RB INA SEKURITAS INDONESIA
|
||||
RF BUANA CAPITAL SEKURITAS
|
||||
RG PROFINDO SEKURITAS INDONESIA
|
||||
RO PLUANG MAJU SEKURITAS
|
||||
RS YULIE SEKURITAS INDONESIA TBK.
|
||||
RX MACQUARIE SEKURITAS INDONESIA
|
||||
SA ELIT SUKSES SEKURITAS
|
||||
SF SURYA FAJAR SEKURITAS
|
||||
SH ARTHA SEKURITAS INDONESIA
|
||||
SQ BCA SEKURITAS
|
||||
SS SUPRA SEKURITAS INDONESIA
|
||||
TF LABA SEKURITAS INDONESIA
|
||||
TP OCBC SEKURITAS INDONESIA
|
||||
TS DWIDANA SAKTI SEKURITAS
|
||||
XA NH KORINDO SEKURITAS INDONESIA
|
||||
XC AJAIB SEKURITAS ASIA
|
||||
XL STOCKBIT SEKURITAS DIGITAL
|
||||
YB YAKIN BERTUMBUH SEKURITAS
|
||||
YJ LOTUS ANDALAN SEKURITAS
|
||||
YO AMANTARA SEKURITAS INDONESIA
|
||||
YP MIRAE ASSET SEKURITAS INDONESIA
|
||||
YU CGS INTERNATIONAL SEKURITAS INDONESIA
|
||||
ZP MAYBANK SEKURITAS INDONESIA
|
||||
ZR BUMIPUTERA SEKURITAS
|
||||
|
Can't render this file because it has a wrong number of fields in line 4.
|
432
crates/idx-core/src/broker/mod.rs
Normal file
432
crates/idx-core/src/broker/mod.rs
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
//! Broker summaries: which IDX brokers bought and sold a stock over a date
|
||||
//! range (IPOT, end of day). See docs/ARCHITECTURE.md.
|
||||
|
||||
pub mod types;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||
|
||||
use crate::api::ipot::BrokerSummaryRaw;
|
||||
use crate::api::{broker_provider, today_wib};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary};
|
||||
|
||||
const SOURCE: &str = "ipot";
|
||||
/// Cache bucket for normalized summaries, versioned with their shape: a
|
||||
/// summary cached before `buy_value`/`sell_value` existed would read back
|
||||
/// without them (and an investor-filtered one without its totals), so a new
|
||||
/// shape gets a new bucket rather than serving such entries.
|
||||
const CACHE_BUCKET: &str = "ipot-broker-v2";
|
||||
/// Longest range one summary may cover.
|
||||
pub const MAX_RANGE_DAYS: i64 = 366;
|
||||
/// Calendar days searched back for the latest session with trades.
|
||||
const LATEST_LOOKBACK_DAYS: i64 = 10;
|
||||
/// A range that ended before today no longer changes.
|
||||
const TTL_CLOSED_SECS: u64 = 7 * 24 * 3600;
|
||||
/// Today's summary moves with every trade.
|
||||
const TTL_TODAY_SECS: u64 = 5 * 60;
|
||||
|
||||
const CODES: &str = include_str!("codes.tsv");
|
||||
|
||||
/// Member name for an IDX broker code such as `CC`; `None` when the code is
|
||||
/// not on the bundled member list.
|
||||
pub fn broker_name(code: &str) -> Option<&'static str> {
|
||||
static NAMES: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
NAMES
|
||||
.get_or_init(|| {
|
||||
CODES
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.filter_map(|line| line.split_once('\t'))
|
||||
.collect()
|
||||
})
|
||||
.get(code.trim().to_ascii_uppercase().as_str())
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub struct Broker<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
impl Broker<'_> {
|
||||
/// Today in WIB, the date `BrokerRange::from_args` defaults `to` to.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
today_wib()
|
||||
}
|
||||
|
||||
/// The summary a frontend asked for: the latest session or a range.
|
||||
pub fn fetch(
|
||||
&self,
|
||||
symbol: &str,
|
||||
range: BrokerRange,
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||
match range {
|
||||
BrokerRange::Latest => self.latest(symbol, investor, board, policy),
|
||||
BrokerRange::Dates { from, to } => self.summary(
|
||||
symbol,
|
||||
&BrokerQuery {
|
||||
from,
|
||||
to,
|
||||
investor,
|
||||
board,
|
||||
},
|
||||
policy,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The broker summary for `symbol` over `query`'s range. An empty
|
||||
/// summary means no trades (weekend, holiday, suspension).
|
||||
pub fn summary(
|
||||
&self,
|
||||
symbol: &str,
|
||||
query: &BrokerQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||
let (resolved, code) = self.idx.idx_code(symbol, "a broker summary")?;
|
||||
let today = today_wib();
|
||||
check_range(query, today)?;
|
||||
let key = format!(
|
||||
"{code}-{}-{}-{}-{}",
|
||||
query.from,
|
||||
query.to,
|
||||
query.investor.as_str(),
|
||||
query.board.as_str()
|
||||
);
|
||||
let provider = broker_provider();
|
||||
cached_fetch(
|
||||
&self.idx.cache,
|
||||
CacheSpec {
|
||||
bucket: CACHE_BUCKET,
|
||||
key: &key,
|
||||
ttl_secs: if query.to < today {
|
||||
TTL_CLOSED_SECS
|
||||
} else {
|
||||
TTL_TODAY_SECS
|
||||
},
|
||||
subject: &resolved,
|
||||
source: SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| {
|
||||
provider
|
||||
.broker_summary(&code, query)
|
||||
.and_then(|raw| check_echoed_dates(query, raw))
|
||||
},
|
||||
|raw| Ok(summary_from_raw(&resolved, query, raw)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The summary of the latest session with trades, searching back from
|
||||
/// today (WIB) over weekdays.
|
||||
pub fn latest(
|
||||
&self,
|
||||
symbol: &str,
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||
let (resolved, _) = self.idx.idx_code(symbol, "a broker summary")?;
|
||||
let today = today_wib();
|
||||
for back in 0..LATEST_LOOKBACK_DAYS {
|
||||
let day = today - Duration::days(back);
|
||||
if matches!(day.weekday(), Weekday::Sat | Weekday::Sun) {
|
||||
continue;
|
||||
}
|
||||
let query = BrokerQuery {
|
||||
from: day,
|
||||
to: day,
|
||||
investor,
|
||||
board,
|
||||
};
|
||||
match self.summary(symbol, &query, policy) {
|
||||
Ok(fetched) if !fetched.data.is_empty() => return Ok(fetched),
|
||||
Ok(_) => {}
|
||||
// Offline, an uncached day says nothing; look further back.
|
||||
Err(IdxError::CacheMiss(_)) if policy == CachePolicy::Offline => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
if policy == CachePolicy::Offline {
|
||||
return Err(IdxError::CacheMiss(format!(
|
||||
"{CACHE_BUCKET}/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)"
|
||||
)));
|
||||
}
|
||||
Err(IdxError::NoMarketData(format!(
|
||||
"{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The public summary of one parsed page.
|
||||
///
|
||||
/// With one investor side selected, the page's footer (`T. Val`) covers only
|
||||
/// that side's buying, so it is the side's `buy_value`, not a total of the
|
||||
/// trades the lists show. `F. NVal`, the foreign net, ignores both filters:
|
||||
/// on all boards it gives the side's selling (foreign buying less the foreign
|
||||
/// net; domestic buying plus it), and with a board filter it is dropped.
|
||||
pub(crate) fn summary_from_raw(
|
||||
symbol: &str,
|
||||
query: &BrokerQuery,
|
||||
raw: BrokerSummaryRaw,
|
||||
) -> BrokerSummary {
|
||||
let one_side = query.investor != BrokerInvestor::All;
|
||||
let all_boards = query.board == BrokerBoard::All;
|
||||
let foreign_net = raw.foreign_net_value.filter(|_| all_boards);
|
||||
let buy_value = raw.total_value;
|
||||
let sell_value = match query.investor {
|
||||
BrokerInvestor::All => raw.total_value,
|
||||
BrokerInvestor::Foreign => buy_value
|
||||
.zip(foreign_net)
|
||||
.and_then(|(buy, net)| buy.checked_sub(net)),
|
||||
BrokerInvestor::Domestic => buy_value
|
||||
.zip(foreign_net)
|
||||
.and_then(|(buy, net)| buy.checked_add(net)),
|
||||
};
|
||||
let mut summary = BrokerSummary {
|
||||
symbol: symbol.to_string(),
|
||||
from: raw.from.unwrap_or(query.from),
|
||||
to: raw.to.unwrap_or(query.to),
|
||||
investor: query.investor,
|
||||
board: query.board,
|
||||
buyers: raw.buyers,
|
||||
sellers: raw.sellers,
|
||||
total_value: raw.total_value.filter(|_| !one_side),
|
||||
total_lots: raw.total_lots.filter(|_| !one_side),
|
||||
average_price: raw.average_price.filter(|_| !one_side),
|
||||
buy_value,
|
||||
sell_value,
|
||||
foreign_net_value: foreign_net,
|
||||
rounded: raw.rounded,
|
||||
};
|
||||
for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) {
|
||||
flow.name = broker_name(&flow.code).map(str::to_string);
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// The page echoes the dates it covers; another range's figures must not be
|
||||
/// cached or reported as the requested one's.
|
||||
fn check_echoed_dates(
|
||||
query: &BrokerQuery,
|
||||
raw: BrokerSummaryRaw,
|
||||
) -> Result<BrokerSummaryRaw, IdxError> {
|
||||
if raw.from == Some(query.from) && raw.to == Some(query.to) {
|
||||
return Ok(raw);
|
||||
}
|
||||
let echoed = |date: Option<NaiveDate>| date.map_or("none".to_string(), |date| date.to_string());
|
||||
Err(IdxError::ParseError(format!(
|
||||
"ipot broker summary: asked for {} to {}, the page covers {} to {}",
|
||||
query.from,
|
||||
query.to,
|
||||
echoed(raw.from),
|
||||
echoed(raw.to)
|
||||
)))
|
||||
}
|
||||
|
||||
fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> {
|
||||
if query.from > query.to {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"--from {} is after --to {}",
|
||||
query.from, query.to
|
||||
)));
|
||||
}
|
||||
if query.to > today {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"{} is in the future (today is {today} in WIB)",
|
||||
query.to
|
||||
)));
|
||||
}
|
||||
if (query.to - query.from).num_days() >= MAX_RANGE_DAYS {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"a broker summary covers at most {MAX_RANGE_DAYS} days"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn query(from: NaiveDate, to: NaiveDate) -> BrokerQuery {
|
||||
BrokerQuery {
|
||||
from,
|
||||
to,
|
||||
investor: BrokerInvestor::All,
|
||||
board: BrokerBoard::All,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broker_name_reads_the_bundled_member_list() {
|
||||
assert_eq!(broker_name("cc"), Some("MANDIRI SEKURITAS"));
|
||||
assert_eq!(broker_name("XL"), Some("STOCKBIT SEKURITAS DIGITAL"));
|
||||
assert_eq!(broker_name("??"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_from_raw_gives_each_side_its_own_totals() {
|
||||
let day = date(2026, 10, 16);
|
||||
let read = |name: &str| {
|
||||
let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap();
|
||||
crate::api::ipot::summary_from_html("BBCA", &html).unwrap()
|
||||
};
|
||||
let all = summary_from_raw(
|
||||
"BBCA.JK",
|
||||
&query(day, day),
|
||||
read("ipot_brokersummary_bbca_20261016.html"),
|
||||
);
|
||||
assert_eq!(all.total_value, Some(6_600_000_000_000));
|
||||
assert_eq!(all.foreign_net_value, Some(-2_300_000_000_000));
|
||||
assert_eq!(all.sellers[0].name.as_deref(), Some("MANDIRI SEKURITAS"));
|
||||
|
||||
assert_eq!(
|
||||
(all.buy_value, all.sell_value),
|
||||
(all.total_value, all.total_value)
|
||||
);
|
||||
|
||||
// Foreign on all boards: the footer's 1.7 T is foreign buying; with
|
||||
// the -2.3 T foreign net, foreign selling is 4.0 T (IDX: about
|
||||
// 3.95 T). Domestic buying 4.9 T makes up the rest of 6.6 T, and its
|
||||
// selling is 2.6 T.
|
||||
let side = |investor, board, name: &str| {
|
||||
let query = BrokerQuery {
|
||||
investor,
|
||||
board,
|
||||
..query(day, day)
|
||||
};
|
||||
summary_from_raw("BBCA.JK", &query, read(name))
|
||||
};
|
||||
let foreign = side(
|
||||
BrokerInvestor::Foreign,
|
||||
BrokerBoard::All,
|
||||
"ipot_brokersummary_bbca_20261016_foreign_all.html",
|
||||
);
|
||||
assert_eq!(
|
||||
(foreign.buy_value, foreign.sell_value),
|
||||
(Some(1_700_000_000_000), Some(4_000_000_000_000))
|
||||
);
|
||||
assert_eq!(foreign.total_value, None);
|
||||
assert_eq!(foreign.foreign_net_value, Some(-2_300_000_000_000));
|
||||
let domestic = side(
|
||||
BrokerInvestor::Domestic,
|
||||
BrokerBoard::All,
|
||||
"ipot_brokersummary_bbca_20261016_domestic_all.html",
|
||||
);
|
||||
assert_eq!(
|
||||
(domestic.buy_value, domestic.sell_value),
|
||||
(Some(4_900_000_000_000), Some(2_600_000_000_000))
|
||||
);
|
||||
|
||||
// Foreign, regular board: the footer's 1.6 T is regular foreign
|
||||
// buying, less than the 2.0 T its top seller alone sold, and the net
|
||||
// covers all boards, so selling cannot be derived.
|
||||
let raw = read("ipot_brokersummary_bbca_20261016_foreign_regular.html");
|
||||
assert!(raw.total_value < raw.sellers[0].value);
|
||||
let regular = side(
|
||||
BrokerInvestor::Foreign,
|
||||
BrokerBoard::Regular,
|
||||
"ipot_brokersummary_bbca_20261016_foreign_regular.html",
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
regular.total_value,
|
||||
regular.total_lots,
|
||||
regular.average_price
|
||||
),
|
||||
(None, None, None)
|
||||
);
|
||||
assert_eq!(
|
||||
(regular.buy_value, regular.sell_value),
|
||||
(Some(1_600_000_000_000), None)
|
||||
);
|
||||
assert_eq!(regular.foreign_net_value, None);
|
||||
assert_eq!(regular.sellers[0].value, Some(2_000_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broker_range_from_args() {
|
||||
let today = date(2026, 10, 18);
|
||||
let range = |date, from, to| BrokerRange::from_args(date, from, to, today);
|
||||
assert_eq!(range(None, None, None).unwrap(), BrokerRange::Latest);
|
||||
assert_eq!(
|
||||
range(Some("2026-10-16"), None, None).unwrap(),
|
||||
BrokerRange::Dates {
|
||||
from: date(2026, 10, 16),
|
||||
to: date(2026, 10, 16)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
range(None, Some("2026-10-12"), None).unwrap(),
|
||||
BrokerRange::Dates {
|
||||
from: date(2026, 10, 12),
|
||||
to: today
|
||||
}
|
||||
);
|
||||
for (d, f, t) in [
|
||||
(None, None, Some("2026-10-16")),
|
||||
(Some("2026-10-16"), Some("2026-10-12"), None),
|
||||
(Some("16/10/2026"), None, None),
|
||||
(None, Some("2026-10-12"), Some("tomorrow")),
|
||||
] {
|
||||
assert!(
|
||||
matches!(range(d, f, t), Err(IdxError::InvalidInput(_))),
|
||||
"{d:?} {f:?} {t:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_range_bounds() {
|
||||
let today = date(2026, 10, 18);
|
||||
assert!(check_range(&query(date(2026, 10, 12), date(2026, 10, 16)), today).is_ok());
|
||||
assert!(check_range(&query(today, today), today).is_ok());
|
||||
assert!(check_range(&query(date(2025, 10, 16), date(2026, 10, 16)), today).is_ok());
|
||||
for bad in [
|
||||
query(date(2026, 10, 16), date(2026, 10, 12)),
|
||||
query(date(2026, 10, 19), date(2026, 10, 19)),
|
||||
query(date(2025, 10, 15), date(2026, 10, 16)),
|
||||
] {
|
||||
assert!(
|
||||
matches!(check_range(&bad, today), Err(IdxError::InvalidInput(_))),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_echoed_dates_rejects_another_sessions_page() {
|
||||
let friday = date(2026, 10, 16);
|
||||
let page = || BrokerSummaryRaw {
|
||||
from: Some(friday),
|
||||
to: Some(friday),
|
||||
..BrokerSummaryRaw::default()
|
||||
};
|
||||
assert!(check_echoed_dates(&query(friday, friday), page()).is_ok());
|
||||
let thursday = date(2026, 10, 15);
|
||||
for (asked, raw) in [
|
||||
(query(thursday, thursday), page()),
|
||||
(query(thursday, friday), page()),
|
||||
(query(friday, friday), BrokerSummaryRaw::default()),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
check_echoed_dates(&asked, raw),
|
||||
Err(IdxError::ParseError(_))
|
||||
),
|
||||
"{asked:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
241
crates/idx-core/src/broker/types.rs
Normal file
241
crates/idx-core/src/broker/types.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! Broker summary types: which IDX brokers bought and sold a stock.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// Whose trades a broker summary counts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrokerInvestor {
|
||||
/// Every trade.
|
||||
#[default]
|
||||
All,
|
||||
/// Trades by foreign investors only.
|
||||
Foreign,
|
||||
/// Trades by domestic investors only.
|
||||
Domestic,
|
||||
}
|
||||
|
||||
impl BrokerInvestor {
|
||||
pub const ALL: [Self; 3] = [Self::All, Self::Foreign, Self::Domestic];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
Self::Foreign => "foreign",
|
||||
Self::Domestic => "domestic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BrokerInvestor {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"all" => Ok(Self::All),
|
||||
"foreign" => Ok(Self::Foreign),
|
||||
"domestic" => Ok(Self::Domestic),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid investor '{value}'; use all, foreign or domestic"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which market board's trades a broker summary counts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrokerBoard {
|
||||
/// Every board.
|
||||
#[default]
|
||||
All,
|
||||
/// Regular market (RG), the continuous order book.
|
||||
Regular,
|
||||
/// Cash market (TN).
|
||||
Cash,
|
||||
/// Negotiated market (NG): pre-arranged block trades.
|
||||
Negotiated,
|
||||
}
|
||||
|
||||
impl BrokerBoard {
|
||||
pub const ALL: [Self; 4] = [Self::All, Self::Regular, Self::Cash, Self::Negotiated];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
Self::Regular => "regular",
|
||||
Self::Cash => "cash",
|
||||
Self::Negotiated => "negotiated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BrokerBoard {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"all" => Ok(Self::All),
|
||||
"regular" => Ok(Self::Regular),
|
||||
"cash" => Ok(Self::Cash),
|
||||
"negotiated" => Ok(Self::Negotiated),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid board '{value}'; use all, regular, cash or negotiated"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's classification of a securities house. This is the broker's
|
||||
/// own status, not the nationality of its clients.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrokerKind {
|
||||
Local,
|
||||
Foreign,
|
||||
/// State-owned securities house (BUMN).
|
||||
StateOwned,
|
||||
}
|
||||
|
||||
impl BrokerKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::Foreign => "foreign",
|
||||
Self::StateOwned => "state_owned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sessions a frontend asked for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BrokerRange {
|
||||
/// The latest session with trades.
|
||||
Latest,
|
||||
/// `from..=to`, WIB dates.
|
||||
Dates { from: NaiveDate, to: NaiveDate },
|
||||
}
|
||||
|
||||
impl BrokerRange {
|
||||
/// From a frontend's `date`, or `from` with an optional `to` (default
|
||||
/// today), as `YYYY-MM-DD`; none of them means the latest session.
|
||||
pub fn from_args(
|
||||
date: Option<&str>,
|
||||
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 (date, from, to) {
|
||||
(None, None, None) => Ok(Self::Latest),
|
||||
(Some(date), None, None) => {
|
||||
let day = parse("date", date)?;
|
||||
Ok(Self::Dates { from: day, to: day })
|
||||
}
|
||||
(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 (or use date for one session)".to_string(),
|
||||
)),
|
||||
(Some(_), _, _) => Err(IdxError::InvalidInput(
|
||||
"date cannot be combined with from or to".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trading days a broker summary covers, both ends included (WIB dates).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BrokerQuery {
|
||||
pub from: NaiveDate,
|
||||
pub to: NaiveDate,
|
||||
#[serde(default)]
|
||||
pub investor: BrokerInvestor,
|
||||
#[serde(default)]
|
||||
pub board: BrokerBoard,
|
||||
}
|
||||
|
||||
/// One broker on one side of a summary: gross buys for a buyer, gross sells
|
||||
/// for a seller. A broker can appear on both sides.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BrokerFlow {
|
||||
/// Position on its side as the source ranks it, 1 = most lots.
|
||||
pub rank: u32,
|
||||
/// IDX member code, e.g. `CC`.
|
||||
pub code: String,
|
||||
/// Member name from the bundled IDX member list; `None` for a code that
|
||||
/// is not on it.
|
||||
pub name: Option<String>,
|
||||
/// The source's classification; `None` when it gives none.
|
||||
pub kind: Option<BrokerKind>,
|
||||
/// Lots bought or sold (one lot is 100 shares).
|
||||
pub lots: Option<i64>,
|
||||
/// Value bought or sold, whole IDR.
|
||||
pub value: Option<i64>,
|
||||
/// Average price, whole IDR.
|
||||
pub average_price: Option<i64>,
|
||||
}
|
||||
|
||||
/// The top buyers and sellers of one stock over a date range, as published.
|
||||
/// Nothing is netted or estimated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BrokerSummary {
|
||||
/// Resolved ticker, e.g. `BBCA.JK`.
|
||||
pub symbol: String,
|
||||
/// First trading date covered, as the source applied it.
|
||||
pub from: NaiveDate,
|
||||
/// Last trading date covered, as the source applied it.
|
||||
pub to: NaiveDate,
|
||||
pub investor: BrokerInvestor,
|
||||
pub board: BrokerBoard,
|
||||
/// Top buyers by lots, most first (up to 10).
|
||||
pub buyers: Vec<BrokerFlow>,
|
||||
/// Top sellers by lots, most first (up to 10).
|
||||
pub sellers: Vec<BrokerFlow>,
|
||||
/// Value traded over the range on the selected board, whole IDR. `None`
|
||||
/// with one investor side selected: the source's footer then covers only
|
||||
/// that side's buying, not the trades the lists show.
|
||||
pub total_value: Option<i64>,
|
||||
/// Lots traded over the range on the selected board; `None` as for
|
||||
/// `total_value`.
|
||||
pub total_lots: Option<i64>,
|
||||
/// Average traded price, whole IDR; `None` when nothing traded, or as
|
||||
/// for `total_value`.
|
||||
pub average_price: Option<i64>,
|
||||
/// Value the selected investors bought over the range on the selected
|
||||
/// board, whole IDR: `total_value` for all investors, and for one side
|
||||
/// the source's footer, which then covers that side's buying.
|
||||
pub buy_value: Option<i64>,
|
||||
/// Value the selected investors sold, whole IDR: `total_value` for all
|
||||
/// investors; for one side on all boards, its buying less its net
|
||||
/// (foreign net, or its negative for domestic investors). `None` for one
|
||||
/// side on one board, where the source gives no net to derive it from.
|
||||
pub sell_value: Option<i64>,
|
||||
/// Foreign buy value minus foreign sell value over the range, whole IDR,
|
||||
/// across all boards. The source does not apply the board filter to it,
|
||||
/// so it is `None` for a summary of one board.
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// True when the source abbreviated at least one figure (`1.6 M` lots,
|
||||
/// `841.2 B` IDR), so those figures carry only one decimal of precision.
|
||||
pub rounded: bool,
|
||||
}
|
||||
|
||||
impl BrokerSummary {
|
||||
/// No trades in the range (weekend, holiday, suspension).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buyers.is_empty() && self.sellers.is_empty()
|
||||
}
|
||||
}
|
||||
|
|
@ -86,6 +86,14 @@ impl Idx {
|
|||
crate::market::Market { idx: self }
|
||||
}
|
||||
|
||||
pub fn broker(&self) -> crate::broker::Broker<'_> {
|
||||
crate::broker::Broker { idx: self }
|
||||
}
|
||||
|
||||
pub fn flow(&self) -> crate::flow::Flow<'_> {
|
||||
crate::flow::Flow { idx: self }
|
||||
}
|
||||
|
||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||
crate::financial::Financial { idx: self }
|
||||
}
|
||||
|
|
@ -103,6 +111,18 @@ impl Idx {
|
|||
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 {
|
||||
format!("{}-{key}", self.provider.kind().as_str())
|
||||
}
|
||||
|
|
|
|||
499
crates/idx-core/src/flow/mod.rs
Normal file
499
crates/idx-core/src/flow/mod.rs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
//! Foreign flow: what foreign investors bought and sold of a stock, session
|
||||
//! by session (end of day). IPOT (the default) needs two plain requests per
|
||||
//! session; IDX's trading summary covers every stock in one request per
|
||||
//! session, cached by date, but needs curl-impersonate.
|
||||
|
||||
pub mod types;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||
|
||||
use crate::api::{today_wib, trading_summary_provider};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{FlowRange, FlowSource, ForeignFlow, ForeignFlowDay, StockDay};
|
||||
|
||||
/// Most sessions `FlowRange::Latest` reads.
|
||||
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,
|
||||
}
|
||||
|
||||
/// What one weekday held for the stock.
|
||||
enum Day {
|
||||
/// The market (or, for IPOT, the stock) did not trade.
|
||||
NoSession,
|
||||
/// The market traded without the stock (a suspension).
|
||||
Absent,
|
||||
Traded {
|
||||
day: Box<ForeignFlowDay>,
|
||||
rounded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
struct Fetch {
|
||||
day: Day,
|
||||
fetched_at: DateTime<Utc>,
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
impl Flow<'_> {
|
||||
/// Today in WIB.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
today_wib()
|
||||
}
|
||||
|
||||
/// Foreign buying and selling of `symbol` over `range`, from `source`.
|
||||
pub fn foreign(
|
||||
&self,
|
||||
symbol: &str,
|
||||
range: FlowRange,
|
||||
source: FlowSource,
|
||||
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 days = Vec::new();
|
||||
let mut sessions = 0;
|
||||
let mut rounded = false;
|
||||
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 fetch = match source {
|
||||
FlowSource::Ipot => self.ipot_day(&resolved, date, policy),
|
||||
FlowSource::Idx => self.idx_day(&code, date, today, policy),
|
||||
};
|
||||
let fetch = match fetch {
|
||||
Ok(fetch) => fetch,
|
||||
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
// Every consulted session counts toward freshness, empty ones too.
|
||||
fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at)));
|
||||
stale |= fetch.stale;
|
||||
match fetch.day {
|
||||
Day::NoSession => continue,
|
||||
Day::Absent => {}
|
||||
Day::Traded {
|
||||
day,
|
||||
rounded: day_rounded,
|
||||
} => {
|
||||
days.push(*day);
|
||||
rounded |= day_rounded;
|
||||
}
|
||||
}
|
||||
sessions += 1;
|
||||
}
|
||||
if sessions == 0 {
|
||||
return Err(if policy == CachePolicy::Offline {
|
||||
IdxError::CacheMiss(format!(
|
||||
"{}-flow/{resolved} (no cached session)",
|
||||
source.as_str()
|
||||
))
|
||||
} else {
|
||||
IdxError::NoMarketData(format!("{resolved}: no sessions in range"))
|
||||
});
|
||||
}
|
||||
days.reverse();
|
||||
let flow =
|
||||
foreign_flow(&resolved, source, days, rounded).ok_or(IdxError::SymbolNotFound(code))?;
|
||||
Ok(Fetched {
|
||||
data: flow,
|
||||
source: source.as_str(),
|
||||
fetched_at: fetched_at.unwrap_or_else(Utc::now),
|
||||
stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// One session from IPOT: the all-investor summary for the value traded,
|
||||
/// the foreign one for foreign buying, selling and net. Both go through
|
||||
/// the broker summary cache.
|
||||
fn ipot_day(
|
||||
&self,
|
||||
resolved: &str,
|
||||
date: NaiveDate,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetch, IdxError> {
|
||||
let broker = self.idx.broker();
|
||||
let all_query = BrokerQuery {
|
||||
from: date,
|
||||
to: date,
|
||||
investor: BrokerInvestor::All,
|
||||
board: BrokerBoard::All,
|
||||
};
|
||||
let all = broker.summary(resolved, &all_query, policy)?;
|
||||
if all.data.is_empty() {
|
||||
return Ok(Fetch {
|
||||
day: Day::NoSession,
|
||||
fetched_at: all.fetched_at,
|
||||
stale: all.stale,
|
||||
});
|
||||
}
|
||||
let foreign_query = BrokerQuery {
|
||||
investor: BrokerInvestor::Foreign,
|
||||
..all_query
|
||||
};
|
||||
let foreign = broker.summary(resolved, &foreign_query, policy)?;
|
||||
Ok(Fetch {
|
||||
day: Day::Traded {
|
||||
day: Box::new(ipot_flow_day(date, &all.data, &foreign.data)),
|
||||
rounded: all.data.rounded || foreign.data.rounded,
|
||||
},
|
||||
fetched_at: all.fetched_at.min(foreign.fetched_at),
|
||||
stale: all.stale || foreign.stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// One session from IDX's trading summary of every stock.
|
||||
fn idx_day(
|
||||
&self,
|
||||
code: &str,
|
||||
date: NaiveDate,
|
||||
today: NaiveDate,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetch, IdxError> {
|
||||
let session = self.session(date, today, policy)?;
|
||||
let day = if session.data.is_empty() {
|
||||
Day::NoSession
|
||||
} else {
|
||||
match session.data.iter().find(|row| row.code == code) {
|
||||
Some(row) => Day::Traded {
|
||||
day: Box::new(idx_flow_day(row)),
|
||||
rounded: false,
|
||||
},
|
||||
None => Day::Absent,
|
||||
}
|
||||
};
|
||||
Ok(Fetch {
|
||||
day,
|
||||
fetched_at: session.fetched_at,
|
||||
stale: session.stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every stock's summary for one session; empty without trading.
|
||||
fn session(
|
||||
&self,
|
||||
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: FlowSource::Idx.as_str(),
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
/// `shares` at the session's average price (value over volume), whole IDR.
|
||||
fn at_average(shares: Option<i64>, value: Option<i64>, volume: Option<i64>) -> Option<i64> {
|
||||
match (shares, value, volume) {
|
||||
(Some(shares), Some(value), Some(volume)) if volume > 0 => {
|
||||
i64::try_from(i128::from(shares) * i128::from(value) / i128::from(volume)).ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreign buying plus selling over twice `total`.
|
||||
fn share(buy: Option<i64>, sell: Option<i64>, total: Option<i64>) -> Option<i64> {
|
||||
let traded = buy?.checked_add(sell?)?;
|
||||
bps(traded, total?.checked_mul(2)?)
|
||||
}
|
||||
|
||||
/// One IPOT session: the value traded from the all-investor summary, the
|
||||
/// foreign figures from the foreign one (`buy_value` is its footer,
|
||||
/// `sell_value` that less the foreign net).
|
||||
pub fn ipot_flow_day(
|
||||
date: NaiveDate,
|
||||
all: &BrokerSummary,
|
||||
foreign: &BrokerSummary,
|
||||
) -> ForeignFlowDay {
|
||||
let foreign_net_value = foreign.foreign_net_value.or(all.foreign_net_value);
|
||||
ForeignFlowDay {
|
||||
date,
|
||||
close: None,
|
||||
change_bps: None,
|
||||
volume: all.total_lots.and_then(|lots| lots.checked_mul(100)),
|
||||
value: all.total_value,
|
||||
foreign_buy: None,
|
||||
foreign_sell: None,
|
||||
foreign_net: None,
|
||||
foreign_buy_value: foreign.buy_value,
|
||||
foreign_sell_value: foreign.sell_value,
|
||||
foreign_net_value,
|
||||
foreign_share_bps: share(foreign.buy_value, foreign.sell_value, all.total_value),
|
||||
non_regular_bps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One IDX session for one stock.
|
||||
pub fn idx_flow_day(row: &StockDay) -> ForeignFlowDay {
|
||||
let foreign_net = row
|
||||
.foreign_buy
|
||||
.zip(row.foreign_sell)
|
||||
.map(|(buy, sell)| buy - sell);
|
||||
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_buy_value: at_average(row.foreign_buy, row.value, row.volume),
|
||||
foreign_sell_value: at_average(row.foreign_sell, row.value, row.volume),
|
||||
foreign_net_value: at_average(foreign_net, row.value, row.volume),
|
||||
foreign_share_bps: share(row.foreign_buy, row.foreign_sell, row.volume),
|
||||
// `value` is the regular board only; the share is of all trading.
|
||||
non_regular_bps: row
|
||||
.non_regular_value
|
||||
.zip(row.value)
|
||||
.and_then(|(part, regular)| bps(part, part.checked_add(regular)?)),
|
||||
}
|
||||
}
|
||||
|
||||
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 `days` (one stock, oldest first); `None` without days.
|
||||
pub fn foreign_flow(
|
||||
symbol: &str,
|
||||
source: FlowSource,
|
||||
days: Vec<ForeignFlowDay>,
|
||||
rounded: bool,
|
||||
) -> Option<ForeignFlow> {
|
||||
let (from, to) = (days.first()?.date, days.last()?.date);
|
||||
// IDX has exact shares; IPOT has values.
|
||||
let foreign_share_bps = match source {
|
||||
FlowSource::Idx => share(
|
||||
sum(&days, |day| day.foreign_buy),
|
||||
sum(&days, |day| day.foreign_sell),
|
||||
sum(&days, |day| day.volume),
|
||||
),
|
||||
FlowSource::Ipot => share(
|
||||
sum(&days, |day| day.foreign_buy_value),
|
||||
sum(&days, |day| day.foreign_sell_value),
|
||||
sum(&days, |day| day.value),
|
||||
),
|
||||
};
|
||||
Some(ForeignFlow {
|
||||
symbol: symbol.to_string(),
|
||||
source,
|
||||
from,
|
||||
to,
|
||||
foreign_net: sum(&days, |day| day.foreign_net),
|
||||
foreign_buy_value: sum(&days, |day| day.foreign_buy_value),
|
||||
foreign_sell_value: sum(&days, |day| day.foreign_sell_value),
|
||||
foreign_net_value: sum(&days, |day| day.foreign_net_value),
|
||||
foreign_share_bps,
|
||||
rounded,
|
||||
days,
|
||||
})
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
|
||||
fn idx_flow(rows: &[StockDay]) -> Option<ForeignFlow> {
|
||||
let days = rows.iter().map(idx_flow_day).collect();
|
||||
foreign_flow("TEST.JK", FlowSource::Idx, days, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_foreign_flow_sums_sessions_and_estimates_value() {
|
||||
let flow =
|
||||
idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))]).unwrap();
|
||||
let first = &flow.days[0];
|
||||
assert_eq!(
|
||||
(first.foreign_net, first.change_bps),
|
||||
(Some(400), Some(1000))
|
||||
);
|
||||
// 400 shares at 1,100 (value over volume).
|
||||
assert_eq!(first.foreign_net_value, Some(440_000));
|
||||
assert_eq!(
|
||||
(first.foreign_buy_value, first.foreign_sell_value),
|
||||
(Some(660_000), Some(220_000))
|
||||
);
|
||||
assert_eq!(first.foreign_share_bps, Some(4000));
|
||||
// 110,000 of 1,100,000 regular plus 110,000 non-regular.
|
||||
assert_eq!(first.non_regular_bps, Some(909));
|
||||
assert_eq!(flow.days[1].change_bps, Some(-500));
|
||||
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 = idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)]).unwrap();
|
||||
assert_eq!(flow.days[1].foreign_net, None);
|
||||
assert_eq!((flow.foreign_net, flow.foreign_share_bps), (None, None));
|
||||
assert!(idx_flow(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_regular_share_is_of_all_trading() {
|
||||
let raw = include_str!("../../tests/fixtures/idx_stocksummary_20261014.json");
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 14).unwrap();
|
||||
let rows = crate::api::idx::parse_stock_summary(raw, date).unwrap();
|
||||
let bbri: Vec<StockDay> = rows.into_iter().filter(|row| row.code == "BBRI").collect();
|
||||
let flow = idx_flow(&bbri).unwrap();
|
||||
// 172.3B non-regular against 338.5B regular: 33.74%, not 50.91%.
|
||||
assert_eq!(flow.days[0].non_regular_bps, Some(3374));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipot_flow_day_reads_foreign_buying_and_selling_from_the_summaries() {
|
||||
let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
|
||||
let summary = |investor, name: &str| {
|
||||
let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap();
|
||||
let raw = crate::api::ipot::summary_from_html("BBCA", &html).unwrap();
|
||||
let query = BrokerQuery {
|
||||
from: day,
|
||||
to: day,
|
||||
investor,
|
||||
board: BrokerBoard::All,
|
||||
};
|
||||
crate::broker::summary_from_raw("BBCA.JK", &query, raw)
|
||||
};
|
||||
let all = summary(BrokerInvestor::All, "ipot_brokersummary_bbca_20261016.html");
|
||||
let foreign = summary(
|
||||
BrokerInvestor::Foreign,
|
||||
"ipot_brokersummary_bbca_20261016_foreign_all.html",
|
||||
);
|
||||
let flow = ipot_flow_day(day, &all, &foreign);
|
||||
assert_eq!(flow.value, Some(6_600_000_000_000));
|
||||
assert_eq!(flow.volume, Some(1_260_000_000));
|
||||
assert_eq!(
|
||||
(
|
||||
flow.foreign_buy_value,
|
||||
flow.foreign_sell_value,
|
||||
flow.foreign_net_value
|
||||
),
|
||||
(
|
||||
Some(1_700_000_000_000),
|
||||
Some(4_000_000_000_000),
|
||||
Some(-2_300_000_000_000)
|
||||
)
|
||||
);
|
||||
// (1.7 T + 4.0 T) / (2 * 6.6 T).
|
||||
assert_eq!(flow.foreign_share_bps, Some(4318));
|
||||
assert_eq!((flow.close, flow.foreign_net), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_range_bounds() {
|
||||
let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
184
crates/idx-core/src/flow/types.rs
Normal file
184
crates/idx-core/src/flow/types.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Foreign flow types: what foreign investors bought and sold of a stock,
|
||||
//! session by session (end of day), from IPOT or IDX's trading summary.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
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;
|
||||
|
||||
/// Where a foreign flow comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FlowSource {
|
||||
/// Indo Premier's broker summary: foreign buy, sell and net value across
|
||||
/// all boards, two plain requests per session. Figures of a million and
|
||||
/// more are rounded to one decimal.
|
||||
#[default]
|
||||
Ipot,
|
||||
/// IDX's own trading summary: exact foreign share counts, close and the
|
||||
/// non-regular share, regular board. Needs curl-impersonate and a
|
||||
/// network IDX's Cloudflare lets through.
|
||||
Idx,
|
||||
}
|
||||
|
||||
impl FlowSource {
|
||||
pub const ALL: [Self; 2] = [Self::Ipot, Self::Idx];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ipot => "ipot",
|
||||
Self::Idx => "idx",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FlowSource {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"ipot" => Ok(Self::Ipot),
|
||||
"idx" => Ok(Self::Idx),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid source '{value}'; use ipot or idx"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One stock's trading summary for one session, as IDX publishes it.
|
||||
/// Prices are whole IDR, volumes are shares. Volume, value and the foreign
|
||||
/// figures cover the regular board only (across a week of every stock, no
|
||||
/// foreign figure exceeded regular volume, even where non-regular volume was
|
||||
/// more than twice it); the non-regular figures cover the rest.
|
||||
#[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 on the regular board, whole IDR.
|
||||
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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session. Which figures are present depends on the source: IPOT gives
|
||||
/// values across all boards, IDX gives exact shares on the regular board (and
|
||||
/// values estimated from them) with the close and the non-regular share.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForeignFlowDay {
|
||||
pub date: NaiveDate,
|
||||
/// Close, whole IDR (IDX only).
|
||||
pub close: Option<i64>,
|
||||
/// Close against the previous close, basis points (IDX only).
|
||||
pub change_bps: Option<i64>,
|
||||
/// Shares traded: the regular board (IDX), or all boards from lots
|
||||
/// (IPOT).
|
||||
pub volume: Option<i64>,
|
||||
/// Value traded, whole IDR: the regular board (IDX) or all boards (IPOT).
|
||||
pub value: Option<i64>,
|
||||
/// Shares foreign investors bought (IDX only).
|
||||
pub foreign_buy: Option<i64>,
|
||||
/// Shares foreign investors sold (IDX only).
|
||||
pub foreign_sell: Option<i64>,
|
||||
/// `foreign_buy - foreign_sell`, shares (IDX only).
|
||||
pub foreign_net: Option<i64>,
|
||||
/// Value foreign investors bought, whole IDR: as published (IPOT), or
|
||||
/// estimated at the session's average price (IDX).
|
||||
pub foreign_buy_value: Option<i64>,
|
||||
/// Value foreign investors sold, whole IDR: their buying less their net
|
||||
/// (IPOT), or estimated at the session's average price (IDX).
|
||||
pub foreign_sell_value: Option<i64>,
|
||||
/// Foreign buy value minus sell value, whole IDR: as published (IPOT), or
|
||||
/// net shares at the session's average price (IDX, an estimate).
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// Foreign buying and selling over twice the session's trading: foreign
|
||||
/// investors' share of it, basis points.
|
||||
pub foreign_share_bps: Option<i64>,
|
||||
/// Value traded outside the regular board (negotiated and cash), basis
|
||||
/// points of all value traded (IDX only).
|
||||
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,
|
||||
pub source: FlowSource,
|
||||
/// 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 (IDX only); `None` when a session lacks
|
||||
/// a figure.
|
||||
pub foreign_net: Option<i64>,
|
||||
/// Foreign buy value over the sessions, whole IDR.
|
||||
pub foreign_buy_value: Option<i64>,
|
||||
/// Foreign sell value over the sessions, whole IDR.
|
||||
pub foreign_sell_value: Option<i64>,
|
||||
/// Foreign net value over the sessions, whole IDR.
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// Foreign investors' share of trading over the sessions, basis points.
|
||||
pub foreign_share_bps: Option<i64>,
|
||||
/// The source rounded figures of a million and more to one decimal.
|
||||
pub rounded: bool,
|
||||
}
|
||||
|
|
@ -4,12 +4,14 @@
|
|||
|
||||
pub mod analysis;
|
||||
pub mod api;
|
||||
pub mod broker;
|
||||
pub mod cache;
|
||||
mod client;
|
||||
pub mod config;
|
||||
pub mod curl_impersonate;
|
||||
pub mod error;
|
||||
pub mod financial;
|
||||
pub mod flow;
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod investor;
|
||||
pub mod market;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ use std::cmp::Ordering;
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||
|
||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||
use crate::api::{history_provider, history_source};
|
||||
use crate::api::{history_provider, history_source, idx_offset};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
|
|
@ -32,10 +34,11 @@ impl Market<'_> {
|
|||
) -> Result<Vec<Fetched<Quote>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let bucket = idx.bucket("quote");
|
||||
let session = latest_session(Utc::now());
|
||||
let mut quotes = Vec::with_capacity(symbols.len());
|
||||
for symbol in symbols {
|
||||
let resolved = idx.resolve(symbol.as_ref())?;
|
||||
quotes.push(cached_fetch(
|
||||
let mut quote = cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
|
|
@ -47,7 +50,12 @@ impl Market<'_> {
|
|||
policy,
|
||||
|| idx.provider.market().quote(&resolved),
|
||||
Ok,
|
||||
)?);
|
||||
)?;
|
||||
// The session rule is IDX's; other exchanges keep other hours.
|
||||
if resolved.ends_with(".JK") {
|
||||
mark_behind_session(&mut quote.data, session);
|
||||
}
|
||||
quotes.push(quote);
|
||||
}
|
||||
Ok(quotes)
|
||||
}
|
||||
|
|
@ -131,10 +139,40 @@ impl Market<'_> {
|
|||
)?;
|
||||
sort_screener_quotes(&mut fetched.data, filter);
|
||||
fetched.data.truncate(limit);
|
||||
if region == ScreenRegion::Id {
|
||||
let session = latest_session(Utc::now());
|
||||
for quote in &mut fetched.data {
|
||||
mark_behind_session(quote, session);
|
||||
}
|
||||
}
|
||||
Ok(fetched)
|
||||
}
|
||||
}
|
||||
|
||||
/// When IDX's post-trading session ends, in WIB; MSN stamps a day's closing
|
||||
/// quote with this time.
|
||||
const SESSION_END: NaiveTime = NaiveTime::from_hms_opt(16, 15, 0).expect("valid time");
|
||||
|
||||
/// The latest weekday whose IDX session has ended by `now`. Exchange holidays
|
||||
/// are not known offline, so on a weekday holiday this is the holiday itself.
|
||||
pub(crate) fn latest_session(now: DateTime<Utc>) -> NaiveDate {
|
||||
let now = now.with_timezone(&idx_offset());
|
||||
let mut day = now.date_naive();
|
||||
if now.time() < SESSION_END {
|
||||
day -= Duration::days(1);
|
||||
}
|
||||
while matches!(day.weekday(), Weekday::Sat | Weekday::Sun) {
|
||||
day -= Duration::days(1);
|
||||
}
|
||||
day
|
||||
}
|
||||
|
||||
fn mark_behind_session(quote: &mut Quote, session: NaiveDate) {
|
||||
quote.behind_session = quote
|
||||
.as_of
|
||||
.is_some_and(|as_of| as_of.with_timezone(&idx_offset()).date_naive() < session);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScreenQuery {
|
||||
pub filter: ScreenFilter,
|
||||
|
|
@ -320,7 +358,9 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ScreenFilter, ScreenRegion, sort_screener_quotes};
|
||||
use super::{
|
||||
ScreenFilter, ScreenRegion, latest_session, mark_behind_session, sort_screener_quotes,
|
||||
};
|
||||
use crate::api::types::Quote;
|
||||
use crate::error::IdxError;
|
||||
|
||||
|
|
@ -339,6 +379,53 @@ mod tests {
|
|||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
behind_session: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn wib(raw: &str) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::parse_from_rfc3339(&format!("{raw}+07:00"))
|
||||
.unwrap()
|
||||
.to_utc()
|
||||
}
|
||||
|
||||
fn day(raw: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_session_is_the_last_weekday_past_the_post_trading_close() {
|
||||
// 2026-10-16 is a Friday.
|
||||
for (now, session) in [
|
||||
("2026-10-16T16:53:00", "2026-10-16"),
|
||||
("2026-10-16T16:15:00", "2026-10-16"),
|
||||
("2026-10-16T16:14:59", "2026-10-15"),
|
||||
("2026-10-16T00:30:00", "2026-10-15"),
|
||||
("2026-10-17T12:00:00", "2026-10-16"),
|
||||
("2026-10-18T23:59:59", "2026-10-16"),
|
||||
("2026-10-19T10:00:00", "2026-10-16"),
|
||||
("2026-10-19T16:15:00", "2026-10-19"),
|
||||
] {
|
||||
assert_eq!(latest_session(wib(now)), day(session), "now {now}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_from_before_the_latest_session_is_behind_it() {
|
||||
// Issue #50: at 16:53 WIB on the 16th, MSN still served the 15th.
|
||||
let session = latest_session(wib("2026-10-16T16:53:00"));
|
||||
for (as_of, behind) in [
|
||||
(Some("2026-10-15T16:15:00+07:00"), true),
|
||||
(Some("2026-10-16T16:15:00+07:00"), false),
|
||||
(Some("2026-10-16T09:15:00Z"), false),
|
||||
(Some("2026-10-15T17:30:00Z"), false),
|
||||
(Some("2025-02-17T16:14:58+07:00"), true),
|
||||
(None, false),
|
||||
] {
|
||||
let mut quote = sample_quote("BBCA.JK", None);
|
||||
quote.as_of = as_of.map(|t| chrono::DateTime::parse_from_rfc3339(t).unwrap());
|
||||
mark_behind_session(&mut quote, session);
|
||||
assert_eq!(quote.behind_session, behind, "as_of {as_of:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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":[]}
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">319,263 </span></td>
|
||||
<td align="right"><span class="text-up">191.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,993 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">248,149 </span></td>
|
||||
<td align="right"><span class="text-down">148.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,988 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">91,178 </span></td>
|
||||
<td align="right"><span class="text-up">54.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,998 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">169,689 </span></td>
|
||||
<td align="right"><span class="text-down">101.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,000 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">82,364 </span></td>
|
||||
<td align="right"><span class="text-up">49.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,991 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">142,270 </span></td>
|
||||
<td align="right"><span class="text-down">85.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,980 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">70,119 </span></td>
|
||||
<td align="right"><span class="text-up">41.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,973 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">109,896 </span></td>
|
||||
<td align="right"><span class="text-down">65.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,978 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">66,085 </span></td>
|
||||
<td align="right"><span class="text-up">39.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,982 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">51,291 </span></td>
|
||||
<td align="right"><span class="text-down">30.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,977 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">46,535 </span></td>
|
||||
<td align="right"><span class="text-up">27.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,989 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">35,962 </span></td>
|
||||
<td align="right"><span class="text-down">21.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,988 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">31,891 </span></td>
|
||||
<td align="right"><span class="text-up">19.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,983 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">33,257 </span></td>
|
||||
<td align="right"><span class="text-down">19.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,987 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XC</span></td>
|
||||
<td align="right"><span class="text-up">31,403 </span></td>
|
||||
<td align="right"><span class="text-up">18.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,983 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-local">CP</span></td>
|
||||
<td align="right"><span class="text-down">29,928 </span></td>
|
||||
<td align="right"><span class="text-down">17.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,962 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">24,560 </span></td>
|
||||
<td align="right"><span class="text-up">14.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,967 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-down">29,063 </span></td>
|
||||
<td align="right"><span class="text-down">17.5 B</span></td>
|
||||
<td align="right"><span class="text-down">6,023 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">20,056 </span></td>
|
||||
<td align="right"><span class="text-up">12.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,970 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-down">23,464 </span></td>
|
||||
<td align="right"><span class="text-down">14.1 B</span></td>
|
||||
<td align="right"><span class="text-down">6,002 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 587.1 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -55.9 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 980,415 </span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,988 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.7 M</span></td>
|
||||
<td align="right"><span class="text-up">963.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,573 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">5.1 M</span></td>
|
||||
<td align="right"><span class="text-down">2.7 T</span></td>
|
||||
<td align="right"><span class="text-down">5,336 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">842.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,192 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||
<td align="right"><span class="text-down">928.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,295 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">900.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,780 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||
<td align="right"><span class="text-down">942.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,756 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||
<td align="right"><span class="text-up">781.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,626 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">1.5 M</span></td>
|
||||
<td align="right"><span class="text-down">800.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,239 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||
<td align="right"><span class="text-up">737.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,408 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">1.4 M</span></td>
|
||||
<td align="right"><span class="text-down">786.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,785 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.3 M</span></td>
|
||||
<td align="right"><span class="text-up">687.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,313 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">852,704 </span></td>
|
||||
<td align="right"><span class="text-down">457.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,368 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">1.1 M</span></td>
|
||||
<td align="right"><span class="text-up">643.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,771 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">830,940 </span></td>
|
||||
<td align="right"><span class="text-down">466.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,617 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">973,921 </span></td>
|
||||
<td align="right"><span class="text-up">519.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,333 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">709,658 </span></td>
|
||||
<td align="right"><span class="text-down">393.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,542 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">919,655 </span></td>
|
||||
<td align="right"><span class="text-up">487.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,296 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">621,425 </span></td>
|
||||
<td align="right"><span class="text-down">363.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,847 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">646,162 </span></td>
|
||||
<td align="right"><span class="text-up">341.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,280 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">441,903 </span></td>
|
||||
<td align="right"><span class="text-down">242.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,490 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 9.9 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -4.1 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 18.1 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,471 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/12/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">316,144 </span></td>
|
||||
<td align="right"><span class="text-up">189.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,993 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">201,030 </span></td>
|
||||
<td align="right"><span class="text-down">120.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,992 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">82,364 </span></td>
|
||||
<td align="right"><span class="text-up">49.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,991 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">137,297 </span></td>
|
||||
<td align="right"><span class="text-down">82.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,981 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">25,000 </span></td>
|
||||
<td align="right"><span class="text-up">15.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,989 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">123,872 </span></td>
|
||||
<td align="right"><span class="text-down">74.4 B</span></td>
|
||||
<td align="right"><span class="text-down">6,007 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">24,560 </span></td>
|
||||
<td align="right"><span class="text-up">14.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,967 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">89,396 </span></td>
|
||||
<td align="right"><span class="text-down">53.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,980 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">20,056 </span></td>
|
||||
<td align="right"><span class="text-up">12.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,970 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">25,685 </span></td>
|
||||
<td align="right"><span class="text-down">15.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,985 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">19,945 </span></td>
|
||||
<td align="right"><span class="text-up">12.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,069 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">20,942 </span></td>
|
||||
<td align="right"><span class="text-down">12.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,950 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">8,553 </span></td>
|
||||
<td align="right"><span class="text-up">5.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,075 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">7,476 </span></td>
|
||||
<td align="right"><span class="text-down">4.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,989 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-up">5,000 </span></td>
|
||||
<td align="right"><span class="text-up">3.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,950 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-down">1,084 </span></td>
|
||||
<td align="right"><span class="text-down">647.7 M</span></td>
|
||||
<td align="right"><span class="text-down">5,975 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AG</span></td>
|
||||
<td align="right"><span class="text-up">4,315 </span></td>
|
||||
<td align="right"><span class="text-up">2.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,951 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">782 </span></td>
|
||||
<td align="right"><span class="text-down">465.3 M</span></td>
|
||||
<td align="right"><span class="text-down">5,950 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">3,957 </span></td>
|
||||
<td align="right"><span class="text-up">2.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,979 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-foreign">AG</span></td>
|
||||
<td align="right"><span class="text-down">696 </span></td>
|
||||
<td align="right"><span class="text-down">417.3 M</span></td>
|
||||
<td align="right"><span class="text-down">5,995 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 308.7 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -55.9 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 514,995 </span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,994 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/13/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/13/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">353,553 </span></td>
|
||||
<td align="right"><span class="text-up">209.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,931 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">362,463 </span></td>
|
||||
<td align="right"><span class="text-down">215.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,933 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">123,885 </span></td>
|
||||
<td align="right"><span class="text-up">73.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,919 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">170,087 </span></td>
|
||||
<td align="right"><span class="text-down">100.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,914 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">119,389 </span></td>
|
||||
<td align="right"><span class="text-up">70.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,915 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">131,973 </span></td>
|
||||
<td align="right"><span class="text-down">78.2 B</span></td>
|
||||
<td align="right"><span class="text-down">5,922 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-up">114,823 </span></td>
|
||||
<td align="right"><span class="text-up">68.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,931 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AG</span></td>
|
||||
<td align="right"><span class="text-down">113,249 </span></td>
|
||||
<td align="right"><span class="text-down">67.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,923 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">90,525 </span></td>
|
||||
<td align="right"><span class="text-up">53.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,931 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">99,863 </span></td>
|
||||
<td align="right"><span class="text-down">59.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,904 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">84,572 </span></td>
|
||||
<td align="right"><span class="text-up">50.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,920 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">87,628 </span></td>
|
||||
<td align="right"><span class="text-down">51.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,915 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">54,296 </span></td>
|
||||
<td align="right"><span class="text-up">32.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,928 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">58,039 </span></td>
|
||||
<td align="right"><span class="text-down">34.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,926 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">44,062 </span></td>
|
||||
<td align="right"><span class="text-up">26.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,907 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">31,516 </span></td>
|
||||
<td align="right"><span class="text-down">18.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,905 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">38,109 </span></td>
|
||||
<td align="right"><span class="text-up">22.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,901 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-down">21,672 </span></td>
|
||||
<td align="right"><span class="text-down">12.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,917 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">22,957 </span></td>
|
||||
<td align="right"><span class="text-up">13.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,898 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-down">19,658 </span></td>
|
||||
<td align="right"><span class="text-down">11.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,937 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 738.2 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 30.1 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 1.2 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,921 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/13/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/13/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">347,552 </span></td>
|
||||
<td align="right"><span class="text-up">206.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,931 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">361,884 </span></td>
|
||||
<td align="right"><span class="text-down">214.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,933 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">119,389 </span></td>
|
||||
<td align="right"><span class="text-up">70.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,915 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">134,566 </span></td>
|
||||
<td align="right"><span class="text-down">79.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,918 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-up">112,063 </span></td>
|
||||
<td align="right"><span class="text-up">66.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,932 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">AG</span></td>
|
||||
<td align="right"><span class="text-down">113,249 </span></td>
|
||||
<td align="right"><span class="text-down">67.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,923 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">90,525 </span></td>
|
||||
<td align="right"><span class="text-up">53.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,931 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">99,863 </span></td>
|
||||
<td align="right"><span class="text-down">59.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,904 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">84,572 </span></td>
|
||||
<td align="right"><span class="text-up">50.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,920 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">57,345 </span></td>
|
||||
<td align="right"><span class="text-down">34.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,927 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">82,117 </span></td>
|
||||
<td align="right"><span class="text-up">48.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,925 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">49,256 </span></td>
|
||||
<td align="right"><span class="text-down">29.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,918 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">48,065 </span></td>
|
||||
<td align="right"><span class="text-up">28.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,932 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">DP</span></td>
|
||||
<td align="right"><span class="text-down">12,747 </span></td>
|
||||
<td align="right"><span class="text-down">7.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,917 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-up">12,342 </span></td>
|
||||
<td align="right"><span class="text-up">7.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,905 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">11,973 </span></td>
|
||||
<td align="right"><span class="text-down">7.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,924 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">3,683 </span></td>
|
||||
<td align="right"><span class="text-up">2.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,929 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-down">8,420 </span></td>
|
||||
<td align="right"><span class="text-down">5.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,925 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">EP</span></td>
|
||||
<td align="right"><span class="text-up">3,000 </span></td>
|
||||
<td align="right"><span class="text-up">1.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,925 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-down">5,000 </span></td>
|
||||
<td align="right"><span class="text-down">3.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,925 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 537.3 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 30.1 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 906,459 </span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,927 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/14/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/14/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">554,786 </span></td>
|
||||
<td align="right"><span class="text-up">338.9 B</span></td>
|
||||
<td align="right"><span class="text-up">6,109 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">525,906 </span></td>
|
||||
<td align="right"><span class="text-down">319.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,081 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">485,138 </span></td>
|
||||
<td align="right"><span class="text-up">294.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,075 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">377,446 </span></td>
|
||||
<td align="right"><span class="text-down">230.0 B</span></td>
|
||||
<td align="right"><span class="text-down">6,094 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">187,056 </span></td>
|
||||
<td align="right"><span class="text-up">113.8 B</span></td>
|
||||
<td align="right"><span class="text-up">6,085 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">164,627 </span></td>
|
||||
<td align="right"><span class="text-down">100.4 B</span></td>
|
||||
<td align="right"><span class="text-down">6,096 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">160,092 </span></td>
|
||||
<td align="right"><span class="text-up">97.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,062 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-down">103,273 </span></td>
|
||||
<td align="right"><span class="text-down">62.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,082 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">79,012 </span></td>
|
||||
<td align="right"><span class="text-up">48.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,071 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">98,516 </span></td>
|
||||
<td align="right"><span class="text-down">59.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,070 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">70,651 </span></td>
|
||||
<td align="right"><span class="text-up">43.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,096 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">73,085 </span></td>
|
||||
<td align="right"><span class="text-down">44.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,098 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">65,000 </span></td>
|
||||
<td align="right"><span class="text-up">39.5 B</span></td>
|
||||
<td align="right"><span class="text-up">6,073 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-down">51,697 </span></td>
|
||||
<td align="right"><span class="text-down">31.4 B</span></td>
|
||||
<td align="right"><span class="text-down">6,075 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-up">31,865 </span></td>
|
||||
<td align="right"><span class="text-up">19.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,027 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">48,169 </span></td>
|
||||
<td align="right"><span class="text-down">29.3 B</span></td>
|
||||
<td align="right"><span class="text-down">6,082 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">30,758 </span></td>
|
||||
<td align="right"><span class="text-up">18.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,066 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">46,536 </span></td>
|
||||
<td align="right"><span class="text-down">28.3 B</span></td>
|
||||
<td align="right"><span class="text-down">6,074 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">27,270 </span></td>
|
||||
<td align="right"><span class="text-up">16.6 B</span></td>
|
||||
<td align="right"><span class="text-up">6,085 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-down">39,660 </span></td>
|
||||
<td align="right"><span class="text-down">24.1 B</span></td>
|
||||
<td align="right"><span class="text-down">6,070 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 1.1 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 216.6 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 1.8 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 6,084 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/14/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/14/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">554,012 </span></td>
|
||||
<td align="right"><span class="text-up">338.5 B</span></td>
|
||||
<td align="right"><span class="text-up">6,109 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">525,906 </span></td>
|
||||
<td align="right"><span class="text-down">319.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,081 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">485,138 </span></td>
|
||||
<td align="right"><span class="text-up">294.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,075 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">368,595 </span></td>
|
||||
<td align="right"><span class="text-down">224.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,094 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">187,042 </span></td>
|
||||
<td align="right"><span class="text-up">113.8 B</span></td>
|
||||
<td align="right"><span class="text-up">6,085 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">163,864 </span></td>
|
||||
<td align="right"><span class="text-down">99.9 B</span></td>
|
||||
<td align="right"><span class="text-down">6,096 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">156,660 </span></td>
|
||||
<td align="right"><span class="text-up">95.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,063 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">98,516 </span></td>
|
||||
<td align="right"><span class="text-down">59.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,070 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">79,012 </span></td>
|
||||
<td align="right"><span class="text-up">48.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,071 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">59,747 </span></td>
|
||||
<td align="right"><span class="text-down">36.5 B</span></td>
|
||||
<td align="right"><span class="text-down">6,103 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">70,651 </span></td>
|
||||
<td align="right"><span class="text-up">43.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,096 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">20,374 </span></td>
|
||||
<td align="right"><span class="text-down">12.4 B</span></td>
|
||||
<td align="right"><span class="text-down">6,074 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">65,000 </span></td>
|
||||
<td align="right"><span class="text-up">39.5 B</span></td>
|
||||
<td align="right"><span class="text-up">6,073 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-down">17,554 </span></td>
|
||||
<td align="right"><span class="text-down">10.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,056 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">17,593 </span></td>
|
||||
<td align="right"><span class="text-up">10.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,067 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">12,729 </span></td>
|
||||
<td align="right"><span class="text-down">7.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,094 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-up">13,632 </span></td>
|
||||
<td align="right"><span class="text-up">8.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,050 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">EP</span></td>
|
||||
<td align="right"><span class="text-down">3,000 </span></td>
|
||||
<td align="right"><span class="text-down">1.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,075 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">DP</span></td>
|
||||
<td align="right"><span class="text-up">474 </span></td>
|
||||
<td align="right"><span class="text-up">288.2 M</span></td>
|
||||
<td align="right"><span class="text-up">6,080 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">IU</span></td>
|
||||
<td align="right"><span class="text-down">1,200 </span></td>
|
||||
<td align="right"><span class="text-down">729.0 M</span></td>
|
||||
<td align="right"><span class="text-down">6,075 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 991.8 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 216.6 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 1.6 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 6,087 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/15/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/15/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">447,791 </span></td>
|
||||
<td align="right"><span class="text-up">271.5 B</span></td>
|
||||
<td align="right"><span class="text-up">6,064 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">335,078 </span></td>
|
||||
<td align="right"><span class="text-down">203.1 B</span></td>
|
||||
<td align="right"><span class="text-down">6,062 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">284,818 </span></td>
|
||||
<td align="right"><span class="text-up">172.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,062 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">229,539 </span></td>
|
||||
<td align="right"><span class="text-down">139.1 B</span></td>
|
||||
<td align="right"><span class="text-down">6,061 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-up">194,491 </span></td>
|
||||
<td align="right"><span class="text-up">117.8 B</span></td>
|
||||
<td align="right"><span class="text-up">6,059 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">225,482 </span></td>
|
||||
<td align="right"><span class="text-down">136.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,059 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">138,619 </span></td>
|
||||
<td align="right"><span class="text-up">84.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,058 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">179,064 </span></td>
|
||||
<td align="right"><span class="text-down">108.5 B</span></td>
|
||||
<td align="right"><span class="text-down">6,059 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">123,061 </span></td>
|
||||
<td align="right"><span class="text-up">74.5 B</span></td>
|
||||
<td align="right"><span class="text-up">6,057 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">143,526 </span></td>
|
||||
<td align="right"><span class="text-down">86.9 B</span></td>
|
||||
<td align="right"><span class="text-down">6,053 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">78,820 </span></td>
|
||||
<td align="right"><span class="text-up">47.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,050 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">68,285 </span></td>
|
||||
<td align="right"><span class="text-down">41.3 B</span></td>
|
||||
<td align="right"><span class="text-down">6,049 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">72,951 </span></td>
|
||||
<td align="right"><span class="text-up">44.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,055 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-down">63,839 </span></td>
|
||||
<td align="right"><span class="text-down">38.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,050 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">36,383 </span></td>
|
||||
<td align="right"><span class="text-up">22.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,066 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-local">CP</span></td>
|
||||
<td align="right"><span class="text-down">49,275 </span></td>
|
||||
<td align="right"><span class="text-down">29.9 B</span></td>
|
||||
<td align="right"><span class="text-down">6,059 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">26,718 </span></td>
|
||||
<td align="right"><span class="text-up">16.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,039 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">34,325 </span></td>
|
||||
<td align="right"><span class="text-down">20.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,068 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">24,529 </span></td>
|
||||
<td align="right"><span class="text-up">14.8 B</span></td>
|
||||
<td align="right"><span class="text-up">6,052 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-down">27,152 </span></td>
|
||||
<td align="right"><span class="text-down">16.4 B</span></td>
|
||||
<td align="right"><span class="text-down">6,053 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 912.0 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 286.1 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 1.5 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 6,060 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/15/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/15/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">445,640 </span></td>
|
||||
<td align="right"><span class="text-up">270.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,064 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">224,007 </span></td>
|
||||
<td align="right"><span class="text-down">135.8 B</span></td>
|
||||
<td align="right"><span class="text-down">6,061 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">283,676 </span></td>
|
||||
<td align="right"><span class="text-up">172.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,062 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">197,862 </span></td>
|
||||
<td align="right"><span class="text-down">119.9 B</span></td>
|
||||
<td align="right"><span class="text-down">6,058 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-up">192,771 </span></td>
|
||||
<td align="right"><span class="text-up">116.8 B</span></td>
|
||||
<td align="right"><span class="text-up">6,059 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">185,436 </span></td>
|
||||
<td align="right"><span class="text-down">112.5 B</span></td>
|
||||
<td align="right"><span class="text-down">6,066 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">138,619 </span></td>
|
||||
<td align="right"><span class="text-up">84.0 B</span></td>
|
||||
<td align="right"><span class="text-up">6,058 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">118,526 </span></td>
|
||||
<td align="right"><span class="text-down">71.7 B</span></td>
|
||||
<td align="right"><span class="text-down">6,053 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">122,365 </span></td>
|
||||
<td align="right"><span class="text-up">74.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,057 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">109,064 </span></td>
|
||||
<td align="right"><span class="text-down">66.1 B</span></td>
|
||||
<td align="right"><span class="text-down">6,064 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">72,951 </span></td>
|
||||
<td align="right"><span class="text-up">44.2 B</span></td>
|
||||
<td align="right"><span class="text-up">6,055 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">25,617 </span></td>
|
||||
<td align="right"><span class="text-down">15.5 B</span></td>
|
||||
<td align="right"><span class="text-down">6,068 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">65,609 </span></td>
|
||||
<td align="right"><span class="text-up">39.7 B</span></td>
|
||||
<td align="right"><span class="text-up">6,051 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">15,460 </span></td>
|
||||
<td align="right"><span class="text-down">9.3 B</span></td>
|
||||
<td align="right"><span class="text-down">6,044 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">36,383 </span></td>
|
||||
<td align="right"><span class="text-up">22.1 B</span></td>
|
||||
<td align="right"><span class="text-up">6,066 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">11,033 </span></td>
|
||||
<td align="right"><span class="text-down">6.7 B</span></td>
|
||||
<td align="right"><span class="text-down">6,069 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-up">6,018 </span></td>
|
||||
<td align="right"><span class="text-up">3.6 B</span></td>
|
||||
<td align="right"><span class="text-up">6,053 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">HD</span></td>
|
||||
<td align="right"><span class="text-down">4,283 </span></td>
|
||||
<td align="right"><span class="text-down">2.6 B</span></td>
|
||||
<td align="right"><span class="text-down">6,058 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AI</span></td>
|
||||
<td align="right"><span class="text-up">200 </span></td>
|
||||
<td align="right"><span class="text-up">120.5 M</span></td>
|
||||
<td align="right"><span class="text-up">6,025 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-down">550 </span></td>
|
||||
<td align="right"><span class="text-down">332.9 M</span></td>
|
||||
<td align="right"><span class="text-down">6,052 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 826.9 B</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 286.1 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 1.4 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 6,061 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">841.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,191 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">4.3 M</span></td>
|
||||
<td align="right"><span class="text-down">2.2 T</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.1 M</span></td>
|
||||
<td align="right"><span class="text-up">600.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,229 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.7 M</span></td>
|
||||
<td align="right"><span class="text-down">908.2 B</span></td>
|
||||
<td align="right"><span class="text-down">5,282 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">543.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,227 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">1.5 M</span></td>
|
||||
<td align="right"><span class="text-down">799.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,238 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">522.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,198 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">687,067 </span></td>
|
||||
<td align="right"><span class="text-down">357.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,202 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">828,163 </span></td>
|
||||
<td align="right"><span class="text-up">432.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,221 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">529,223 </span></td>
|
||||
<td align="right"><span class="text-down">275.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">827,340 </span></td>
|
||||
<td align="right"><span class="text-up">431.7 B</span></td>
|
||||
<td align="right"><span class="text-up">5,218 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">444,696 </span></td>
|
||||
<td align="right"><span class="text-down">233.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,251 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">687,603 </span></td>
|
||||
<td align="right"><span class="text-up">356.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,186 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">419,266 </span></td>
|
||||
<td align="right"><span class="text-down">218.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,219 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">592,667 </span></td>
|
||||
<td align="right"><span class="text-up">309.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,218 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">418,986 </span></td>
|
||||
<td align="right"><span class="text-down">218.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,202 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">441,000 </span></td>
|
||||
<td align="right"><span class="text-up">230.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,220 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-down">357,177 </span></td>
|
||||
<td align="right"><span class="text-down">186.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">398,474 </span></td>
|
||||
<td align="right"><span class="text-up">209.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,268 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">296,269 </span></td>
|
||||
<td align="right"><span class="text-down">155.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,232 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 6.6 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 12.6 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,228 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" selected>Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">841.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,191 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">1.5 M</span></td>
|
||||
<td align="right"><span class="text-down">799.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,238 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.1 M</span></td>
|
||||
<td align="right"><span class="text-up">595.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,229 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">577,558 </span></td>
|
||||
<td align="right"><span class="text-down">300.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">841,738 </span></td>
|
||||
<td align="right"><span class="text-up">439.2 B</span></td>
|
||||
<td align="right"><span class="text-up">5,217 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">448,606 </span></td>
|
||||
<td align="right"><span class="text-down">233.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">824,562 </span></td>
|
||||
<td align="right"><span class="text-up">430.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,218 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-down">356,533 </span></td>
|
||||
<td align="right"><span class="text-down">185.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">824,521 </span></td>
|
||||
<td align="right"><span class="text-up">430.5 B</span></td>
|
||||
<td align="right"><span class="text-up">5,221 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">296,269 </span></td>
|
||||
<td align="right"><span class="text-down">155.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,232 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">587,559 </span></td>
|
||||
<td align="right"><span class="text-up">306.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,217 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-down">264,188 </span></td>
|
||||
<td align="right"><span class="text-down">138.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,242 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">CP</span></td>
|
||||
<td align="right"><span class="text-up">347,229 </span></td>
|
||||
<td align="right"><span class="text-up">181.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,216 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-local">CP</span></td>
|
||||
<td align="right"><span class="text-down">188,644 </span></td>
|
||||
<td align="right"><span class="text-down">98.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,245 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XC</span></td>
|
||||
<td align="right"><span class="text-up">336,997 </span></td>
|
||||
<td align="right"><span class="text-up">176.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,226 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">166,603 </span></td>
|
||||
<td align="right"><span class="text-down">86.6 B</span></td>
|
||||
<td align="right"><span class="text-down">5,201 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KK</span></td>
|
||||
<td align="right"><span class="text-up">302,943 </span></td>
|
||||
<td align="right"><span class="text-up">157.9 B</span></td>
|
||||
<td align="right"><span class="text-up">5,211 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-down">142,119 </span></td>
|
||||
<td align="right"><span class="text-down">74.7 B</span></td>
|
||||
<td align="right"><span class="text-down">5,254 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BQ</span></td>
|
||||
<td align="right"><span class="text-up">245,981 </span></td>
|
||||
<td align="right"><span class="text-up">128.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,228 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-foreign">KK</span></td>
|
||||
<td align="right"><span class="text-down">133,633 </span></td>
|
||||
<td align="right"><span class="text-down">70.0 B</span></td>
|
||||
<td align="right"><span class="text-down">5,236 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 4.9 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 9.3 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,216 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">521.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,198 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">3.9 M</span></td>
|
||||
<td align="right"><span class="text-down">2.0 T</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">575,965 </span></td>
|
||||
<td align="right"><span class="text-up">298.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.7 M</span></td>
|
||||
<td align="right"><span class="text-down">908.2 B</span></td>
|
||||
<td align="right"><span class="text-down">5,282 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">369,446 </span></td>
|
||||
<td align="right"><span class="text-up">196.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,304 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">474,808 </span></td>
|
||||
<td align="right"><span class="text-down">247.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">366,209 </span></td>
|
||||
<td align="right"><span class="text-up">193.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,274 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">444,447 </span></td>
|
||||
<td align="right"><span class="text-down">233.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,251 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">287,065 </span></td>
|
||||
<td align="right"><span class="text-up">150.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,226 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">419,266 </span></td>
|
||||
<td align="right"><span class="text-down">218.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,219 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">198,140 </span></td>
|
||||
<td align="right"><span class="text-up">104.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,267 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">252,383 </span></td>
|
||||
<td align="right"><span class="text-down">131.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">162,162 </span></td>
|
||||
<td align="right"><span class="text-up">97.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,987 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-down">252,100 </span></td>
|
||||
<td align="right"><span class="text-down">130.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,175 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">123,966 </span></td>
|
||||
<td align="right"><span class="text-up">64.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,225 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">115,380 </span></td>
|
||||
<td align="right"><span class="text-down">60.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-up">70,621 </span></td>
|
||||
<td align="right"><span class="text-up">36.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,189 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">109,509 </span></td>
|
||||
<td align="right"><span class="text-down">56.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,200 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-up">15,000 </span></td>
|
||||
<td align="right"><span class="text-up">7.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-down">34,490 </span></td>
|
||||
<td align="right"><span class="text-down">17.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,195 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 1.7 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 3.2 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,263 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" selected>Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" selected>Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">521.3 B</span></td>
|
||||
<td align="right"><span class="text-up">5,198 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">3.9 M</span></td>
|
||||
<td align="right"><span class="text-down">2.0 T</span></td>
|
||||
<td align="right"><span class="text-down">5,211 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">575,965 </span></td>
|
||||
<td align="right"><span class="text-up">298.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||
<td align="right"><span class="text-down">820.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,208 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">369,446 </span></td>
|
||||
<td align="right"><span class="text-up">196.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,304 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">474,808 </span></td>
|
||||
<td align="right"><span class="text-down">247.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">366,209 </span></td>
|
||||
<td align="right"><span class="text-up">193.1 B</span></td>
|
||||
<td align="right"><span class="text-up">5,274 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">444,447 </span></td>
|
||||
<td align="right"><span class="text-down">233.4 B</span></td>
|
||||
<td align="right"><span class="text-down">5,251 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">287,065 </span></td>
|
||||
<td align="right"><span class="text-up">150.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,226 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">419,265 </span></td>
|
||||
<td align="right"><span class="text-down">218.8 B</span></td>
|
||||
<td align="right"><span class="text-down">5,219 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">198,140 </span></td>
|
||||
<td align="right"><span class="text-up">104.4 B</span></td>
|
||||
<td align="right"><span class="text-up">5,267 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">252,383 </span></td>
|
||||
<td align="right"><span class="text-down">131.3 B</span></td>
|
||||
<td align="right"><span class="text-down">5,203 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-up">123,966 </span></td>
|
||||
<td align="right"><span class="text-up">64.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,225 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-down">252,099 </span></td>
|
||||
<td align="right"><span class="text-down">130.5 B</span></td>
|
||||
<td align="right"><span class="text-down">5,175 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-up">70,621 </span></td>
|
||||
<td align="right"><span class="text-up">36.6 B</span></td>
|
||||
<td align="right"><span class="text-up">5,189 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">115,380 </span></td>
|
||||
<td align="right"><span class="text-down">60.1 B</span></td>
|
||||
<td align="right"><span class="text-down">5,212 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">17,322 </span></td>
|
||||
<td align="right"><span class="text-up">9.0 B</span></td>
|
||||
<td align="right"><span class="text-up">5,202 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">BB</span></td>
|
||||
<td align="right"><span class="text-down">109,509 </span></td>
|
||||
<td align="right"><span class="text-down">56.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,200 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">DX</span></td>
|
||||
<td align="right"><span class="text-up">15,000 </span></td>
|
||||
<td align="right"><span class="text-up">7.8 B</span></td>
|
||||
<td align="right"><span class="text-up">5,181 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">TP</span></td>
|
||||
<td align="right"><span class="text-down">34,490 </span></td>
|
||||
<td align="right"><span class="text-down">17.9 B</span></td>
|
||||
<td align="right"><span class="text-down">5,195 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 1.6 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -2.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 3.1 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 5,225 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/17/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/17/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="right"><span class="text-up"></span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
<td align="right"><span class="text-down"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 0 </span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 0 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.9 M</span></td>
|
||||
<td align="right"><span class="text-up">504.3 B</span></td>
|
||||
<td align="right"><span class="text-up">2,709 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">3.7 M</span></td>
|
||||
<td align="right"><span class="text-down">1.0 T</span></td>
|
||||
<td align="right"><span class="text-down">2,716 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||
<td align="right"><span class="text-up">436.0 B</span></td>
|
||||
<td align="right"><span class="text-up">2,720 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||
<td align="right"><span class="text-down">481.4 B</span></td>
|
||||
<td align="right"><span class="text-down">2,672 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">1.2 M</span></td>
|
||||
<td align="right"><span class="text-up">322.4 B</span></td>
|
||||
<td align="right"><span class="text-up">2,719 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">1.3 M</span></td>
|
||||
<td align="right"><span class="text-down">358.2 B</span></td>
|
||||
<td align="right"><span class="text-down">2,717 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">281.3 B</span></td>
|
||||
<td align="right"><span class="text-up">2,694 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">933,299 </span></td>
|
||||
<td align="right"><span class="text-down">253.5 B</span></td>
|
||||
<td align="right"><span class="text-down">2,716 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">SQ</span></td>
|
||||
<td align="right"><span class="text-up">712,722 </span></td>
|
||||
<td align="right"><span class="text-up">193.1 B</span></td>
|
||||
<td align="right"><span class="text-up">2,709 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">697,845 </span></td>
|
||||
<td align="right"><span class="text-down">189.9 B</span></td>
|
||||
<td align="right"><span class="text-down">2,721 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">OD</span></td>
|
||||
<td align="right"><span class="text-up">518,938 </span></td>
|
||||
<td align="right"><span class="text-up">140.6 B</span></td>
|
||||
<td align="right"><span class="text-up">2,709 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">477,399 </span></td>
|
||||
<td align="right"><span class="text-down">129.4 B</span></td>
|
||||
<td align="right"><span class="text-down">2,711 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-up">496,800 </span></td>
|
||||
<td align="right"><span class="text-up">133.9 B</span></td>
|
||||
<td align="right"><span class="text-up">2,695 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">447,840 </span></td>
|
||||
<td align="right"><span class="text-down">120.9 B</span></td>
|
||||
<td align="right"><span class="text-down">2,700 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">420,264 </span></td>
|
||||
<td align="right"><span class="text-up">112.3 B</span></td>
|
||||
<td align="right"><span class="text-up">2,672 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-down">431,663 </span></td>
|
||||
<td align="right"><span class="text-down">117.6 B</span></td>
|
||||
<td align="right"><span class="text-down">2,725 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">PD</span></td>
|
||||
<td align="right"><span class="text-up">403,468 </span></td>
|
||||
<td align="right"><span class="text-up">109.5 B</span></td>
|
||||
<td align="right"><span class="text-up">2,713 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-down">234,641 </span></td>
|
||||
<td align="right"><span class="text-down">63.1 B</span></td>
|
||||
<td align="right"><span class="text-down">2,690 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XC</span></td>
|
||||
<td align="right"><span class="text-up">381,871 </span></td>
|
||||
<td align="right"><span class="text-up">103.7 B</span></td>
|
||||
<td align="right"><span class="text-up">2,717 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-local">XA</span></td>
|
||||
<td align="right"><span class="text-down">217,888 </span></td>
|
||||
<td align="right"><span class="text-down">59.1 B</span></td>
|
||||
<td align="right"><span class="text-down">2,714 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 3.3 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -1.1 T</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 12.2 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 2,708 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="modContentBrokerSum"></div>
|
||||
|
||||
<!--DATA BROKER SUMMARY-->
|
||||
<div class="pa5">
|
||||
<div class="row mincol-3">
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||
</div>
|
||||
<div class="mt15 visible-xs"> </div>
|
||||
<div class="col col-xs-5 col-sm-3">
|
||||
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||
<option value="all">All</option>
|
||||
<option value="F" >Foreign</option>
|
||||
<option value="D" >Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-5 col-sm-2">
|
||||
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||
<option value="all">All Trade</option>
|
||||
<option value="RG" >Regular</option>
|
||||
<option value="TN" >Tunai</option>
|
||||
<option value="NG" >Nego</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col col-xs-2 col-sm-1">
|
||||
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Buyer</th>
|
||||
<th align="right">B.Lot</th>
|
||||
<th align="right">B.Val</th>
|
||||
<th align="right">B.Avg</th>
|
||||
<th align="center">#</th>
|
||||
<th align="center">Seller</th>
|
||||
<th align="right">S.Lot</th>
|
||||
<th align="right">S.Val</th>
|
||||
<th align="right">S.Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-up">1.9 M</span></td>
|
||||
<td align="right"><span class="text-up">381.6 B</span></td>
|
||||
<td align="right"><span class="text-up">1,960 </span></td>
|
||||
<td align="center">1</td>
|
||||
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||
<td align="right"><span class="text-down">3.7 M</span></td>
|
||||
<td align="right"><span class="text-down">721.6 B</span></td>
|
||||
<td align="right"><span class="text-down">1,933 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-up">1.5 M</span></td>
|
||||
<td align="right"><span class="text-up">292.8 B</span></td>
|
||||
<td align="right"><span class="text-up">1,932 </span></td>
|
||||
<td align="center">2</td>
|
||||
<td align="center"><span class="text-bumn">CC</span></td>
|
||||
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||
<td align="right"><span class="text-down">353.9 B</span></td>
|
||||
<td align="right"><span class="text-down">1,933 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||
<td align="right"><span class="text-up">274.7 B</span></td>
|
||||
<td align="right"><span class="text-up">1,917 </span></td>
|
||||
<td align="center">3</td>
|
||||
<td align="center"><span class="text-foreign">BK</span></td>
|
||||
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||
<td align="right"><span class="text-down">312.2 B</span></td>
|
||||
<td align="right"><span class="text-down">1,926 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">RX</span></td>
|
||||
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||
<td align="right"><span class="text-up">200.0 B</span></td>
|
||||
<td align="right"><span class="text-up">1,933 </span></td>
|
||||
<td align="center">4</td>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-down">1.0 M</span></td>
|
||||
<td align="right"><span class="text-down">198.1 B</span></td>
|
||||
<td align="right"><span class="text-down">1,926 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||
<td align="right"><span class="text-up">992,588 </span></td>
|
||||
<td align="right"><span class="text-up">192.3 B</span></td>
|
||||
<td align="right"><span class="text-up">1,937 </span></td>
|
||||
<td align="center">5</td>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-down">704,361 </span></td>
|
||||
<td align="right"><span class="text-down">136.7 B</span></td>
|
||||
<td align="right"><span class="text-down">1,941 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-up">944,581 </span></td>
|
||||
<td align="right"><span class="text-up">180.4 B</span></td>
|
||||
<td align="right"><span class="text-up">1,910 </span></td>
|
||||
<td align="center">6</td>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-down">529,257 </span></td>
|
||||
<td align="right"><span class="text-down">101.4 B</span></td>
|
||||
<td align="right"><span class="text-down">1,916 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">AK</span></td>
|
||||
<td align="right"><span class="text-up">715,278 </span></td>
|
||||
<td align="right"><span class="text-up">138.2 B</span></td>
|
||||
<td align="right"><span class="text-up">1,932 </span></td>
|
||||
<td align="center">7</td>
|
||||
<td align="center"><span class="text-foreign">YU</span></td>
|
||||
<td align="right"><span class="text-down">324,973 </span></td>
|
||||
<td align="right"><span class="text-down">62.5 B</span></td>
|
||||
<td align="right"><span class="text-down">1,924 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">MG</span></td>
|
||||
<td align="right"><span class="text-up">528,650 </span></td>
|
||||
<td align="right"><span class="text-up">100.6 B</span></td>
|
||||
<td align="right"><span class="text-up">1,903 </span></td>
|
||||
<td align="center">8</td>
|
||||
<td align="center"><span class="text-bumn">NI</span></td>
|
||||
<td align="right"><span class="text-down">208,165 </span></td>
|
||||
<td align="right"><span class="text-down">39.8 B</span></td>
|
||||
<td align="right"><span class="text-down">1,911 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-up">331,760 </span></td>
|
||||
<td align="right"><span class="text-up">64.1 B</span></td>
|
||||
<td align="right"><span class="text-up">1,931 </span></td>
|
||||
<td align="center">9</td>
|
||||
<td align="center"><span class="text-local">XL</span></td>
|
||||
<td align="right"><span class="text-down">173,939 </span></td>
|
||||
<td align="right"><span class="text-down">33.7 B</span></td>
|
||||
<td align="right"><span class="text-down">1,937 </span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-up">234,664 </span></td>
|
||||
<td align="right"><span class="text-up">44.9 B</span></td>
|
||||
<td align="right"><span class="text-up">1,913 </span></td>
|
||||
<td align="center">10</td>
|
||||
<td align="center"><span class="text-foreign">YP</span></td>
|
||||
<td align="right"><span class="text-down">168,848 </span></td>
|
||||
<td align="right"><span class="text-down">32.7 B</span></td>
|
||||
<td align="right"><span class="text-down">1,938 </span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bdr-t">
|
||||
<th colspan="9" align="center">
|
||||
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||
<span class="fsize12 ml5 mr5">T. Val : 2.2 T</span>
|
||||
<span class="fsize12 ml5 mr5">F. NVal : -90.2 B</span>
|
||||
<span class="fsize12 ml5 mr5">T.Lot : 11.3 M</span>
|
||||
<span class="fsize12 ml5 mr5">Avg : 1,931 </span>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- =======================
|
||||
======SCRIPT MODULE=========
|
||||
============================-->
|
||||
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||
1
crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Invalid stock code!
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
//! Tool inputs. String enums are validated by idx-core's `FromStr`, so the
|
||||
//! accepted values and error messages match the CLI; the schemas list them.
|
||||
|
||||
use idx_core::analysis::bandar::TIERS;
|
||||
use idx_core::api::types::{Interval, Period};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
||||
use idx_core::flow::types::FlowSource;
|
||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -114,6 +117,70 @@ pub struct AnalyzeParams {
|
|||
pub kinds: Option<Vec<AnalysisKind>>,
|
||||
}
|
||||
|
||||
/// Which sessions and trades a broker tool covers.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BrokerScope {
|
||||
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
/// First session of a range, YYYY-MM-DD (at most 366 days).
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
/// Last session of the range, YYYY-MM-DD (default today).
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
/// Whose trades to count, by investor domicile (default "all").
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "investor_schema")]
|
||||
pub investor: Option<String>,
|
||||
/// Which board's trades to count (default "all"); "regular" leaves out
|
||||
/// negotiated block trades.
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "board_schema")]
|
||||
pub board: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BrokerParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
pub symbol: String,
|
||||
#[serde(flatten)]
|
||||
pub scope: BrokerScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BandarParams {
|
||||
/// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20.
|
||||
pub symbols: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
pub scope: BrokerScope,
|
||||
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "top_schema")]
|
||||
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>,
|
||||
/// Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net
|
||||
/// value across all boards; "idx" gives exact share counts, the close and the non-regular
|
||||
/// share, but needs curl-impersonate and may be blocked (HTTP 403).
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "flow_source_schema")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ShareholdersParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
|
|
@ -178,3 +245,19 @@ fn filter_schema(_: &mut SchemaGenerator) -> Schema {
|
|||
fn region_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn investor_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(BrokerInvestor::ALL.map(BrokerInvestor::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn board_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn flow_source_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn top_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({ "type": "integer", "enum": TIERS })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use idx_core::analysis::BandarQuery;
|
||||
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
||||
use idx_core::analysis::fundamental::FundamentalReport;
|
||||
use idx_core::broker::types::BrokerRange;
|
||||
use idx_core::config::CoreConfig;
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||
use idx_core::flow::types::FlowRange;
|
||||
use idx_core::investor::{HolderSource, Investor};
|
||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||
use idx_core::ownership::types::ChangeRow;
|
||||
|
|
@ -21,8 +25,9 @@ use serde_json::{Map, Value, json};
|
|||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::params::{
|
||||
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams,
|
||||
HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||
CompanySection, EntityParams, FlowParams, HistoryParams, ScreenParams, SearchParams,
|
||||
ShareholdersParams, SymbolsParams, SyncParams,
|
||||
};
|
||||
|
||||
const SYNC_TOOL: &str = "ownership_sync";
|
||||
|
|
@ -110,7 +115,9 @@ impl IdxServer {
|
|||
impl IdxServer {
|
||||
#[tool(
|
||||
description = "Latest quotes (price, change, volume, 52-week range, market cap) \
|
||||
for one or more IDX tickers."
|
||||
for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \
|
||||
when that is before the latest IDX session, so the price is not that session's close: the \
|
||||
provider lags, the stock did not trade, or the day was an exchange holiday."
|
||||
)]
|
||||
async fn get_quotes(&self, params: Parameters<SymbolsParams>) -> ToolResult {
|
||||
let symbols = match checked_symbols(params.0.symbols) {
|
||||
|
|
@ -143,7 +150,8 @@ for one or more IDX tickers."
|
|||
|
||||
#[tool(
|
||||
description = "Run a stock screen (top/worst performers, high dividend, low P/E, \
|
||||
52-week highs/lows, high volume, large cap)."
|
||||
52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, \
|
||||
`behind_session` as in get_quotes."
|
||||
)]
|
||||
async fn screen_stocks(&self, params: Parameters<ScreenParams>) -> ToolResult {
|
||||
let ScreenParams {
|
||||
|
|
@ -165,6 +173,106 @@ for one or more IDX tickers."
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers \
|
||||
by lots over a session or date range, each with lots (1 lot = 100 shares), value and average \
|
||||
price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can \
|
||||
appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the \
|
||||
broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign \
|
||||
or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is \
|
||||
null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs \
|
||||
`board` all). `rounded` = true means figures of a million and more carry one decimal of \
|
||||
precision."
|
||||
)]
|
||||
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
|
||||
let BrokerParams { symbol, scope } = params.0;
|
||||
self.live(move |idx| {
|
||||
let broker = idx.broker();
|
||||
let range = BrokerRange::from_args(
|
||||
scope.date.as_deref(),
|
||||
scope.from.as_deref(),
|
||||
scope.to.as_deref(),
|
||||
broker.today(),
|
||||
)?;
|
||||
broker.fetch(
|
||||
&symbol,
|
||||
range,
|
||||
scope.investor.as_deref().unwrap_or("all").parse()?,
|
||||
scope.board.as_deref().unwrap_or("all").parse()?,
|
||||
CachePolicy::Default,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bandarmology for one or more IDX tickers from the broker summary: for \
|
||||
the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value \
|
||||
traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral \
|
||||
(within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \
|
||||
strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \
|
||||
end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \
|
||||
gross figures overstate their direction. `method` names the rules applied. With `investor` \
|
||||
foreign or domestic, shares are of that side's own buying (`buy_value`) and selling \
|
||||
(`sell_value`); that needs `board` all."
|
||||
)]
|
||||
async fn analyze_bandar(&self, params: Parameters<BandarParams>) -> ToolResult {
|
||||
let BandarParams {
|
||||
scope,
|
||||
symbols,
|
||||
top,
|
||||
} = params.0;
|
||||
let symbols = match checked_symbols(symbols) {
|
||||
Ok(symbols) => symbols,
|
||||
Err(err) => return err,
|
||||
};
|
||||
self.live(move |idx| {
|
||||
let query = BandarQuery {
|
||||
range: BrokerRange::from_args(
|
||||
scope.date.as_deref(),
|
||||
scope.from.as_deref(),
|
||||
scope.to.as_deref(),
|
||||
idx.broker().today(),
|
||||
)?,
|
||||
investor: scope.investor.as_deref().unwrap_or("all").parse()?,
|
||||
board: scope.board.as_deref().unwrap_or("all").parse()?,
|
||||
top: top.unwrap_or(DEFAULT_TOP),
|
||||
};
|
||||
let screen = idx
|
||||
.analysis()
|
||||
.bandar_screen(&symbols, &query, CachePolicy::Default)?;
|
||||
let reports: &[Fetched<BandarReport>] = &screen.reports;
|
||||
Ok(json!({ "reports": reports, "failures": failures_json(&screen.failures) }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Foreign investors' buying and selling of one IDX ticker, session by \
|
||||
session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): \
|
||||
`foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, \
|
||||
`value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and \
|
||||
`non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \
|
||||
\"idx\": exact foreign share counts on the regular board, values estimated at the session's \
|
||||
average price, the close, `change_bps` and `non_regular_bps`."
|
||||
)]
|
||||
async fn get_foreign_flow(&self, params: Parameters<FlowParams>) -> ToolResult {
|
||||
let FlowParams {
|
||||
symbol,
|
||||
days,
|
||||
from,
|
||||
to,
|
||||
source,
|
||||
} = params.0;
|
||||
self.live(move |idx| {
|
||||
let flow = idx.flow();
|
||||
let range = FlowRange::from_args(days, from.as_deref(), to.as_deref(), flow.today())?;
|
||||
let source = source.as_deref().unwrap_or("ipot").parse()?;
|
||||
flow.foreign(&symbol, range, source, CachePolicy::Default)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||
|
|
@ -256,17 +364,8 @@ tickers. Tickers that fail are listed under `failures`."
|
|||
};
|
||||
self.live(move |idx| {
|
||||
let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?;
|
||||
let failures: Vec<Value> = comparison
|
||||
.failures
|
||||
.iter()
|
||||
.map(|(symbol, err)| {
|
||||
let mut body = error_body(err);
|
||||
body.insert("symbol".into(), json!(symbol));
|
||||
Value::Object(body)
|
||||
})
|
||||
.collect();
|
||||
let reports: &[Fetched<FundamentalReport>] = &comparison.reports;
|
||||
Ok(json!({ "reports": reports, "failures": failures }))
|
||||
Ok(json!({ "reports": reports, "failures": failures_json(&comparison.failures) }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -442,6 +541,18 @@ where
|
|||
})
|
||||
}
|
||||
|
||||
/// Per-symbol failures as error bodies carrying their symbol.
|
||||
fn failures_json(failures: &[(String, IdxError)]) -> Vec<Value> {
|
||||
failures
|
||||
.iter()
|
||||
.map(|(symbol, err)| {
|
||||
let mut body = error_body(err);
|
||||
body.insert("symbol".into(), json!(symbol));
|
||||
Value::Object(body)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn input_error(message: String) -> ToolResult {
|
||||
let err = IdxError::InvalidInput(message);
|
||||
Ok(CallToolResult::structured_error(Value::Object(error_body(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,80 @@
|
|||
[
|
||||
{
|
||||
"description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"board": {
|
||||
"default": null,
|
||||
"description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.",
|
||||
"enum": [
|
||||
"all",
|
||||
"regular",
|
||||
"cash",
|
||||
"negotiated"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"default": null,
|
||||
"description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"from": {
|
||||
"default": null,
|
||||
"description": "First session of a range, YYYY-MM-DD (at most 366 days).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"investor": {
|
||||
"default": null,
|
||||
"description": "Whose trades to count, by investor domicile (default \"all\").",
|
||||
"enum": [
|
||||
"all",
|
||||
"foreign",
|
||||
"domestic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"symbols": {
|
||||
"description": "IDX tickers, e.g. [\"BBCA\", \"BBRI\"]; the `.JK` suffix is optional. At most 20.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"to": {
|
||||
"default": null,
|
||||
"description": "Last session of the range, YYYY-MM-DD (default today).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"top": {
|
||||
"default": null,
|
||||
"description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).",
|
||||
"enum": [
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
10
|
||||
],
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"symbols"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "analyze_bandar"
|
||||
},
|
||||
{
|
||||
"description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.",
|
||||
"inputSchema": {
|
||||
|
|
@ -57,6 +133,68 @@
|
|||
},
|
||||
"name": "compare_stocks"
|
||||
},
|
||||
{
|
||||
"description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"board": {
|
||||
"default": null,
|
||||
"description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.",
|
||||
"enum": [
|
||||
"all",
|
||||
"regular",
|
||||
"cash",
|
||||
"negotiated"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"default": null,
|
||||
"description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"from": {
|
||||
"default": null,
|
||||
"description": "First session of a range, YYYY-MM-DD (at most 366 days).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"investor": {
|
||||
"default": null,
|
||||
"description": "Whose trades to count, by investor domicile (default \"all\").",
|
||||
"enum": [
|
||||
"all",
|
||||
"foreign",
|
||||
"domestic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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_broker_summary"
|
||||
},
|
||||
{
|
||||
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
|
||||
"inputSchema": {
|
||||
|
|
@ -97,6 +235,58 @@
|
|||
},
|
||||
"name": "get_company"
|
||||
},
|
||||
{
|
||||
"description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"default": null,
|
||||
"description": "Where the figures come from (default \"ipot\"): \"ipot\" gives foreign buy, sell and net\nvalue across all boards; \"idx\" gives exact share counts, the close and the non-regular\nshare, but needs curl-impersonate and may be blocked (HTTP 403).",
|
||||
"enum": [
|
||||
"ipot",
|
||||
"idx"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"description": "IDX ticker, e.g. \"BBCA\".",
|
||||
"type": "string"
|
||||
},
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
|
|
@ -207,7 +397,7 @@
|
|||
"name": "get_price_history"
|
||||
},
|
||||
{
|
||||
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers.",
|
||||
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
|
|
@ -267,7 +457,7 @@
|
|||
"name": "ownership_sync"
|
||||
},
|
||||
{
|
||||
"description": "Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap).",
|
||||
"description": "Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, `behind_session` as in get_quotes.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 11] = [
|
||||
const TOOLS: [&str; 14] = [
|
||||
"analyze_bandar",
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
"get_broker_summary",
|
||||
"get_company",
|
||||
"get_foreign_flow",
|
||||
"get_investor_holdings",
|
||||
"get_ownership_changes",
|
||||
"get_price_history",
|
||||
|
|
@ -247,6 +250,50 @@ fn live_tools_wrap_data_with_freshness() {
|
|||
assert_fetched(&analysis[kind]);
|
||||
}
|
||||
|
||||
let brokers = client.ok(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16"}),
|
||||
);
|
||||
assert_fetched(&brokers);
|
||||
assert_eq!(brokers["source"], "ipot");
|
||||
assert_eq!(brokers["data"]["buyers"][0]["code"], "MG");
|
||||
assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
|
||||
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
|
||||
let bandar = client.ok(
|
||||
"analyze_bandar",
|
||||
json!({"symbols": ["BBCA", "BBRI", "ZZZZ"], "date": "2026-10-16", "top": 3}),
|
||||
);
|
||||
let reports = bandar["reports"].as_array().unwrap();
|
||||
assert_eq!(reports.len(), 2);
|
||||
assert_fetched(&reports[0]);
|
||||
assert_eq!(reports[0]["data"]["top"], 3);
|
||||
assert_eq!(reports[0]["data"]["method"], "top_concentration_v1");
|
||||
assert!(
|
||||
reports[0]["data"]["imbalance_bps"].as_i64()
|
||||
>= reports[1]["data"]["imbalance_bps"].as_i64()
|
||||
);
|
||||
assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK");
|
||||
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"], "ipot");
|
||||
assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5);
|
||||
assert_eq!(
|
||||
flow["data"]["days"][4]["foreign_sell_value"],
|
||||
4_000_000_000_000_i64
|
||||
);
|
||||
let idx_flow = client.ok(
|
||||
"get_foreign_flow",
|
||||
json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16", "source": "idx"}),
|
||||
);
|
||||
assert_eq!(idx_flow["source"], "idx");
|
||||
assert_eq!(idx_flow["data"]["days"][4]["close"], 5175);
|
||||
|
||||
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
||||
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(compared["failures"], json!([]));
|
||||
|
|
@ -273,6 +320,25 @@ fn errors_carry_cli_codes() {
|
|||
("compare_stocks", json!({"symbols": []})),
|
||||
("get_company", json!({"symbol": "BBCA", "sections": []})),
|
||||
("analyze_stock", json!({"symbol": "BBCA", "kinds": []})),
|
||||
(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16", "from": "2026-10-12"}),
|
||||
),
|
||||
(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
|
||||
),
|
||||
("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})),
|
||||
("analyze_bandar", json!({"symbols": []})),
|
||||
("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})),
|
||||
(
|
||||
"get_foreign_flow",
|
||||
json!({"symbol": "BBCA", "source": "rti"}),
|
||||
),
|
||||
(
|
||||
"get_foreign_flow",
|
||||
json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}),
|
||||
),
|
||||
] {
|
||||
let (is_error, body) = client.call(tool, arguments);
|
||||
assert!(is_error, "{tool}: {body}");
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ command parses arguments, makes one idx-core call, and renders the result.
|
|||
### Dual Provider Model
|
||||
- **MSN Finance** — default provider. Rich data: quotes, fundamentals, profile, earnings, financials, sentiment, insights, news, screener, and explicit price-only chart history for supported IDX windows.
|
||||
- **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`.
|
||||
- **IPOT (Indo Premier)** — broker summaries, whichever provider is selected
|
||||
(`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries).
|
||||
- **IDX (idx.co.id)** — the daily trading summary behind `stocks flow
|
||||
--source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow).
|
||||
|
||||
### MSN Endpoints
|
||||
- `https://assets.msn.com/service/`: quotes, charts, equities, earnings,
|
||||
|
|
@ -71,8 +75,13 @@ MSN candle dates use WIB (UTC+7); Yahoo candle dates use `meta.gmtoffset`, with
|
|||
WIB as the fallback.
|
||||
|
||||
### Quote and Screener Data
|
||||
- Quotes expose nullable `as_of` last-trade timestamps in WIB. Table output flags
|
||||
quotes whose last trade was more than seven days ago.
|
||||
- Quotes expose nullable `as_of` last-trade timestamps in WIB, and
|
||||
`behind_session` when `as_of` is before the latest weekday whose session has
|
||||
ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close
|
||||
by hours, and on an exchange holiday every quote reads as behind. Only `.JK`
|
||||
quotes and `--region id` screens are marked; other exchanges keep other hours.
|
||||
Table output notes behind quotes, and calls those over seven days old not
|
||||
current.
|
||||
- `change`, `change_pct`, and `volume` are nullable when the provider omits them;
|
||||
JSON consumers must not assume these fields are always numeric.
|
||||
- Listed MSN instruments without a price return `NOMARKETDATA`; unknown symbols
|
||||
|
|
@ -83,6 +92,64 @@ WIB as the fallback.
|
|||
when the initial batch is incomplete, and rejects a still-truncated response.
|
||||
- Full-list screen caches use an `all` key, separate from older limited results.
|
||||
|
||||
### Broker Summaries
|
||||
- `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php`
|
||||
takes `code`, `start`/`end` (`MM/DD/YYYY`), `fd` (`all|F|D`) and `board`
|
||||
(`all|RG|TN|NG`). It needs no login and answers an HTML fragment: the top 10
|
||||
buyers and top 10 sellers ranked by lots (gross, so a broker can be on both
|
||||
sides), plus range totals. `api/ipot/parse.rs` parses it purely, with
|
||||
html5ever (`scraper`), so it reads the cells, date inputs and footer a
|
||||
browser would build: a hand-written reader kept disagreeing with HTML on
|
||||
crafted markup in the 0.4.0 review. A table whose rows don't line up
|
||||
(missing cells, ranks out of order, a broker after a blank row, a second
|
||||
body) rejects the page, and the dates it echoes must match the request.
|
||||
- Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled
|
||||
in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the
|
||||
investor and board filters, so `foreign_net_value` is null for a summary of
|
||||
one board. With `fd=F` or `fd=D` the footer covers only that
|
||||
side's buying (BBCA 2026-10-16: foreign 1.7 T and domestic 4.9 T of 6.6 T),
|
||||
so `total_value` is null and the footer is `buy_value`. On all boards the
|
||||
side's `sell_value` is its buying less the foreign net (foreign: 1.7 + 2.3
|
||||
= 4.0 T, against IDX's 3.95 T regular) or plus it (domestic); with a board
|
||||
filter it is null.
|
||||
- An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The
|
||||
server also answers spurious nginx 404s for seconds at a time, so the client
|
||||
retries 404 and 5xx with backoff. A range without trades is an empty summary.
|
||||
- Data is end of day: IDX hides broker codes during trading. Summaries that
|
||||
ended before today are cached for 7 days, today's for 5 minutes. Broker
|
||||
names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`).
|
||||
- Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure
|
||||
over one summary: for the top 1/3/5/10 per side, the buyers' share of the
|
||||
value bought minus the sellers' share of the value sold (for all investors,
|
||||
buy value minus sell value in basis points of value traded), with thresholds ±500/±1500 bps taken from
|
||||
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.
|
||||
|
||||
### Foreign Flow
|
||||
- The default source is IPOT: per session, the all-investor broker summary
|
||||
gives the value traded and the foreign one (`fd=F`) gives foreign buying
|
||||
(its footer), the foreign net, and selling (buying less the net), all
|
||||
boards, through the broker summary cache. IPOT's foreign net matched IDX's
|
||||
estimate within 0.3 B on BBCA 2026-10-12 to 16. An empty all-investor
|
||||
summary counts as no session.
|
||||
- `--source idx`: `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary`
|
||||
`?length=9999&start=0&date=YYYYMMDD` answers one JSON row per stock for the
|
||||
session: prices, volume, value, `ForeignBuy`/`ForeignSell` (shares) and
|
||||
non-regular volume and value. `Volume`, `Value` and the foreign figures are
|
||||
the regular board only: IPOT's regular-board total for BBCA on 2026-10-16
|
||||
matches `Value`, and in a week of every stock no foreign figure exceeded
|
||||
regular volume. `non_regular_bps` is therefore over regular plus
|
||||
non-regular 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
|
||||
```
|
||||
|
|
@ -92,6 +159,8 @@ HistoryProvider = separate trait, not all providers implement
|
|||
Factory functions:
|
||||
default_provider(kind) → Box<dyn MarketDataProvider>
|
||||
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
|
||||
broker_provider() → Box<dyn BrokerSummaryProvider>
|
||||
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
- `stocks`: every `stocks` command for one ticker, plus `compare BUMI,ADRO,AIMS`,
|
||||
- `stocks`: every `stocks` command for one ticker (`broker`, `bandar`,
|
||||
`flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates
|
||||
them is fine), plus `compare BUMI,ADRO,AIMS`,
|
||||
whose MSN key ratios contain non-finite values.
|
||||
- `ownership`: `discover`, then imports the newest supported report into a temp
|
||||
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"}'
|
||||
sleep 2
|
||||
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
||||
jq -se 'map(select(.id == 2))[0].result.tools | length == 11' out.jsonl
|
||||
jq -se 'map(select(.id == 2))[0].result.tools | length == 14' out.jsonl
|
||||
touch "$out"
|
||||
'';
|
||||
# The website's generated pages match the CLI help and the MCP
|
||||
|
|
|
|||
|
|
@ -123,6 +123,14 @@ contract() {
|
|||
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
||||
"stocks news "*)
|
||||
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 "*"--source idx"*)
|
||||
echo '(.source == "idx") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;;
|
||||
"stocks flow "*)
|
||||
echo '(.source == "ipot") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy_value | type == "number") and (.foreign_sell_value | type == "number")) and (.foreign_net_value | type == "number")' ;;
|
||||
"ownership discover"*)
|
||||
echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;;
|
||||
"ownership releases")
|
||||
|
|
@ -194,6 +202,12 @@ run_case() {
|
|||
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
|
||||
command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; }
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
|
@ -217,6 +231,11 @@ if selected stocks; then
|
|||
run_case compare "stocks compare $SYMBOL,BBRI"
|
||||
# BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN).
|
||||
run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS"
|
||||
# Broker summaries and foreign flow (IPOT; --source idx via curl-impersonate).
|
||||
run_new_case broker "stocks broker $SYMBOL"
|
||||
run_new_case bandar "stocks bandar $SYMBOL,BBRI,TLKM"
|
||||
run_new_case flow "stocks flow $SYMBOL --days 3"
|
||||
run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx"
|
||||
fi
|
||||
|
||||
if selected ownership; then
|
||||
|
|
|
|||
|
|
@ -62,5 +62,35 @@
|
|||
"prompt": "Search the KSEI ownership database for investors named Salim.",
|
||||
"expect_tools": [{"name": "search_investors", "args": {"query": "Salim"}}],
|
||||
"expect_text": []
|
||||
},
|
||||
{
|
||||
"id": "broker",
|
||||
"prompt": "Which brokers bought and sold the most BBCA on 2026-10-16?",
|
||||
"expect_tools": [{"name": "get_broker_summary", "args": {"symbol": "BBCA", "date": "2026-10-16"}}],
|
||||
"expect_text": ["\\bMG\\b"],
|
||||
"live": {
|
||||
"prompt": "Which brokers bought and sold the most BBCA in the latest session?",
|
||||
"expect_tools": [{"name": "get_broker_summary", "args": {"symbol": "BBCA"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bandar",
|
||||
"prompt": "Were the big brokers (bandar) accumulating or distributing BBCA on 2026-10-16?",
|
||||
"expect_tools": [{"name": "analyze_bandar", "args": {"symbols": ["BBCA"], "date": "2026-10-16"}}],
|
||||
"expect_text": ["(?i)distribut"],
|
||||
"live": {
|
||||
"prompt": "Are the big brokers (bandar) accumulating or distributing BBCA lately?",
|
||||
"expect_tools": [{"name": "analyze_bandar", "args": {"symbols": ["BBCA"]}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flow",
|
||||
"prompt": "What was the net foreign flow in BBCA from 2026-10-12 to 2026-10-16?",
|
||||
"expect_tools": [{"name": "get_foreign_flow", "args": {"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}}],
|
||||
"expect_text": ["(?i)foreign"],
|
||||
"live": {
|
||||
"prompt": "What has the net foreign flow in BBCA been over the last 10 sessions?",
|
||||
"expect_tools": [{"name": "get_foreign_flow", "args": {"symbol": "BBCA"}}]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ Commands:
|
|||
valuation Run valuation analysis on a stock
|
||||
risk Run risk analysis on a stock
|
||||
fundamental Run full fundamental analysis on a stock
|
||||
broker Which brokers bought and sold a stock
|
||||
bandar Bandarmology: is buying more concentrated than selling
|
||||
flow Foreign buying and selling of a stock, session by session
|
||||
profile Get company profile
|
||||
financials Get financial statements
|
||||
earnings Get earnings report
|
||||
|
|
@ -278,6 +281,216 @@ Examples:
|
|||
idx -o json stocks fundamental BBCA
|
||||
```
|
||||
|
||||
### `idx stocks broker`
|
||||
|
||||
```text
|
||||
Which brokers bought and sold a stock: the top 10 buyers and top 10 sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX does not show broker codes during trading. Without dates, the latest session with trades.
|
||||
|
||||
Usage: idx stocks broker [OPTIONS] <SYMBOL>
|
||||
|
||||
Arguments:
|
||||
<SYMBOL>
|
||||
Single ticker symbol (e.g. BBCA)
|
||||
|
||||
Options:
|
||||
--date <DATE>
|
||||
One session, YYYY-MM-DD. Default: the latest session with trades
|
||||
|
||||
-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 366 days)
|
||||
|
||||
--no-color
|
||||
Disable colored output
|
||||
|
||||
-q, --quiet
|
||||
Suppress non-essential output
|
||||
|
||||
--to <TO>
|
||||
Last session of the range, YYYY-MM-DD. Default: today
|
||||
|
||||
--investor <INVESTOR>
|
||||
Whose trades to count
|
||||
|
||||
Possible values:
|
||||
- all: Every trade
|
||||
- foreign: Trades by foreign investors only
|
||||
- domestic: Trades by domestic investors only
|
||||
|
||||
[default: all]
|
||||
|
||||
-v, --verbose...
|
||||
Increase verbosity (-v, -vv)
|
||||
|
||||
--board <BOARD>
|
||||
Which board's trades to count; `regular` leaves out negotiated block trades
|
||||
|
||||
Possible values:
|
||||
- all: Every board
|
||||
- regular: Regular market (RG), the continuous order book
|
||||
- cash: Cash market (TN)
|
||||
- negotiated: Negotiated market (NG): pre-arranged block trades
|
||||
|
||||
[default: all]
|
||||
|
||||
--offline
|
||||
Run without network requests
|
||||
|
||||
--no-cache
|
||||
Bypass local cache
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
Examples:
|
||||
idx stocks broker BBCA
|
||||
idx stocks broker BBCA --date 2026-10-16
|
||||
idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular
|
||||
idx -o json stocks broker BBCA --investor foreign
|
||||
```
|
||||
|
||||
### `idx stocks bandar`
|
||||
|
||||
```text
|
||||
Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers against its buying and its sellers against its selling (needs `--board all`). A heuristic over end-of-day broker codes, not advice.
|
||||
|
||||
Usage: idx stocks bandar [OPTIONS] <SYMBOLS>...
|
||||
|
||||
Arguments:
|
||||
<SYMBOLS>...
|
||||
One or more symbols, comma-separated or space-separated; `-` reads them from stdin
|
||||
|
||||
Options:
|
||||
--date <DATE>
|
||||
One session, YYYY-MM-DD. Default: the latest session with trades
|
||||
|
||||
-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 366 days)
|
||||
|
||||
--no-color
|
||||
Disable colored output
|
||||
|
||||
-q, --quiet
|
||||
Suppress non-essential output
|
||||
|
||||
--to <TO>
|
||||
Last session of the range, YYYY-MM-DD. Default: today
|
||||
|
||||
--investor <INVESTOR>
|
||||
Whose trades to count
|
||||
|
||||
Possible values:
|
||||
- all: Every trade
|
||||
- foreign: Trades by foreign investors only
|
||||
- domestic: Trades by domestic investors only
|
||||
|
||||
[default: all]
|
||||
|
||||
-v, --verbose...
|
||||
Increase verbosity (-v, -vv)
|
||||
|
||||
--board <BOARD>
|
||||
Which board's trades to count; `regular` leaves out negotiated block trades
|
||||
|
||||
Possible values:
|
||||
- all: Every board
|
||||
- regular: Regular market (RG), the continuous order book
|
||||
- cash: Cash market (TN)
|
||||
- negotiated: Negotiated market (NG): pre-arranged block trades
|
||||
|
||||
[default: all]
|
||||
|
||||
--offline
|
||||
Run without network requests
|
||||
|
||||
--no-cache
|
||||
Bypass local cache
|
||||
|
||||
--top <TOP>
|
||||
Tier behind the headline signal: 1, 3, 5 or 10 brokers per side
|
||||
|
||||
[default: 5]
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
Examples:
|
||||
idx stocks bandar BBCA
|
||||
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
|
||||
idx stocks bandar BBCA,BBRI,BMRI,TLKM
|
||||
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, end of day. By default from Indo Premier (IPOT): foreign buy, sell and net value across all boards and foreign investors' share of trading, two plain requests per session. `--source idx` reads IDX's own trading summary instead: exact foreign share counts, the close and the share traded outside the regular board, one request per session to idx.co.id, which needs curl-impersonate and a network IDX's Cloudflare lets through.
|
||||
|
||||
Usage: idx stocks flow [OPTIONS] <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
|
||||
|
||||
--source <SOURCE>
|
||||
Where the figures come from
|
||||
|
||||
Possible values:
|
||||
- ipot: Indo Premier's broker summary: foreign buy, sell and net value across all boards, two plain requests per session. Figures of a million and more are rounded to one decimal
|
||||
- idx: IDX's own trading summary: exact foreign share counts, close and the non-regular share, regular board. Needs curl-impersonate and a network IDX's Cloudflare lets through
|
||||
|
||||
[default: ipot]
|
||||
|
||||
-v, --verbose...
|
||||
Increase verbosity (-v, -vv)
|
||||
|
||||
--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 stocks flow BBCA --source idx
|
||||
idx -o json stocks flow BBCA | jq '.foreign_net_value'
|
||||
```
|
||||
|
||||
### `idx stocks profile`
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Stocks
|
||||
description: Quotes, history, company data, analysis and screening.
|
||||
description: Quotes, history, company data, broker summaries, analysis and screening.
|
||||
---
|
||||
|
||||
All `stocks` commands take IDX tickers (`BBCA`, or `BBCA.JK`). Every flag is
|
||||
|
|
@ -28,6 +28,83 @@ them from stdin. `history` takes `--period` (`1d` to `5y`) and `--interval`
|
|||
| `insights` | AI insights from the provider |
|
||||
| `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) |
|
||||
|
||||
## Broker summary
|
||||
|
||||
```bash
|
||||
idx stocks broker BBCA
|
||||
idx stocks broker BBCA --date 2026-10-16
|
||||
idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular
|
||||
```
|
||||
|
||||
Which brokers bought and sold a stock: the top 10 buyers and the top 10
|
||||
sellers by lots, each with lots, value and average price, plus totals and the
|
||||
net foreign value. Without dates it shows the latest session with trades.
|
||||
`--investor foreign|domestic` counts only one side's clients (each broker row
|
||||
is then that broker's foreign or domestic clients' trades, and the footer
|
||||
gives what that side bought and sold), and `--board regular` leaves out
|
||||
negotiated block trades.
|
||||
|
||||
The data comes from Indo Premier (IPOT) and is end of day: IDX hides broker
|
||||
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
|
||||
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
|
||||
idx stocks flow BBCA --source idx
|
||||
```
|
||||
|
||||
What foreign investors bought and sold, session by session, with the totals
|
||||
over the range. Without dates it shows the latest 10 sessions (`--days`, at
|
||||
most 60).
|
||||
|
||||
By default the figures come from Indo Premier (IPOT), like `broker`: foreign
|
||||
buy, sell and net value across all boards and foreign investors' share of
|
||||
trading. It needs nothing installed, takes two requests per session, and
|
||||
rounds figures of a million and more to one decimal.
|
||||
|
||||
`--source idx` reads IDX's own daily trading summary instead: exact foreign
|
||||
share counts, the close and day change, and the share of value traded
|
||||
outside the regular board. Those figures are the regular board, and values
|
||||
are estimated at each session's average price. Each session is one request
|
||||
to idx.co.id, cached for every stock, and needs
|
||||
[curl-impersonate](/start/install/) and a network IDX's Cloudflare lets
|
||||
through; even then it can answer 403.
|
||||
|
||||
## Bandarmology
|
||||
|
||||
```bash
|
||||
idx stocks bandar BBCA
|
||||
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
|
||||
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
|
||||
```
|
||||
|
||||
`bandar` reads the broker summary: for the top 1, 3, 5 and 10 brokers on each
|
||||
side, the buyers' value minus the sellers' value as a share of the value
|
||||
traded. The headline tier (`--top`, default 5) gives the signal:
|
||||
|
||||
| Top-N buy minus sell, % of value | Signal |
|
||||
| --- | --- |
|
||||
| +15% or more | big accumulation |
|
||||
| +5% to +15% | accumulation |
|
||||
| within ±5% | neutral |
|
||||
| -15% to -5% | distribution |
|
||||
| -15% or less | big distribution |
|
||||
|
||||
Several symbols give a screen ranked by that figure (at most 100, one request
|
||||
each), and it takes the same date and filter flags as `broker`. With
|
||||
`--investor foreign` (or `domestic`) it reads that side alone: the top buyers'
|
||||
share of foreign buying against the top sellers' share of foreign selling.
|
||||
That needs `--board all`, since IPOT gives one side's selling only across all
|
||||
boards. It is a
|
||||
heuristic: a broker trades for many clients, and a broker on both sides of
|
||||
the summary is listed, since its gross figures overstate which way it traded.
|
||||
The JSON names the rules in `method` (`top_concentration_v1`).
|
||||
|
||||
## Analysis
|
||||
|
||||
| Command | What it computes |
|
||||
|
|
@ -62,6 +139,7 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa
|
|||
## Providers
|
||||
|
||||
MSN is the default for quotes, company data and screening; Yahoo serves
|
||||
price history. Switch with `IDX_PROVIDER=msn|yahoo` and
|
||||
price history. Indo Premier (IPOT) serves broker summaries and IDX's own site
|
||||
serves foreign flow, whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and
|
||||
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching
|
||||
[config keys](/start/config/).
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ starts with `--allow-sync`. Results that come from a live provider are wrapped
|
|||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||
message}` with `isError` set.
|
||||
|
||||
## `analyze_bandar`
|
||||
|
||||
Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all.
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. |
|
||||
| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. |
|
||||
| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). |
|
||||
| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). |
|
||||
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
||||
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
|
||||
| `top` | `1` / `3` / `5` / `10` | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). |
|
||||
|
||||
## `analyze_stock`
|
||||
|
||||
Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.
|
||||
|
|
@ -27,6 +41,19 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker
|
|||
| --- | --- | --- | --- |
|
||||
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
||||
|
||||
## `get_broker_summary`
|
||||
|
||||
Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision.
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. |
|
||||
| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. |
|
||||
| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). |
|
||||
| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). |
|
||||
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
|
||||
|
||||
## `get_company`
|
||||
|
||||
Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.
|
||||
|
|
@ -36,6 +63,18 @@ 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). |
|
||||
| `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), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `days` | integer | no | Latest sessions (default 10, at most 60). Omit when giving from/to. |
|
||||
| `from` | string | no | First session of a range, YYYY-MM-DD (at most 92 days). |
|
||||
| `source` | `ipot` / `idx` | no | Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net value across all boards; "idx" gives exact share counts, the close and the non-regular share, but needs curl-impersonate and may be blocked (HTTP 403). |
|
||||
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
|
||||
|
||||
## `get_investor_holdings`
|
||||
|
||||
Every IDX holding (above 1%) of one investor, matched by name.
|
||||
|
|
@ -68,7 +107,7 @@ OHLCV price history for one IDX ticker.
|
|||
|
||||
## `get_quotes`
|
||||
|
||||
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers.
|
||||
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
@ -98,7 +137,7 @@ Download or refresh the KSEI ownership snapshot (several MB) into the local data
|
|||
|
||||
## `screen_stocks`
|
||||
|
||||
Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap).
|
||||
Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, `behind_session` as in get_quotes.
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -74,12 +74,14 @@ idx version
|
|||
## Helper tools
|
||||
|
||||
Everyday use needs nothing else: stock data with the default MSN provider,
|
||||
price history, `ownership sync` and every local ownership query. A few
|
||||
commands call external tools:
|
||||
price history, broker summaries, bandarmology and foreign flow, `ownership sync`
|
||||
and every
|
||||
local ownership query. A few commands call external tools:
|
||||
|
||||
| Tool | Needed for |
|
||||
| --- | --- |
|
||||
| `curl_chrome*` from [curl-impersonate](https://github.com/lexiforest/curl-impersonate), or `IDX_CURL_IMPERSONATE_BIN` | `ownership discover` and `ownership import --url` (IDX sits behind Cloudflare), and Yahoo fundamentals with `IDX_PROVIDER=yahoo` |
|
||||
| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow --source idx`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile (the default source, IPOT, needs nothing) |
|
||||
| `mutool` from MuPDF | importing legacy PDF ownership reports |
|
||||
|
||||
## The MCP server
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue