refactor: split into idx-core library and idx-cli binary workspace

Phase 2 of docs/DESIGN.md, no behavior change:

- crates/idx-core (library): api, analysis, cache, config file handling
  and provider selection, curl_impersonate, error, ownership, runtime,
  plus the shared test fixtures. clap derives are behind an optional
  `clap` feature so the library does not require clap.
- crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output
  format and color resolution). Package and binary names are unchanged.
- config.rs is split: file read/write and validation move to core; a
  CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting.
- ownership::db::open_db/db_path drop their unused IdxConfig argument.
- One workspace version; shared dependency versions; no third-party
  version changes in Cargo.lock. LICENSE is symlinked into each crate.
- flake: workspace version, new source paths, and a per-crate package
  check (idx-cli is built from its tarball against packaged idx-core,
  since idx-core is not on crates.io yet). CI cache key, release path
  filter, and the golden whitespace-hook exclusion follow the new layout.

Parity: all 143 golden outputs match byte for byte without
regeneration; 157 core + 17 CLI unit tests (173 before plus the new
OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main:
16/16 stocks cases identical; ownership discover got IDX 403 on both
(exit 4 vs 1 is the phase 1 exit-code change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 09:13:46 +00:00
commit 15947de51a
238 changed files with 544 additions and 375 deletions

43
crates/idx-cli/Cargo.toml Normal file
View file

@ -0,0 +1,43 @@
[package]
name = "idx-cli"
description = "CLI tool for Indonesian stock market (IDX) analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "../../README.md"
keywords = ["idx", "stocks", "indonesia", "cli", "finance"]
categories = ["command-line-utilities", "finance"]
include = ["/Cargo.toml", "/LICENSE", "/src/**"]
[[bin]]
name = "idx"
path = "src/main.rs"
[features]
default = ["ownership"]
# rusqlite/sha2/ureq back ownership import code that phase 3 moves into idx-core.
ownership = ["idx-core/ownership", "dep:rusqlite", "dep:sha2", "dep:ureq"]
[dependencies]
idx-core = { workspace = true, features = ["clap"] }
clap.workspace = true
clap_complete = "4"
serde.workspace = true
serde_json.workspace = true
comfy-table = "7"
owo-colors = "4"
toml.workspace = true
chrono.workspace = true
rusqlite = { workspace = true, optional = true }
sha2 = { workspace = true, optional = true }
ureq = { workspace = true, optional = true }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
rusqlite.workspace = true
sha2.workspace = true
zip.workspace = true

1
crates/idx-cli/LICENSE Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1,68 @@
use clap::{Args, Subcommand};
use crate::output::{OutputFormat, json};
use idx_core::cache::Cache;
use idx_core::error::IdxError;
use idx_core::runtime;
#[derive(Debug, Args)]
#[command(about = "Manage local cache")]
pub struct CacheCmd {
#[command(subcommand)]
pub command: CacheSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum CacheSubcommand {
#[command(about = "Show cache usage information")]
Info,
#[command(about = "Clear all cached files")]
Clear,
}
pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
let cache = Cache::new()?;
match &cmd.command {
CacheSubcommand::Info => {
let info = cache.info()?;
if format.is_json() {
return json::print_json(&serde_json::json!({
"path": info.path,
"files": info.files,
"size_bytes": info.total_size,
"oldest": info.oldest,
"newest": info.newest,
}));
}
println!("path: {}", info.path.display());
println!("files: {}", info.files);
println!("size_bytes: {}", info.total_size);
println!(
"oldest: {}",
info.oldest
.map(|v| v.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
);
println!(
"newest: {}",
info.newest
.map(|v| v.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
);
}
CacheSubcommand::Clear => {
let (removed, failed) = cache.clear()?;
if format.is_json() {
return json::print_json(&serde_json::json!({
"removed": removed,
"failed": failed,
}));
}
println!("cleared {removed} files");
if !failed.is_empty() {
runtime::warn(format!("failed to remove {} file(s)", failed.len()));
}
}
}
Ok(())
}

View file

@ -0,0 +1,45 @@
use clap::{Args, Subcommand};
use crate::config::{config_path, ensure_default_config, get_config_value, set_config_value};
use idx_core::error::IdxError;
#[derive(Debug, Args)]
#[command(about = "Manage configuration")]
pub struct ConfigCmd {
#[command(subcommand)]
pub command: ConfigSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum ConfigSubcommand {
#[command(about = "Create default configuration file")]
Init,
#[command(about = "Get a configuration value by key")]
Get { key: String },
#[command(about = "Set a configuration value by key")]
Set { key: String, value: String },
#[command(about = "Print configuration file path")]
Path,
}
pub fn handle(cmd: &ConfigCmd) -> Result<(), IdxError> {
match &cmd.command {
ConfigSubcommand::Init => {
let path = ensure_default_config()?;
println!("{}", path.display());
}
ConfigSubcommand::Get { key } => {
let value = get_config_value(key)?
.ok_or_else(|| IdxError::ConfigError(format!("key not found: {key}")))?;
println!("{value}");
}
ConfigSubcommand::Set { key, value } => {
set_config_value(key, value)?;
println!("ok");
}
ConfigSubcommand::Path => {
println!("{}", config_path()?.display());
}
}
Ok(())
}

View file

@ -0,0 +1,63 @@
pub mod cache;
pub mod config;
#[cfg(feature = "ownership")]
pub mod ownership;
pub mod stocks;
use clap::{Parser, Subcommand, ValueEnum};
use crate::output::OutputSetting;
#[derive(Debug, Clone, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
}
#[derive(Debug, Parser)]
#[command(
name = "idx",
bin_name = "idx",
about = "CLI tool for Indonesian stock market (IDX) analysis",
long_about = "idx-cli is a Rust command-line tool for Indonesian stock market (IDX) analysis.\nIt supports quote lookup, historical data, local caching, and output formats for both humans and AI agents."
)]
pub struct Cli {
#[arg(
short,
long,
value_enum,
global = true,
help = "Output format [default: auto = table on a terminal, json when piped]"
)]
pub output: Option<OutputSetting>,
#[arg(long, global = true, help = "Disable colored output")]
pub no_color: bool,
#[arg(short, long, global = true, help = "Suppress non-essential output")]
pub quiet: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")]
pub verbose: u8,
#[arg(long, global = true, help = "Run without network requests")]
pub offline: bool,
#[arg(long, global = true, help = "Bypass local cache")]
pub no_cache: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
#[command(about = "Stock data and analysis")]
Stocks(stocks::StocksCmd),
#[command(about = "Manage configuration")]
Config(config::ConfigCmd),
#[command(about = "Manage local cache")]
Cache(cache::CacheCmd),
#[cfg(feature = "ownership")]
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
Ownership(ownership::OwnershipCmd),
#[command(about = "Generate shell completions")]
Completions { shell: Shell },
#[command(about = "Show idx-cli version")]
Version,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
use std::fs;
use std::io::IsTerminal;
use serde::Deserialize;
pub use idx_core::config::*;
use crate::cli::Cli;
use crate::output::{OutputFormat, OutputSetting};
use idx_core::error::IdxError;
#[derive(Debug, Clone)]
pub struct IdxConfig {
pub provider: ProviderKind,
pub history_provider: HistoryProviderKind,
pub exchange: String,
pub output: OutputFormat,
pub no_color: bool,
pub quote_ttl: u64,
pub fundamental_ttl: u64,
}
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
general: Option<FileGeneral>,
cache: Option<FileCache>,
}
#[derive(Debug, Deserialize, Default)]
struct FileGeneral {
provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>,
output: Option<OutputSetting>,
color: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
struct FileCache {
quote_ttl: Option<u64>,
fundamental_ttl: Option<u64>,
}
impl Default for IdxConfig {
fn default() -> Self {
Self {
provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(),
output: OutputSetting::Auto.resolve(stdout_is_terminal()),
no_color: false,
quote_ttl: 300,
fundamental_ttl: 3600,
}
}
}
impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut cfg = Self::load()?;
if let Ok(provider) = std::env::var("IDX_PROVIDER") {
cfg.provider = ProviderKind::parse(&provider)?;
}
if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") {
cfg.history_provider = HistoryProviderKind::parse(&history_provider)?;
}
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
cfg.exchange = exchange;
}
if let Ok(output) = std::env::var("IDX_OUTPUT") {
let setting = OutputSetting::parse(&output).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
cfg.output = setting.resolve(stdout_is_terminal());
}
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? {
cfg.quote_ttl = parsed;
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? {
cfg.fundamental_ttl = parsed;
}
if let Some(output) = cli.output {
cfg.output = output.resolve(stdout_is_terminal());
}
cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal();
Ok(cfg)
}
pub fn load() -> Result<Self, IdxError> {
let mut cfg = Self::default();
let path = config_path()?;
if path.exists() {
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
let parsed: FileConfig =
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
if let Some(general) = parsed.general {
if let Some(provider) = general.provider {
cfg.provider = provider;
}
if let Some(history_provider) = general.history_provider {
cfg.history_provider = history_provider;
}
if let Some(exchange) = general.exchange {
cfg.exchange = exchange;
}
if let Some(output) = general.output {
cfg.output = output.resolve(stdout_is_terminal());
}
if let Some(color) = general.color {
cfg.no_color = !color;
}
}
if let Some(cache) = parsed.cache {
if let Some(v) = cache.quote_ttl {
cfg.quote_ttl = v;
}
if let Some(v) = cache.fundamental_ttl {
cfg.fundamental_ttl = v;
}
}
}
Ok(cfg)
}
}
pub fn stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
#[cfg(test)]
mod tests {
use super::IdxConfig;
#[test]
fn default_values_are_sane() {
let cfg = IdxConfig::default();
assert_eq!(cfg.provider, super::ProviderKind::Msn);
assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto);
assert_eq!(cfg.exchange, "JK");
assert_eq!(cfg.quote_ttl, 300);
}
}
#[cfg(test)]
mod output_values_tests {
use clap::ValueEnum;
use crate::output::OutputSetting;
#[test]
fn core_output_values_match_cli_output_setting() {
let cli: Vec<String> = OutputSetting::value_variants()
.iter()
.filter_map(|v| v.to_possible_value())
.map(|v| v.get_name().to_string())
.collect();
assert_eq!(cli, super::OUTPUT_VALUES);
}
}

104
crates/idx-cli/src/main.rs Normal file
View file

@ -0,0 +1,104 @@
mod cli;
mod config;
mod output;
use idx_core::runtime;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::{generate, shells};
use crate::cli::{Cli, Commands};
use crate::config::IdxConfig;
use crate::output::{OutputFormat, OutputSetting, emit_error};
use idx_core::api::default_provider;
use idx_core::error::IdxError;
fn main() {
if let Err(err) = run() {
std::process::exit(err.exit_code());
}
}
fn run() -> Result<(), IdxError> {
let cli = Cli::parse();
runtime::set_quiet(cli.quiet);
let config = match IdxConfig::load_with_cli(&cli) {
Ok(config) => config,
Err(err) => {
emit_error(&err, &bootstrap_output_format(&cli));
return Err(err);
}
};
output::json::configure(config.output, config::stdout_is_terminal());
output::style::set_color(!config.no_color);
match &cli.command {
Commands::Version => {
if config.output.is_json() {
output::json::print_json(&serde_json::json!({
"version": env!("CARGO_PKG_VERSION")
}))?;
} else {
println!("{}", env!("CARGO_PKG_VERSION"));
}
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let name = cmd.get_name().to_owned();
match shell {
cli::Shell::Bash => generate(shells::Bash, &mut cmd, name, &mut std::io::stdout()),
cli::Shell::Zsh => generate(shells::Zsh, &mut cmd, name, &mut std::io::stdout()),
cli::Shell::Fish => generate(shells::Fish, &mut cmd, name, &mut std::io::stdout()),
}
}
Commands::Stocks(stocks) => {
let provider = default_provider(config.provider, cli.verbose > 0);
if let Err(err) = cli::stocks::handle(
stocks,
&config,
&provider,
cli.offline,
cli.no_cache,
cli.verbose > 0,
) {
emit_error(&err, &config.output);
return Err(err);
}
}
Commands::Config(cfg) => {
if let Err(err) = cli::config::handle(cfg) {
emit_error(&err, &config.output);
return Err(err);
}
}
Commands::Cache(cache) => {
if let Err(err) = cli::cache::handle(cache, &config.output) {
emit_error(&err, &config.output);
return Err(err);
}
}
#[cfg(feature = "ownership")]
Commands::Ownership(cmd) => {
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
emit_error(&err, &config.output);
return Err(err);
}
}
}
Ok(())
}
/// Output format for errors raised before the configuration has loaded.
fn bootstrap_output_format(cli: &Cli) -> OutputFormat {
let setting = cli
.output
.or_else(|| {
std::env::var("IDX_OUTPUT")
.ok()
.and_then(|value| OutputSetting::parse(&value))
})
.unwrap_or_default();
setting.resolve(config::stdout_is_terminal())
}

View file

@ -0,0 +1,44 @@
use std::sync::atomic::{AtomicU8, Ordering};
use serde::Serialize;
use idx_core::error::IdxError;
const PRETTY: u8 = 0;
const COMPACT: u8 = 1;
const LINES: u8 = 2;
static STYLE: AtomicU8 = AtomicU8::new(PRETTY);
/// Pretty JSON only for a human at a terminal; compact for pipes and
/// captured output; JSON Lines when requested.
pub fn configure(format: super::OutputFormat, stdout_is_terminal: bool) {
let style = match format {
super::OutputFormat::Jsonl => LINES,
_ if stdout_is_terminal => PRETTY,
_ => COMPACT,
};
STYLE.store(style, Ordering::Relaxed);
}
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), IdxError> {
let to_err = |e: serde_json::Error| IdxError::ParseError(e.to_string());
match STYLE.load(Ordering::Relaxed) {
PRETTY => println!("{}", serde_json::to_string_pretty(value).map_err(to_err)?),
COMPACT => println!("{}", serde_json::to_string(value).map_err(to_err)?),
_ => {
// Split an array into one line per item without re-serializing,
// so each line keeps the same key order as `-o json`.
let compact = serde_json::to_string(value).map_err(to_err)?;
match serde_json::from_str::<Vec<&serde_json::value::RawValue>>(&compact) {
Ok(items) => {
for item in items {
println!("{}", item.get());
}
}
Err(_) => println!("{compact}"),
}
}
}
Ok(())
}

View file

@ -0,0 +1,257 @@
pub mod json;
pub mod style;
pub mod table;
use std::io::IsTerminal;
use chrono::NaiveDate;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::signals::TechnicalSignal;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData,
};
use idx_core::error::IdxError;
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputSetting {
// Table on a terminal, JSON when stdout is piped or captured.
#[default]
Auto,
Table,
Json,
// JSON Lines: one array item per line.
Jsonl,
}
impl OutputSetting {
pub fn parse(value: &str) -> Option<Self> {
<Self as ValueEnum>::from_str(value, true).ok()
}
pub fn resolve(self, stdout_is_terminal: bool) -> OutputFormat {
match self {
Self::Auto if stdout_is_terminal => OutputFormat::Table,
Self::Auto | Self::Json => OutputFormat::Json,
Self::Table => OutputFormat::Table,
Self::Jsonl => OutputFormat::Jsonl,
}
}
}
/// Resolved output format used by renderers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Table,
Json,
Jsonl,
}
impl OutputFormat {
/// True for every machine-readable format (JSON and JSON Lines).
pub fn is_json(self) -> bool {
matches!(self, Self::Json | Self::Jsonl)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechnicalReport {
pub symbol: String,
pub as_of: NaiveDate,
pub current_price: i64,
pub sma20: Option<f64>,
pub sma50: Option<f64>,
pub sma200: Option<f64>,
pub rsi14: Option<f64>,
pub macd: MacdSnapshot,
pub volume: VolumeSnapshot,
pub signals: TechnicalSignal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacdSnapshot {
pub line: Option<f64>,
pub signal: Option<f64>,
pub histogram: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeSnapshot {
pub current: u64,
pub average20: Option<f64>,
pub ratio20: Option<f64>,
}
pub fn render_quotes(
quotes: &[Quote],
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
}
}
pub fn render_history(
symbol: &str,
history: &[Ohlc],
format: &OutputFormat,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_history(symbol, history),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(history),
}
}
pub fn render_technical(
report: &TechnicalReport,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_technical(report, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_growth(
symbol: &str,
report: &GrowthReport,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_growth(symbol, report, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_valuation(
symbol: &str,
report: &ValuationReport,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_valuation(symbol, report, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_risk(
symbol: &str,
report: &RiskReport,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_risk(symbol, report, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_fundamental(
report: &FundamentalReport,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_fundamental(report, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_compare(
reports: &[FundamentalReport],
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_compare(reports, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(reports),
}
}
pub fn render_profile(profile: &CompanyProfile, format: &OutputFormat) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_profile(profile),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(profile),
}
}
pub fn render_financials(
financials: &FinancialStatements,
format: &OutputFormat,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_financials(financials),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(financials),
}
}
pub fn render_earnings(report: &EarningsReport, format: &OutputFormat) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_earnings(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
pub fn render_sentiment(data: &SentimentData, format: &OutputFormat) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_sentiment(data),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(data),
}
}
pub fn render_insights(data: &InsightData, format: &OutputFormat) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_insights(data),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(data),
}
}
pub fn render_news(items: &[NewsItem], format: &OutputFormat) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_news(items),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(items),
}
}
pub fn render_screener(
quotes: &[Quote],
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
}
}
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
if !format.is_json() {
eprintln!("Error: {err}");
return;
}
let payload = serde_json::json!({
"error": true,
"code": format!("{:?}", err.code()).to_uppercase(),
"message": err.to_string()
});
let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal();
let rendered = if pretty {
serde_json::to_string_pretty(&payload)
} else {
serde_json::to_string(&payload)
};
eprintln!("{}", rendered.unwrap_or_else(|_| "{}".to_string()));
}

View file

@ -0,0 +1,41 @@
//! Terminal styling that turns itself off when color is disabled or stdout is
//! not a terminal, so piped output never carries ANSI escape codes.
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
use owo_colors::OwoColorize;
static COLOR: AtomicBool = AtomicBool::new(true);
pub fn set_color(enabled: bool) {
COLOR.store(enabled, Ordering::Relaxed);
}
fn paint<T: Display>(value: T, styled: impl FnOnce(&T) -> String) -> String {
if COLOR.load(Ordering::Relaxed) {
styled(&value)
} else {
value.to_string()
}
}
pub trait Paint: Display + Sized {
fn bold(self) -> String {
paint(self, |v| OwoColorize::bold(v).to_string())
}
fn green(self) -> String {
paint(self, |v| OwoColorize::green(v).to_string())
}
fn red(self) -> String {
paint(self, |v| OwoColorize::red(v).to_string())
}
fn yellow(self) -> String {
paint(self, |v| OwoColorize::yellow(v).to_string())
}
fn blue(self) -> String {
paint(self, |v| OwoColorize::blue(v).to_string())
}
}
impl<T: Display> Paint for T {}

View file

@ -0,0 +1,967 @@
use crate::output::style::Paint;
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use crate::output::TechnicalReport;
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::signals::Signal;
use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
Quote, SentimentData,
};
use idx_core::error::IdxError;
pub fn format_idr(value: i64) -> String {
let sign = if value.is_negative() { "-" } else { "" };
let chars: Vec<char> = value.unsigned_abs().to_string().chars().rev().collect();
let mut out = String::new();
for (i, ch) in chars.iter().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(*ch);
}
format!("{sign}{}", out.chars().rev().collect::<String>())
}
pub fn format_u64(value: u64) -> String {
format_idr(value as i64)
}
fn format_52w_range_bar(position: Option<f64>) -> String {
let Some(pos) = position else {
return "-".to_string();
};
let clamped = pos.clamp(0.0, 1.0);
let filled = (clamped * 10.0).round() as usize;
format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled))
}
pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"SYMBOL",
"PRICE",
"CHG",
"CHG%",
"VOLUME",
"MKT CAP",
"52W RANGE",
]);
for q in quotes {
let pct_cell = match q.change_pct {
None => Cell::new("-"),
Some(pct) if no_color => Cell::new(format!("{pct:+.2}%")),
Some(pct) if pct >= 0.0 => Cell::new(format!("{pct:+.2}%")).fg(Color::Green),
Some(pct) => Cell::new(format!("{pct:+.2}%")).fg(Color::Red),
};
table.add_row(vec![
Cell::new(&q.symbol),
Cell::new(format_idr(q.price)),
Cell::new(
q.change
.map(|c| format!("{c:+}"))
.unwrap_or_else(|| "-".to_string()),
),
pct_cell,
Cell::new(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
Cell::new(
q.market_cap
.map(format_u64)
.unwrap_or_else(|| "-".to_string()),
),
Cell::new(format_52w_range_bar(q.week52_position)),
]);
}
println!("{table}");
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
println!("{note}");
}
Ok(())
}
/// 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.
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!(
"Note: {} last traded on {}; this price is not current.",
q.symbol,
as_of.date_naive()
)
})
})
.collect()
}
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
println!("{}", format!("History for {symbol}").bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["DATE", "OPEN", "HIGH", "LOW", "CLOSE", "VOLUME"]);
for item in history {
table.add_row(vec![
Cell::new(item.date),
Cell::new(format_idr(item.open)),
Cell::new(format_idr(item.high)),
Cell::new(format_idr(item.low)),
Cell::new(format_idr(item.close)),
Cell::new(format_u64(item.volume)),
]);
}
println!("{table}");
Ok(())
}
pub fn print_technical(report: &TechnicalReport, no_color: bool) -> Result<(), IdxError> {
println!(
"{}",
format!(
"Technical Analysis for {} ({})",
report.symbol, report.as_of
)
.bold()
);
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
table.add_row(vec![
Cell::new("Current Price"),
Cell::new(format_idr(report.current_price)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("SMA 20"),
Cell::new(format_idr_option(report.sma20)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("SMA 50"),
Cell::new(format_idr_option(report.sma50)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("SMA 200"),
Cell::new(format_idr_option(report.sma200)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("RSI (14)"),
Cell::new(format_float(report.rsi14, 2)),
Cell::new(format_signal(report.signals.rsi, no_color, false)),
]);
table.add_row(vec![
Cell::new("MACD (12,26,9)"),
Cell::new(format!(
"{}/{}/{}",
format_float(report.macd.line, 2),
format_float(report.macd.signal, 2),
format_float(report.macd.histogram, 2)
)),
Cell::new(format_signal(report.signals.macd, no_color, false)),
]);
table.add_row(vec![
Cell::new("Trend"),
Cell::new(trend_context(report)),
Cell::new(format_signal(report.signals.trend, no_color, false)),
]);
table.add_row(vec![
Cell::new("Volume Ratio (20)"),
Cell::new(format_volume_ratio(report)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("Overall Signal"),
Cell::new("-"),
Cell::new(format_signal(report.signals.overall, no_color, true)),
]);
println!("{table}");
Ok(())
}
pub fn print_growth(symbol: &str, report: &GrowthReport, no_color: bool) -> Result<(), IdxError> {
println!("{}", format!("Growth Analysis for {symbol}").bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
table.add_row(vec![
Cell::new("Revenue Growth"),
Cell::new(format_pct(report.revenue_growth_pct)),
Cell::new(format_growth_signal(&report.revenue_signal, no_color)),
]);
table.add_row(vec![
Cell::new("Earnings Growth"),
Cell::new(format_pct(report.earnings_growth_pct)),
Cell::new(format_growth_signal(&report.earnings_signal, no_color)),
]);
table.add_row(vec![
Cell::new("Overall"),
Cell::new("-"),
Cell::new(format_growth_signal(&report.overall_signal, no_color)),
]);
println!("{table}");
Ok(())
}
pub fn print_valuation(
symbol: &str,
report: &ValuationReport,
no_color: bool,
) -> Result<(), IdxError> {
println!("{}", format!("Valuation Analysis for {symbol}").bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
table.add_row(vec![
Cell::new("P/E (Trailing)"),
Cell::new(format_opt_f64(report.pe_trailing, 2)),
Cell::new(format_valuation_signal(&report.pe_signal, no_color)),
]);
table.add_row(vec![
Cell::new("P/E (Forward)"),
Cell::new(format_opt_f64(report.pe_forward, 2)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("Price/Book"),
Cell::new(format_opt_f64(report.pb, 2)),
Cell::new(format_valuation_signal(&report.pb_signal, no_color)),
]);
table.add_row(vec![
Cell::new("ROE"),
Cell::new(format_pct(report.roe_pct)),
Cell::new(format_valuation_signal(&report.roe_signal, no_color)),
]);
table.add_row(vec![
Cell::new("Net Margin"),
Cell::new(format_pct(report.net_margin_pct)),
Cell::new(format_valuation_signal(&report.margin_signal, no_color)),
]);
table.add_row(vec![
Cell::new("EV/EBITDA"),
Cell::new(format_opt_f64(report.ev_ebitda, 2)),
Cell::new(format_valuation_signal(&report.ev_ebitda_signal, no_color)),
]);
table.add_row(vec![
Cell::new("Overall"),
Cell::new("-"),
Cell::new(format_valuation_signal(&report.overall_signal, no_color)),
]);
println!("{table}");
Ok(())
}
pub fn print_risk(symbol: &str, report: &RiskReport, no_color: bool) -> Result<(), IdxError> {
println!("{}", format!("Risk Analysis for {symbol}").bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
table.add_row(vec![
Cell::new("Debt/Equity"),
Cell::new(format_opt_f64(report.debt_to_equity, 2)),
Cell::new(format_risk_signal(&report.de_signal, no_color)),
]);
table.add_row(vec![
Cell::new("Current Ratio"),
Cell::new(format_opt_f64(report.current_ratio, 2)),
Cell::new(format_risk_signal(&report.current_ratio_signal, no_color)),
]);
table.add_row(vec![
Cell::new("ROA"),
Cell::new(format_pct(report.roa_pct)),
Cell::new("-"),
]);
table.add_row(vec![
Cell::new("Overall"),
Cell::new("-"),
Cell::new(format_risk_signal(&report.overall_signal, no_color)),
]);
println!("{table}");
Ok(())
}
pub fn print_fundamental(report: &FundamentalReport, no_color: bool) -> Result<(), IdxError> {
println!(
"{}",
format!("Fundamental Analysis for {}", report.symbol).bold()
);
println!();
print_growth(&report.symbol, &report.growth, no_color)?;
println!();
print_valuation(&report.symbol, &report.valuation, no_color)?;
println!();
print_risk(&report.symbol, &report.risk, no_color)?;
println!();
println!(
"{} {}",
"Overall Signal:".bold(),
format_growth_signal(&report.overall_signal, no_color)
);
Ok(())
}
pub fn print_compare(reports: &[FundamentalReport], no_color: bool) -> Result<(), IdxError> {
println!("{}", "Fundamental Comparison".bold());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
let mut header = vec![Cell::new("METRIC")];
header.extend(reports.iter().map(|report| Cell::new(&report.symbol)));
table.set_header(header);
add_compare_row(
&mut table,
"Symbol",
reports
.iter()
.map(|report| report.symbol.clone())
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"Overall",
reports
.iter()
.map(|report| format_growth_signal(&report.overall_signal, no_color))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"Growth",
reports
.iter()
.map(|report| format_growth_signal(&report.growth.overall_signal, no_color))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"Valuation",
reports
.iter()
.map(|report| format_valuation_signal(&report.valuation.overall_signal, no_color))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"Risk",
reports
.iter()
.map(|report| format_risk_signal(&report.risk.overall_signal, no_color))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"P/E",
reports
.iter()
.map(|report| format_opt_f64(report.valuation.pe_trailing, 2))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"ROE",
reports
.iter()
.map(|report| format_pct(report.valuation.roe_pct))
.collect::<Vec<_>>(),
);
add_compare_row(
&mut table,
"Revenue Growth",
reports
.iter()
.map(|report| format_pct(report.growth.revenue_growth_pct))
.collect::<Vec<_>>(),
);
println!("{table}");
Ok(())
}
fn format_idr_option(value: Option<f64>) -> String {
value
.map(|v| format_idr(v.round() as i64))
.unwrap_or_else(|| "-".to_string())
}
fn format_float(value: Option<f64>, precision: usize) -> String {
value
.map(|v| format!("{v:.precision$}"))
.unwrap_or_else(|| "-".to_string())
}
fn format_idr_option_from_f64(value: Option<f64>) -> String {
value
.map(|v| format_idr(v.round() as i64))
.unwrap_or_else(|| "-".to_string())
}
fn format_table_date(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return "-".to_string();
}
trimmed.split('T').next().unwrap_or(trimmed).to_string()
}
fn format_table_date_option(value: Option<&str>) -> String {
value
.filter(|raw| !raw.trim().is_empty())
.map(format_table_date)
.unwrap_or_else(|| "-".to_string())
}
fn humanize_metric_key(key: &str) -> String {
let trimmed = key.trim();
if trimmed.is_empty() {
return "-".to_string();
}
let chars: Vec<char> = trimmed.chars().collect();
let mut words = Vec::new();
let mut current = String::new();
for (idx, ch) in chars.iter().enumerate() {
if matches!(ch, '_' | '-' | ' ') {
if !current.is_empty() {
words.push(current);
current = String::new();
}
continue;
}
let next = chars.get(idx + 1).copied();
let boundary = current.chars().last().is_some_and(|prev| {
(prev.is_ascii_lowercase() && ch.is_ascii_uppercase())
|| (prev.is_ascii_alphabetic() && ch.is_ascii_digit())
|| (prev.is_ascii_digit() && ch.is_ascii_alphabetic())
|| (prev.is_ascii_uppercase()
&& ch.is_ascii_uppercase()
&& next.is_some_and(|next_ch| next_ch.is_ascii_lowercase()))
});
if boundary {
words.push(current);
current = String::new();
}
current.push(*ch);
}
if !current.is_empty() {
words.push(current);
}
words
.iter()
.map(|word| humanize_metric_word(word))
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_metric_word(word: &str) -> String {
let upper = word.to_ascii_uppercase();
match upper.as_str() {
"EPS" | "DPS" | "EBIT" | "EBITDA" | "GAAP" | "IDR" | "CIQ" => upper,
_ if word.chars().all(|ch| ch.is_ascii_uppercase()) && word.len() <= 4 => word.to_string(),
_ => {
let mut chars = word.chars();
let first = chars.next().unwrap_or_default().to_ascii_uppercase();
format!("{first}{}", chars.as_str().to_ascii_lowercase())
}
}
}
fn format_earnings_period(period: &str) -> String {
let trimmed = period.trim();
if trimmed.is_empty() {
return "-".to_string();
}
if trimmed.len() == 4 && trimmed.chars().all(|ch| ch.is_ascii_digit()) {
return format!("FY{trimmed}");
}
if trimmed.len() == 6
&& trimmed.starts_with('Q')
&& trimmed[1..2].chars().all(|ch| ch.is_ascii_digit())
&& trimmed[2..].chars().all(|ch| ch.is_ascii_digit())
{
return format!("{} {}", &trimmed[..2], &trimmed[2..]);
}
trimmed.to_string()
}
fn format_opt_f64(value: Option<f64>, precision: usize) -> String {
format_float(value, precision)
}
fn format_pct(value: Option<f64>) -> String {
value
.map(|v| format!("{v:+.2}%"))
.unwrap_or_else(|| "-".to_string())
}
fn format_signal(signal: Signal, no_color: bool, uppercase: bool) -> String {
let label = if uppercase {
signal_label_upper(signal)
} else {
signal_label(signal)
};
if no_color {
return label.to_string();
}
match signal {
Signal::Bullish => label.green().to_string(),
Signal::Bearish => label.red().to_string(),
Signal::Neutral => label.yellow().to_string(),
}
}
fn signal_label(signal: Signal) -> &'static str {
match signal {
Signal::Bullish => "Bullish",
Signal::Bearish => "Bearish",
Signal::Neutral => "Neutral",
}
}
fn signal_label_upper(signal: Signal) -> &'static str {
match signal {
Signal::Bullish => "BULLISH",
Signal::Bearish => "BEARISH",
Signal::Neutral => "NEUTRAL",
}
}
fn trend_context(report: &TechnicalReport) -> String {
match (report.sma50, report.sma200) {
(Some(sma50), Some(sma200)) => format!(
"{} vs SMA50 {}, SMA200 {}",
format_idr(report.current_price),
format_idr(sma50.round() as i64),
format_idr(sma200.round() as i64)
),
_ => "Trend unavailable (need at least 200 daily candles)".to_string(),
}
}
fn format_volume_ratio(report: &TechnicalReport) -> String {
match (report.volume.ratio20, report.volume.average20) {
(Some(ratio), Some(avg)) => format!(
"{ratio:.2}x ({} vs {} avg)",
format_u64(report.volume.current),
format_u64(avg.round() as u64)
),
_ => "-".to_string(),
}
}
fn format_growth_signal(signal: &str, no_color: bool) -> String {
format_text_signal(
signal,
no_color,
&["strong", "moderate", "growing", "healthy"],
&["contracting", "declining", "shrinking", "weak"],
)
}
fn format_valuation_signal(signal: &str, no_color: bool) -> String {
format_text_signal(
signal,
no_color,
&["deep value", "undervalued", "excellent", "strong"],
&["expensive", "negative"],
)
}
fn format_risk_signal(signal: &str, no_color: bool) -> String {
format_text_signal(
signal,
no_color,
&["conservative", "strong", "adequate", "low risk"],
&["highly leveraged", "weak", "high risk", "negative equity"],
)
}
fn format_text_signal(
signal: &str,
no_color: bool,
positive: &[&str],
negative: &[&str],
) -> String {
if no_color {
return signal.to_string();
}
if positive.contains(&signal) {
signal.green().to_string()
} else if negative.contains(&signal) {
signal.red().to_string()
} else {
signal.yellow().to_string()
}
}
fn add_compare_row(table: &mut Table, label: &str, values: Vec<String>) {
let mut row = vec![Cell::new(label)];
row.extend(values.into_iter().map(Cell::new));
table.add_row(row);
}
pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_header(vec!["FIELD", "VALUE"]);
// Use long_name with short_name as fallback (IDX stocks often only have shortName)
let name = if !profile.long_name.is_empty() {
&profile.long_name
} else {
&profile.short_name
};
let add_if_present = |t: &mut Table, label: &str, value: &str| {
if !value.is_empty() {
t.add_row(vec![Cell::new(label), Cell::new(value)]);
}
};
add_if_present(&mut table, "Symbol", &profile.symbol);
add_if_present(&mut table, "Name", name);
add_if_present(&mut table, "Sector", &profile.sector);
add_if_present(&mut table, "Industry", &profile.industry);
add_if_present(&mut table, "Website", &profile.website);
add_if_present(&mut table, "Country", &profile.country);
add_if_present(&mut table, "City", &profile.city);
add_if_present(&mut table, "Phone", &profile.phone);
if profile.employees > 0 {
table.add_row(vec![
Cell::new("Employees"),
Cell::new(profile.employees.to_string()),
]);
}
if !profile.description.is_empty() {
// Truncate long descriptions for table display
let desc = if profile.description.len() > 200 {
format!("{}...", &profile.description[..200])
} else {
profile.description.clone()
};
table.add_row(vec![Cell::new("Description"), Cell::new(desc)]);
}
if !profile.officers.is_empty() {
table.add_row(vec![
Cell::new("Executives"),
Cell::new(
profile
.officers
.iter()
.take(5)
.map(|o| format!("{} ({})", o.name, o.title))
.collect::<Vec<_>>()
.join("\n"),
),
]);
}
println!("{table}");
Ok(())
}
pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> {
let print_section = |label: &str, section: &idx_core::api::types::StatementSection| {
println!("\n── {label} ({}) ──", format_table_date(&section.end_date));
let mut t = Table::new();
let value_header = format!("VALUE ({})", section.currency);
t.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["LINE ITEM", value_header.as_str()]);
let mut entries: Vec<(String, &f64)> = section
.values
.iter()
.map(|(key, value)| (humanize_metric_key(key), value))
.collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
for (label, value) in entries {
t.add_row(vec![Cell::new(label), Cell::new(format_idr(*value as i64))]);
}
println!("{t}");
};
if let Some(income) = &fin.income_statement {
print_section("Income Statement", income);
}
if let Some(balance) = &fin.balance_sheet {
print_section("Balance Sheet", balance);
}
if let Some(cf) = &fin.cash_flow {
print_section("Cash Flow", cf);
}
if fin.income_statement.is_none() && fin.balance_sheet.is_none() && fin.cash_flow.is_none() {
println!("No financial statement data available for this stock.");
}
Ok(())
}
pub fn print_earnings(report: &EarningsReport) -> Result<(), IdxError> {
if report.history.is_empty() && report.forecast.is_empty() {
println!("No earnings data available for this stock.");
return Ok(());
}
if !report.history.is_empty() {
println!("── Earnings History ──");
let mut history_table = Table::new();
history_table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"PERIOD",
"EPS ACT",
"EPS FC",
"SURPRISE",
"SURPRISE%",
"REV ACT",
"DATE",
]);
for row in &report.history {
add_earnings_history_row(&mut history_table, row);
}
println!("{history_table}");
}
if !report.forecast.is_empty() {
println!("\n── Earnings Forecast ──");
let mut forecast_table = Table::new();
forecast_table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["PERIOD", "EPS FC", "REV FC", "DATE"]);
for row in &report.forecast {
add_earnings_forecast_row(&mut forecast_table, row);
}
println!("{forecast_table}");
}
Ok(())
}
pub fn print_sentiment(data: &SentimentData) -> Result<(), IdxError> {
if data.statistics.is_empty() {
println!("No sentiment votes for {}.", data.symbol);
return Ok(());
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_header(vec!["RANGE", "BULLISH", "BEARISH", "NEUTRAL"]);
for row in &data.statistics {
table.add_row(vec![
Cell::new(&row.time_range),
Cell::new(row.bullish),
Cell::new(row.bearish),
Cell::new(row.neutral),
]);
}
println!("{table}");
Ok(())
}
pub fn print_insights(data: &InsightData) -> Result<(), IdxError> {
println!("{}", data.summary);
if !data.last_updated.is_empty() {
println!("Last updated: {}", data.last_updated);
}
if !data.highlights.is_empty() {
println!("Highlights:");
for h in &data.highlights {
println!("- {h}");
}
}
if !data.risks.is_empty() {
println!("Risks:");
for r in &data.risks {
println!("- {r}");
}
}
Ok(())
}
pub fn print_news(items: &[NewsItem]) -> Result<(), IdxError> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_header(vec!["TITLE", "PROVIDER", "DATE", "URL"]);
for item in items {
table.add_row(vec![
Cell::new(&item.title),
Cell::new(&item.provider),
Cell::new(&item.published_at),
Cell::new(truncate_url(&item.url)),
]);
}
println!("{table}");
Ok(())
}
fn add_earnings_history_row(table: &mut Table, row: &EarningsData) {
table.add_row(vec![
Cell::new(format_earnings_period(&row.period_type)),
Cell::new(format_float(row.eps_actual, 2)),
Cell::new(format_float(row.eps_forecast, 2)),
Cell::new(format_float(row.eps_surprise, 2)),
Cell::new(format_float(row.eps_surprise_pct, 2)),
Cell::new(format_idr_option_from_f64(row.revenue_actual)),
Cell::new(format_table_date_option(
row.earning_release_date.as_deref(),
)),
]);
}
fn add_earnings_forecast_row(table: &mut Table, row: &EarningsData) {
table.add_row(vec![
Cell::new(format_earnings_period(&row.period_type)),
Cell::new(format_float(row.eps_forecast, 2)),
Cell::new(format_idr_option_from_f64(row.revenue_forecast)),
Cell::new(format_table_date_option(
row.earning_release_date.as_deref(),
)),
]);
}
fn truncate_url(url: &str) -> String {
if url.len() > 72 {
format!("{}...", &url[..72])
} else {
url.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
humanize_metric_key, stale_quote_notes,
};
use idx_core::analysis::signals::Signal;
use idx_core::api::types::Quote;
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
Quote {
symbol: symbol.into(),
price: 204,
change: None,
change_pct: None,
volume: None,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: None,
avg_volume: None,
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
}
}
#[test]
fn notes_only_quotes_older_than_a_week() {
let now = chrono::DateTime::parse_from_rfc3339("2026-10-02T10:00:00Z")
.unwrap()
.to_utc();
let quotes = [
quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"),
quote_as_of("BBCA.JK", "2026-10-02T16:15:00+07:00"),
quote_as_of("TLKM.JK", "2026-09-26T16:15:00+07:00"),
];
assert_eq!(
stale_quote_notes(&quotes, now),
vec!["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");
assert_eq!(format_idr(-433_471_000_000), "-433,471,000,000");
assert_eq!(format_u64(1_215_200_000_000_000), "1,215,200,000,000,000");
}
#[test]
fn formats_plain_signal_labels() {
assert_eq!(format_signal(Signal::Bullish, true, false), "Bullish");
assert_eq!(format_signal(Signal::Bearish, true, true), "BEARISH");
}
#[test]
fn humanizes_metric_keys_for_table_output() {
assert_eq!(humanize_metric_key("netIncome"), "Net Income");
assert_eq!(
humanize_metric_key("basicEPSExcludingExtraordinaryItems"),
"Basic EPS Excluding Extraordinary Items"
);
assert_eq!(
humanize_metric_key("cashAndShortTermInvestments"),
"Cash And Short Term Investments"
);
}
#[test]
fn formats_table_dates_without_timestamps() {
assert_eq!(format_table_date("2025-03-31T00:00:00Z"), "2025-03-31");
assert_eq!(format_table_date("2025-12-31"), "2025-12-31");
}
#[test]
fn formats_earnings_periods_for_display() {
assert_eq!(format_earnings_period("2025"), "FY2025");
assert_eq!(format_earnings_period("Q12026"), "Q1 2026");
assert_eq!(format_earnings_period(""), "-");
}
}

2321
crates/idx-cli/tests/cli.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,240 @@
//! Golden output tests: byte-for-byte parity of CLI stdout, stderr, and exit
//! code for every shipped command (see "Parity and Verification" in
//! docs/DESIGN.md).
//!
//! Cases live in `tests/golden/cases.toml`; expected output in
//! `tests/golden/<case>.golden`. Regenerate only for an intentional, reviewed
//! output change, and commit the diff with the reason:
//!
//! UPDATE_GOLDEN=1 cargo test --test golden
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use serde::Deserialize;
const IDX: &str = env!("CARGO_BIN_EXE_idx");
const CRATE: &str = env!("CARGO_MANIFEST_DIR");
#[derive(Deserialize)]
struct CaseFile {
case: Vec<Case>,
}
#[derive(Deserialize)]
struct Case {
name: String,
args: Vec<String>,
/// Output formats to expand the case into (`-o <format>` is prepended and
/// `.<format>` appended to the name). Empty means run the args as given.
#[serde(default = "default_formats")]
formats: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
/// Seed the fixture ownership DB before running.
#[serde(default)]
ownership: bool,
/// Commands run first in the same environment; their output is ignored.
#[serde(default)]
before: Vec<Vec<String>>,
/// Text piped to the command's stdin.
#[serde(default)]
stdin: Option<String>,
}
fn default_formats() -> Vec<String> {
vec!["table".into(), "json".into()]
}
/// Commands run from the idx-core crate root: the mock provider and the cases
/// read fixtures as `tests/fixtures/...` relative to the working directory.
fn core_dir() -> PathBuf {
Path::new(CRATE)
.join("../idx-core")
.canonicalize()
.expect("idx-core crate dir")
}
fn golden_dir() -> PathBuf {
Path::new(CRATE).join("tests").join("golden")
}
fn work_dir() -> PathBuf {
std::env::temp_dir().join(format!("idx-golden-{}", std::process::id()))
}
/// Runs idx with an isolated home under `root` and only the given IDX_* env.
fn idx(root: &Path, args: &[String], env: &BTreeMap<String, String>) -> std::process::Output {
idx_with_stdin(root, args, env, None)
}
fn idx_with_stdin(
root: &Path,
args: &[String],
env: &BTreeMap<String, String>,
stdin: Option<&str>,
) -> std::process::Output {
let mut cmd = Command::new(IDX);
cmd.current_dir(core_dir()).args(args);
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("IDX_") || key == "NO_COLOR" {
cmd.env_remove(key);
}
}
cmd.env("HOME", root)
.env("XDG_CONFIG_HOME", root.join("config"))
.env("XDG_CACHE_HOME", root.join("cache"))
.env("XDG_DATA_HOME", root.join("data"))
.env("IDX_USE_MOCK_PROVIDER", "1")
.envs(env)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn idx");
let mut pipe = child.stdin.take().expect("stdin pipe");
if let Some(input) = stdin {
pipe.write_all(input.as_bytes()).expect("write stdin");
}
drop(pipe);
child.wait_with_output().expect("run idx")
}
/// Fixture ownership DB: the above-1% XLSX (2026-08-31) plus two KSEI archive
/// months (2026-01-30, 2026-02-27), with wall-clock columns pinned.
fn seeded_ownership_db() -> &'static Path {
static DB: OnceLock<PathBuf> = OnceLock::new();
DB.get_or_init(|| {
let root = work_dir().join("seed");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create seed dir");
// The importer derives the release from the IDX file name.
let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx");
fs::copy(
core_dir().join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
&xlsx,
)
.expect("copy xlsx fixture");
let inputs = [
xlsx,
core_dir().join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"),
core_dir().join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
];
for input in inputs {
let args = ["ownership", "import", "--file", input.to_str().unwrap()]
.map(String::from)
.to_vec();
let out = idx(&root, &args, &BTreeMap::new());
assert!(out.status.success(), "seed import failed: {out:?}");
}
let db = root.join("data/idx/ownership.db");
let conn = rusqlite::Connection::open(&db).expect("open seed db");
conn.execute_batch(
"UPDATE ownership_releases SET imported_at = 0;
UPDATE entities SET created_at = 0, updated_at = 0;
UPDATE bing_holdings SET fetched_at = 0;",
)
.expect("pin timestamps");
db
})
}
fn render(root: &Path, out: &std::process::Output) -> String {
let normalize = |bytes: &[u8]| {
String::from_utf8_lossy(bytes)
.replace(root.to_str().unwrap(), "<ROOT>")
.replace(core_dir().to_str().unwrap(), "<REPO>")
};
format!(
"exit: {}\n--- stdout\n{}--- stderr\n{}",
out.status.code().map_or("signal".into(), |c| c.to_string()),
normalize(&out.stdout),
normalize(&out.stderr),
)
}
#[test]
fn cli_output_matches_golden_files() {
let raw = fs::read_to_string(golden_dir().join("cases.toml")).expect("read cases.toml");
let cases: CaseFile = toml::from_str(&raw).expect("parse cases.toml");
let update = std::env::var_os("UPDATE_GOLDEN").is_some();
let mut seen = BTreeSet::new();
let mut failures = Vec::new();
for case in &cases.case {
let runs: Vec<(String, Vec<String>)> = if case.formats.is_empty() {
vec![(case.name.clone(), case.args.clone())]
} else {
case.formats
.iter()
.map(|format| {
let mut args = vec!["-o".to_string(), format.clone()];
args.extend(case.args.iter().cloned());
(format!("{}.{format}", case.name), args)
})
.collect()
};
for (name, args) in runs {
assert!(seen.insert(name.clone()), "duplicate golden case {name}");
let root = work_dir().join(&name);
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create case dir");
if case.ownership {
let target = root.join("data/idx/ownership.db");
fs::create_dir_all(target.parent().unwrap()).expect("create data dir");
fs::copy(seeded_ownership_db(), &target).expect("copy seeded db");
}
for before in &case.before {
idx(&root, before, &case.env);
}
let output = idx_with_stdin(&root, &args, &case.env, case.stdin.as_deref());
let actual = render(&root, &output);
let path = golden_dir().join(format!("{name}.golden"));
if update {
fs::write(&path, &actual).expect("write golden");
} else {
match fs::read_to_string(&path) {
Ok(expected) if expected == actual => {}
Ok(expected) => failures.push(format!(
"{name}: output differs\n--- expected\n{expected}\n--- actual\n{actual}"
)),
Err(_) => failures.push(format!("{name}: missing {}", path.display())),
}
}
}
}
for entry in fs::read_dir(golden_dir()).expect("read golden dir") {
let path = entry.expect("dir entry").path();
let Some(stem) = path
.file_name()
.unwrap()
.to_str()
.unwrap()
.strip_suffix(".golden")
else {
continue;
};
if !seen.contains(stem) {
if update {
fs::remove_file(&path).expect("remove orphan golden");
} else {
failures.push(format!("{stem}: golden file has no case in cases.toml"));
}
}
}
let _ = fs::remove_dir_all(work_dir());
assert!(
failures.is_empty(),
"{} golden mismatch(es); if the change is intentional, rerun with UPDATE_GOLDEN=1 \
and commit the diff with the reason\n\n{}",
failures.len(),
failures.join("\n\n")
);
}

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │
╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡
│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │
└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘
--- stderr

View file

@ -0,0 +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}]
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"failed":[],"removed":0}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
cleared 0 files
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"files":0,"newest":null,"oldest":null,"path":"<ROOT>/cache/idx","size_bytes":0}
--- stderr

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
path: <ROOT>/cache/idx
files: 0
size_bytes: 0
oldest: -
newest: -
--- stderr

View file

@ -0,0 +1,382 @@
# Golden output cases for tests/golden.rs.
#
# Each case runs once per entry in `formats` (default: table and json) with
# `-o <format>` prepended; `formats = []` runs the args as given, which means
# the auto format: stdout is a pipe here, so auto resolves to compact JSON.
# Every run uses the mock provider (IDX_USE_MOCK_PROVIDER=1) and an isolated
# home directory. `ownership = true` seeds the fixture DB; `before` runs setup
# commands first; `stdin` is piped to the command.
#
# `idx version` is excluded on purpose: its output changes with every release.
# --- help and shell completions ---------------------------------------------
[[case]]
name = "help-root"
args = ["--help"]
formats = []
[[case]]
name = "help-stocks"
args = ["stocks", "--help"]
formats = []
[[case]]
name = "help-ownership"
args = ["ownership", "--help"]
formats = []
[[case]]
name = "help-config"
args = ["config", "--help"]
formats = []
[[case]]
name = "help-cache"
args = ["cache", "--help"]
formats = []
[[case]]
name = "completions-bash"
args = ["completions", "bash"]
formats = []
# --- config and cache ---------------------------------------------------------
[[case]]
name = "config-path"
args = ["config", "path"]
formats = []
[[case]]
name = "config-init"
args = ["config", "init"]
formats = []
[[case]]
name = "config-get"
args = ["config", "get", "general.provider"]
formats = []
before = [["config", "init"]]
[[case]]
name = "config-set"
args = ["config", "set", "general.provider", "yahoo"]
formats = []
[[case]]
name = "config-set-output-jsonl"
args = ["config", "set", "general.output", "jsonl"]
formats = []
[[case]]
name = "config-set-output-invalid"
args = ["config", "set", "general.output", "yaml"]
formats = []
[[case]]
name = "config-output-table-pins-format"
args = ["stocks", "quote", "BBCA"]
formats = []
before = [["config", "set", "general.output", "table"]]
[[case]]
name = "cache-info-empty"
args = ["cache", "info"]
[[case]]
name = "cache-clear"
args = ["cache", "clear"]
# --- stocks: MSN (default provider) --------------------------------------------
[[case]]
name = "stocks-quote"
args = ["stocks", "quote", "BBCA"]
[[case]]
name = "stocks-quote-multi"
formats = ["table", "json", "jsonl"]
args = ["stocks", "quote", "BBCA,BBRI"]
[[case]]
name = "stocks-history"
formats = ["table", "json", "jsonl"]
args = ["stocks", "history", "BBCA"]
[[case]]
name = "stocks-history-msn"
args = ["stocks", "history", "BBCA", "--history-provider", "msn"]
[[case]]
name = "stocks-technical"
args = ["stocks", "technical", "BBCA"]
[[case]]
name = "stocks-growth"
args = ["stocks", "growth", "BBCA"]
[[case]]
name = "stocks-valuation"
args = ["stocks", "valuation", "BBCA"]
[[case]]
name = "stocks-valuation-nonfinite"
args = ["stocks", "valuation", "BBCA"]
env = { IDX_MOCK_MSN_KEYRATIOS_FIXTURE = "tests/fixtures/msn_keyratios_infinity.json" }
[[case]]
name = "stocks-risk"
args = ["stocks", "risk", "BBCA"]
[[case]]
name = "stocks-fundamental"
formats = ["table", "json", "jsonl"]
args = ["stocks", "fundamental", "BBCA"]
[[case]]
name = "stocks-compare"
args = ["stocks", "compare", "BBCA", "BBRI"]
[[case]]
name = "stocks-profile"
args = ["stocks", "profile", "BBCA"]
[[case]]
name = "stocks-financials"
args = ["stocks", "financials", "BBCA"]
[[case]]
name = "stocks-financials-income"
args = ["stocks", "financials", "BBCA", "--statement", "income"]
[[case]]
name = "stocks-earnings"
args = ["stocks", "earnings", "BBCA"]
[[case]]
name = "stocks-earnings-history-quarterly"
args = ["stocks", "earnings", "BBCA", "--history", "--quarterly"]
[[case]]
name = "stocks-earnings-forecast-annual"
args = ["stocks", "earnings", "BBCA", "--forecast", "--annual"]
[[case]]
name = "stocks-sentiment"
args = ["stocks", "sentiment", "BBCA"]
[[case]]
name = "stocks-insights"
args = ["stocks", "insights", "BBCA"]
[[case]]
name = "stocks-news"
args = ["stocks", "news", "BBCA", "--limit", "5"]
[[case]]
name = "stocks-screen"
args = ["stocks", "screen"]
[[case]]
name = "stocks-screen-worst"
args = ["stocks", "screen", "--filter", "worst-performers", "--limit", "5"]
[[case]]
name = "stocks-screen-high-volume"
args = ["stocks", "screen", "--filter", "high-volume"]
[[case]]
name = "stocks-screen-large-cap"
args = ["stocks", "screen", "--filter", "large-cap"]
[[case]]
name = "auto-format-piped"
args = ["stocks", "quote", "BBCA,BBRI"]
formats = []
[[case]]
name = "auto-format-env-table"
args = ["stocks", "quote", "BBCA"]
formats = []
env = { IDX_OUTPUT = "table" }
[[case]]
name = "stocks-quote-stdin"
args = ["stocks", "quote", "-"]
stdin = "BBCA\nBBRI\n"
[[case]]
name = "stocks-compare-stdin"
args = ["stocks", "compare", "-"]
stdin = "BBCA, BBRI"
[[case]]
name = "error-stdin-empty"
args = ["stocks", "quote", "-"]
stdin = ""
[[case]]
name = "error-invalid-output-env"
args = ["stocks", "quote", "BBCA"]
formats = []
env = { IDX_OUTPUT = "yaml" }
[[case]]
name = "stocks-quote-offline-warm"
args = ["--offline", "stocks", "quote", "BBCA"]
before = [["stocks", "quote", "BBCA"]]
# --- stocks: Yahoo provider -----------------------------------------------------
[[case]]
name = "yahoo-quote"
args = ["stocks", "quote", "BBCA"]
env = { IDX_PROVIDER = "yahoo" }
[[case]]
name = "yahoo-history"
args = ["stocks", "history", "BBCA"]
env = { IDX_PROVIDER = "yahoo" }
[[case]]
name = "yahoo-fundamental"
args = ["stocks", "fundamental", "BBCA"]
env = { IDX_PROVIDER = "yahoo" }
# --- errors ---------------------------------------------------------------------
[[case]]
name = "error-usage-missing-symbol"
args = ["stocks", "quote"]
[[case]]
name = "error-invalid-provider"
args = ["stocks", "quote", "BBCA"]
env = { IDX_PROVIDER = "bogus" }
[[case]]
name = "error-yahoo-profile-unsupported"
args = ["stocks", "profile", "BBCA"]
env = { IDX_PROVIDER = "yahoo" }
[[case]]
name = "error-industry-only-fundamentals"
args = ["stocks", "valuation", "BBCA"]
env = { IDX_MOCK_MSN_KEYRATIOS_FIXTURE = "tests/fixtures/msn_keyratios_industry_only.json" }
[[case]]
name = "error-screen-region"
formats = ["table", "json", "jsonl"]
args = ["stocks", "screen", "--region", "eu"]
[[case]]
name = "error-offline-no-cache-flags"
args = ["--offline", "--no-cache", "stocks", "quote", "BBCA"]
[[case]]
name = "error-offline-cache-miss"
args = ["--offline", "stocks", "quote", "BBCA"]
[[case]]
name = "error-provider-failure"
args = ["stocks", "quote", "BBCA"]
env = { IDX_MOCK_ERROR = "1" }
[[case]]
name = "error-compare-total-failure"
args = ["stocks", "compare", "BBCA", "BBRI"]
env = { IDX_MOCK_ERROR = "1" }
[[case]]
name = "error-fetch-bing-unsupported"
args = ["ownership", "import", "--fetch-bing", "BBCA"]
# --- ownership --------------------------------------------------------------------
[[case]]
name = "ownership-releases-empty"
args = ["ownership", "releases"]
[[case]]
name = "ownership-ticker-empty"
args = ["ownership", "ticker", "BBCA"]
[[case]]
name = "ownership-import-xlsx"
args = ["ownership", "import", "--file", "tests/fixtures/ksei_above1_20260831_excerpt.xlsx"]
[[case]]
name = "ownership-import-archive-txt"
args = ["ownership", "import", "--file", "tests/fixtures/ksei_balancepos_20260227_excerpt.txt"]
[[case]]
name = "ownership-releases"
args = ["ownership", "releases"]
ownership = true
[[case]]
name = "ownership-ticker"
args = ["ownership", "ticker", "BBCA"]
ownership = true
[[case]]
name = "ownership-ticker-ksei"
args = ["ownership", "ticker", "BBCA", "--source", "ksei"]
ownership = true
[[case]]
name = "ownership-entity"
args = ["ownership", "entity", "ANTHONI SALIM"]
ownership = true
[[case]]
name = "ownership-search"
args = ["ownership", "search", "salim"]
ownership = true
[[case]]
name = "ownership-cross-holders"
args = ["ownership", "cross-holders", "--min-tickers", "1"]
ownership = true
[[case]]
name = "ownership-concentration"
args = ["ownership", "concentration"]
ownership = true
[[case]]
name = "ownership-flow"
args = ["ownership", "flow", "BBCA"]
ownership = true
[[case]]
name = "ownership-changes"
formats = ["table", "json", "jsonl"]
args = ["ownership", "changes", "--from", "2026-01-30", "--to", "2026-02-27"]
ownership = true
[[case]]
name = "ownership-graph"
args = ["ownership", "graph", "BBCA"]
ownership = true
[[case]]
name = "ownership-graph-dot"
args = ["ownership", "graph", "BBCA", "--format", "dot"]
ownership = true
[[case]]
name = "ownership-resolve-unresolved"
args = ["ownership", "resolve", "unresolved"]
ownership = true
[[case]]
name = "ownership-resolve-map"
args = ["ownership", "resolve", "map", "SALIM ANTHONI", "ANTHONI SALIM"]
ownership = true
[[case]]
name = "ownership-resolve-merge"
args = ["ownership", "resolve", "merge", "1", "2"]
ownership = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
msn
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
<ROOT>/config/idx/config.toml
--- stderr

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │
╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡
│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │
└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
<ROOT>/config/idx/config.toml
--- stderr

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid output format 'yaml': expected 'auto', 'table', 'json', or 'jsonl'"}

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
ok
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
ok
--- stderr

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: provider unavailable

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
{"code":"UNSUPPORTED","error":true,"message":"unsupported: --fetch-bing import is not implemented yet"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: --fetch-bing import is not implemented yet

View file

@ -0,0 +1,4 @@
exit: 1
--- stdout
--- stderr
{"code":"PARSEERROR","error":true,"message":"parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled"}

View file

@ -0,0 +1,4 @@
exit: 1
--- stdout
--- stderr
Error: parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"CONFIGERROR","error":true,"message":"config error: invalid IDX_OUTPUT value: 'yaml', expected 'auto', 'table', 'json', or 'jsonl'"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: config error: invalid provider 'bogus' (expected yahoo or msn)

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
{"code":"CACHEMISS","error":true,"message":"cache miss: msn-quote/BBCA.JK"}

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
Error: cache miss: msn-quote/BBCA.JK

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"INVALIDINPUT","error":true,"message":"invalid input: cannot combine --offline with --no-cache"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: invalid input: cannot combine --offline with --no-cache

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: provider unavailable

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"INVALIDINPUT","error":true,"message":"invalid input: no symbols read from stdin"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: invalid input: no symbols read from stdin

View file

@ -0,0 +1,9 @@
exit: 2
--- stdout
--- stderr
error: the following required arguments were not provided:
<SYMBOLS>...
Usage: idx stocks quote <SYMBOLS>...
For more information, try '--help'.

View file

@ -0,0 +1,9 @@
exit: 2
--- stdout
--- stderr
error: the following required arguments were not provided:
<SYMBOLS>...
Usage: idx stocks quote <SYMBOLS>...
For more information, try '--help'.

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: command requires --provider msn"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: BBCA.JK: command requires --provider msn

View file

@ -0,0 +1,20 @@
exit: 0
--- stdout
Manage local cache
Usage: idx cache [OPTIONS] <COMMAND>
Commands:
info Show cache usage information
clear Clear all cached files
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
--- stderr

View file

@ -0,0 +1,22 @@
exit: 0
--- stdout
Manage configuration
Usage: idx config [OPTIONS] <COMMAND>
Commands:
init Create default configuration file
get Get a configuration value by key
set Set a configuration value by key
path Print configuration file path
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
--- stderr

View file

@ -0,0 +1,58 @@
exit: 0
--- stdout
Ownership intelligence (KSEI + Bing).
Preferred bootstrap path:
1. Run `idx ownership sync` to install or refresh a maintained SQLite snapshot.
2. If no snapshot manifest is available, run `idx ownership discover` and then `idx ownership import --url <pdf-url>`.
3. Local `.xlsx`, `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows.
Usage: idx ownership [OPTIONS] <COMMAND>
Commands:
discover Discover the latest IDX-hosted ownership report URLs
import Import ownership data directly from source files
sync Install or refresh a maintained ownership SQLite snapshot
ticker Show all holders for a ticker (KSEI + Bing combined)
entity Show all holdings for an entity across tickers
search Search entities by name
cross-holders Rank entities by cross-ownership breadth
concentration Rank tickers by ownership concentration
flow Show Bing institutional flow for a ticker
changes Diff two KSEI releases
graph Ownership network graph
resolve Manual entity resolution workflow
releases List imported KSEI releases
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--no-color
Disable colored output
-q, --quiet
Suppress non-essential output
-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 ownership sync
idx ownership sync --manifest /path/to/ownership-snapshot-manifest.json
idx ownership discover --limit 1
idx ownership import --url <pdf-url>
idx ownership releases
--- stderr

View file

@ -0,0 +1,40 @@
exit: 0
--- stdout
idx-cli is a Rust command-line tool for Indonesian stock market (IDX) analysis.
It supports quote lookup, historical data, local caching, and output formats for both humans and AI agents.
Usage: idx [OPTIONS] <COMMAND>
Commands:
stocks Stock data and analysis
config Manage configuration
cache Manage local cache
ownership Ownership intelligence (bootstrap with `ownership sync`)
completions Generate shell completions
version Show idx-cli version
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--no-color
Disable colored output
-q, --quiet
Suppress non-essential output
-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')
--- stderr

View file

@ -0,0 +1,33 @@
exit: 0
--- stdout
Stock data and analysis
Usage: idx stocks [OPTIONS] <COMMAND>
Commands:
quote Get real-time stock quotes
history Get historical OHLC data
technical Run technical analysis on a stock
growth Run growth analysis on a stock
valuation Run valuation analysis on a stock
risk Run risk analysis on a stock
fundamental Run full fundamental analysis on a stock
profile Get company profile
financials Get financial statements
earnings Get earnings report
sentiment Get crowd sentiment
insights Get AI insights
news Get stock news
screen MSN screener
compare Compare fundamentals across stocks
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL ID","change_type":"decreased","old_bps":2400,"new_bps":2334,"delta_bps":-66},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL IS","change_type":"increased","old_bps":75,"new_bps":129,"delta_bps":54},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL MF","change_type":"increased","old_bps":117,"new_bps":152,"delta_bps":35},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN MF","change_type":"increased","old_bps":307,"new_bps":337,"delta_bps":30},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN OT","change_type":"decreased","old_bps":91,"new_bps":64,"delta_bps":-27},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN PF","change_type":"decreased","old_bps":76,"new_bps":56,"delta_bps":-20},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN CP","change_type":"increased","old_bps":72,"new_bps":86,"delta_bps":14},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL CP","change_type":"decreased","old_bps":6481,"new_bps":6467,"delta_bps":-14},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL SC","change_type":"decreased","old_bps":36,"new_bps":29,"delta_bps":-7},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN IB","change_type":"decreased","old_bps":267,"new_bps":263,"delta_bps":-4},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN SC","change_type":"increased","old_bps":51,"new_bps":55,"delta_bps":4},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL PF","change_type":"increased","old_bps":17,"new_bps":20,"delta_bps":3},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN ID","change_type":"decreased","old_bps":3,"new_bps":2,"delta_bps":-1}]
--- stderr

View file

@ -0,0 +1,16 @@
exit: 0
--- stdout
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL ID","change_type":"decreased","old_bps":2400,"new_bps":2334,"delta_bps":-66}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL IS","change_type":"increased","old_bps":75,"new_bps":129,"delta_bps":54}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL MF","change_type":"increased","old_bps":117,"new_bps":152,"delta_bps":35}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN MF","change_type":"increased","old_bps":307,"new_bps":337,"delta_bps":30}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN OT","change_type":"decreased","old_bps":91,"new_bps":64,"delta_bps":-27}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN PF","change_type":"decreased","old_bps":76,"new_bps":56,"delta_bps":-20}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN CP","change_type":"increased","old_bps":72,"new_bps":86,"delta_bps":14}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL CP","change_type":"decreased","old_bps":6481,"new_bps":6467,"delta_bps":-14}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL SC","change_type":"decreased","old_bps":36,"new_bps":29,"delta_bps":-7}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN IB","change_type":"decreased","old_bps":267,"new_bps":263,"delta_bps":-4}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN SC","change_type":"increased","old_bps":51,"new_bps":55,"delta_bps":4}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL PF","change_type":"increased","old_bps":17,"new_bps":20,"delta_bps":3}
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN ID","change_type":"decreased","old_bps":3,"new_bps":2,"delta_bps":-1}
--- stderr

View file

@ -0,0 +1,32 @@
exit: 0
--- stdout
┌────────┬───────────────────────────┬───────────┬────────┬────────┬────────┐
│ TICKER ┆ ENTITY ┆ CHANGE ┆ OLD% ┆ NEW% ┆ DELTA% │
╞════════╪═══════════════════════════╪═══════════╪════════╪════════╪════════╡
│ AADI ┆ KSEI AGGREGATE LOCAL ID ┆ DECREASED ┆ 24.00% ┆ 23.34% ┆ -0.66% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE LOCAL IS ┆ INCREASED ┆ 0.75% ┆ 1.29% ┆ +0.54% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE LOCAL MF ┆ INCREASED ┆ 1.17% ┆ 1.52% ┆ +0.35% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN MF ┆ INCREASED ┆ 3.07% ┆ 3.37% ┆ +0.30% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN OT ┆ DECREASED ┆ 0.91% ┆ 0.64% ┆ -0.27% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN PF ┆ DECREASED ┆ 0.76% ┆ 0.56% ┆ -0.20% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN CP ┆ INCREASED ┆ 0.72% ┆ 0.86% ┆ +0.14% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE LOCAL CP ┆ DECREASED ┆ 64.81% ┆ 64.67% ┆ -0.14% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE LOCAL SC ┆ DECREASED ┆ 0.36% ┆ 0.29% ┆ -0.07% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN IB ┆ DECREASED ┆ 2.67% ┆ 2.63% ┆ -0.04% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN SC ┆ INCREASED ┆ 0.51% ┆ 0.55% ┆ +0.04% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE LOCAL PF ┆ INCREASED ┆ 0.17% ┆ 0.20% ┆ +0.03% │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ AADI ┆ KSEI AGGREGATE FOREIGN ID ┆ DECREASED ┆ 0.03% ┆ 0.02% ┆ -0.01% │
└────────┴───────────────────────────┴───────────┴────────┴────────┴────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[["AALI",{"top1_bps":7968,"top3_bps":7968,"hhi":6348,"free_float_bps":2032,"holder_count":1}],["BBCA",{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4}],["AADI",{"top1_bps":4110,"top3_bps":6230,"hhi":2009,"free_float_bps":2030,"holder_count":10}],["ABBA",{"top1_bps":283,"top3_bps":418,"hhi":9,"free_float_bps":9582,"holder_count":2}]]
--- stderr

View file

@ -0,0 +1,14 @@
exit: 0
--- stdout
┌──────┬────────┬────────┬────────┬──────┬─────────────┬─────────┐
│ RANK ┆ TICKER ┆ TOP1% ┆ TOP3% ┆ HHI ┆ FREE_FLOAT% ┆ HOLDERS │
╞══════╪════════╪════════╪════════╪══════╪═════════════╪═════════╡
│ 1 ┆ AALI ┆ 79.68% ┆ 79.68% ┆ 6348 ┆ 20.32% ┆ 1 │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ 2 ┆ BBCA ┆ 54.94% ┆ 57.16% ┆ 3021 ┆ 41.82% ┆ 4 │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ 3 ┆ AADI ┆ 41.10% ┆ 62.30% ┆ 2009 ┆ 20.30% ┆ 10 │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ 4 ┆ ABBA ┆ 2.83% ┆ 4.18% ┆ 9 ┆ 95.82% ┆ 2 │
└──────┴────────┴────────┴────────┴──────┴─────────────┴─────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"entity":{"id":11,"canonical_name":"ASTRA INTERNATIONAL","entity_type":null,"country":null},"ticker_count":1,"total_bps":7968},{"entity":{"id":14,"canonical_name":"DWIMURIA INVESTAMA ANDALAN","entity_type":null,"country":null},"ticker_count":1,"total_bps":5494},{"entity":{"id":4,"canonical_name":"ADARO STRATEGIC INVESTMENTS","entity_type":null,"country":null},"ticker_count":1,"total_bps":4110},{"entity":{"id":5,"canonical_name":"ALAMTRI RESOURCES INDONESIA TBK PT","entity_type":null,"country":null},"ticker_count":1,"total_bps":1537},{"entity":{"id":7,"canonical_name":"GARIBALDI THOHIR","entity_type":null,"country":null},"ticker_count":1,"total_bps":583},{"entity":{"id":2,"canonical_name":"SARATOGA INVESTAMA SEDAYA","entity_type":null,"country":null},"ticker_count":1,"total_bps":438},{"entity":{"id":9,"canonical_name":"EDWIN SOERYADJAYA","entity_type":null,"country":null},"ticker_count":1,"total_bps":352},{"entity":{"id":8,"canonical_name":"IR. T. PERMADI RACHMAT","entity_type":null,"country":null},"ticker_count":1,"total_bps":348},{"entity":{"id":13,"canonical_name":"YAYASAN ABDI BANGSA","entity_type":null,"country":null},"ticker_count":1,"total_bps":283},{"entity":{"id":10,"canonical_name":"SANDIAGA SALAHUDDIN UNO","entity_type":null,"country":null},"ticker_count":1,"total_bps":204},{"entity":{"id":1,"canonical_name":"PERSADA CAPITAL INVESTAMA","entity_type":null,"country":null},"ticker_count":1,"total_bps":154},{"entity":{"id":12,"canonical_name":"YAYASAN BINA SEJAHTERA WARGA BULOG","entity_type":null,"country":null},"ticker_count":1,"total_bps":135},{"entity":{"id":6,"canonical_name":"FTAG INVESTMENT BANK","entity_type":null,"country":null},"ticker_count":1,"total_bps":129},{"entity":{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null},"ticker_count":1,"total_bps":115},{"entity":{"id":3,"canonical_name":"TRINUGRAHA THOHIR","entity_type":null,"country":null},"ticker_count":1,"total_bps":115},{"entity":{"id":16,"canonical_name":"TRICIPTA MANDHALA GUMILANG","entity_type":null,"country":null},"ticker_count":1,"total_bps":107},{"entity":{"id":15,"canonical_name":"CATURGUWIRATNA SUMAPALA","entity_type":null,"country":null},"ticker_count":1,"total_bps":102}]
--- stderr

View file

@ -0,0 +1,40 @@
exit: 0
--- stdout
┌──────┬────────────────────────────────────┬─────────┬───────────┐
│ RANK ┆ ENTITY ┆ TICKERS ┆ TOTAL_BPS │
╞══════╪════════════════════════════════════╪═════════╪═══════════╡
│ 1 ┆ ASTRA INTERNATIONAL ┆ 1 ┆ 79.68% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 2 ┆ DWIMURIA INVESTAMA ANDALAN ┆ 1 ┆ 54.94% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 3 ┆ ADARO STRATEGIC INVESTMENTS ┆ 1 ┆ 41.10% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 4 ┆ ALAMTRI RESOURCES INDONESIA TBK PT ┆ 1 ┆ 15.37% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 5 ┆ GARIBALDI THOHIR ┆ 1 ┆ 5.83% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 6 ┆ SARATOGA INVESTAMA SEDAYA ┆ 1 ┆ 4.38% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 7 ┆ EDWIN SOERYADJAYA ┆ 1 ┆ 3.52% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 8 ┆ IR. T. PERMADI RACHMAT ┆ 1 ┆ 3.48% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 9 ┆ YAYASAN ABDI BANGSA ┆ 1 ┆ 2.83% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 10 ┆ SANDIAGA SALAHUDDIN UNO ┆ 1 ┆ 2.04% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 11 ┆ PERSADA CAPITAL INVESTAMA ┆ 1 ┆ 1.54% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 12 ┆ YAYASAN BINA SEJAHTERA WARGA BULOG ┆ 1 ┆ 1.35% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 13 ┆ FTAG INVESTMENT BANK ┆ 1 ┆ 1.29% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 14 ┆ ANTHONI SALIM ┆ 1 ┆ 1.15% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 15 ┆ TRINUGRAHA THOHIR ┆ 1 ┆ 1.15% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 16 ┆ TRICIPTA MANDHALA GUMILANG ┆ 1 ┆ 1.07% │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 17 ┆ CATURGUWIRATNA SUMAPALA ┆ 1 ┆ 1.02% │
└──────┴────────────────────────────────────┴─────────┴───────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"entity":{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null},"ticker_count":1,"holdings":[{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"source":"ksei","shares":1416306835,"percentage_bps":115,"report_date":"2026-08-31"}]}
--- stderr

View file

@ -0,0 +1,10 @@
exit: 0
--- stdout
Entity: ANTHONI SALIM
┌────────┬────────┬───────────────┬───────┬─────────────┐
│ TICKER ┆ SOURCE ┆ SHARES ┆ % ┆ REPORT_DATE │
╞════════╪════════╪═══════════════╪═══════╪═════════════╡
│ BBCA ┆ KSEI ┆ 1,416,306,835 ┆ 1.15% ┆ 2026-08-31 │
└────────┴────────┴───────────────┴───────┴─────────────┘
Total tickers: 1
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
null
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
No institutional flow data available for BBCA.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"nodes":[{"id":"entity:14","label":"DWIMURIA INVESTAMA ANDALAN","node_type":"entity"},{"id":"entity:15","label":"CATURGUWIRATNA SUMAPALA","node_type":"entity"},{"id":"entity:16","label":"TRICIPTA MANDHALA GUMILANG","node_type":"entity"},{"id":"entity:17","label":"ANTHONI SALIM","node_type":"entity"},{"id":"ticker:BBCA","label":"BBCA","node_type":"ticker"}],"edges":[{"from":"entity:17","to":"ticker:BBCA","percentage_bps":115,"source":"ksei"},{"from":"entity:15","to":"ticker:BBCA","percentage_bps":102,"source":"ksei"},{"from":"entity:14","to":"ticker:BBCA","percentage_bps":5494,"source":"ksei"},{"from":"entity:16","to":"ticker:BBCA","percentage_bps":107,"source":"ksei"}]}
--- stderr

View file

@ -0,0 +1,20 @@
exit: 0
--- stdout
digraph ownership {
rankdir=LR;
graph [fontname="Helvetica"];
node [fontname="Helvetica"];
edge [fontname="Helvetica"];
"entity:14" [label="DWIMURIA INVESTAMA ANDALAN", shape=ellipse];
"entity:15" [label="CATURGUWIRATNA SUMAPALA", shape=ellipse];
"entity:16" [label="TRICIPTA MANDHALA GUMILANG", shape=ellipse];
"entity:17" [label="ANTHONI SALIM", shape=ellipse];
"ticker:BBCA" [label="BBCA", shape=box];
"entity:17" -> "ticker:BBCA" [label="1.15% (ksei)"];
"entity:15" -> "ticker:BBCA" [label="1.02% (ksei)"];
"entity:14" -> "ticker:BBCA" [label="54.94% (ksei)"];
"entity:16" -> "ticker:BBCA" [label="1.07% (ksei)"];
}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"nodes":[{"id":"entity:14","label":"DWIMURIA INVESTAMA ANDALAN","node_type":"entity"},{"id":"entity:15","label":"CATURGUWIRATNA SUMAPALA","node_type":"entity"},{"id":"entity:16","label":"TRICIPTA MANDHALA GUMILANG","node_type":"entity"},{"id":"entity:17","label":"ANTHONI SALIM","node_type":"entity"},{"id":"ticker:BBCA","label":"BBCA","node_type":"ticker"}],"edges":[{"from":"entity:17","to":"ticker:BBCA","percentage_bps":115,"source":"ksei"},{"from":"entity:15","to":"ticker:BBCA","percentage_bps":102,"source":"ksei"},{"from":"entity:14","to":"ticker:BBCA","percentage_bps":5494,"source":"ksei"},{"from":"entity:16","to":"ticker:BBCA","percentage_bps":107,"source":"ksei"}]}
--- stderr

View file

@ -0,0 +1,10 @@
exit: 0
--- stdout
nodes: 5 edges: 4
BBCA [TICKER]
├─ DWIMURIA INVESTAMA ANDALAN (54.94%, ksei)
├─ ANTHONI SALIM (1.15%, ksei)
├─ TRICIPTA MANDHALA GUMILANG (1.07%, ksei)
└─ CATURGUWIRATNA SUMAPALA (1.02%, ksei)
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"action":"imported","inserted_rows":18,"ticker_count":1,"as_of_date":"2026-02-27","sha256":"7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547","source_url":null}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
Imported 18 rows for 1 tickers (as of 2026-02-27).
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"action":"imported","inserted_rows":17,"ticker_count":4,"as_of_date":"2026-08-31","sha256":"7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3","source_url":null}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
Imported 17 rows for 4 tickers (as of 2026-08-31).
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[]
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
No ownership releases imported yet.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"id":1,"source_url":null,"sha256":"7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3","as_of_date":"2026-08-31","row_count":17,"imported_at":0},{"id":3,"source_url":null,"sha256":"7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547","as_of_date":"2026-02-27","row_count":18,"imported_at":0},{"id":2,"source_url":null,"sha256":"efb3d8fb19c9e0d803dc1b1734c8389356605d0f78d73db6729fb76d3d75753d","as_of_date":"2026-01-30","row_count":18,"imported_at":0}]
--- stderr

View file

@ -0,0 +1,12 @@
exit: 0
--- stdout
┌────────────┬──────┬──────────────────────────────────────────────────────────────────┬────────┬─────────────┐
│ AS_OF ┆ ROWS ┆ SHA256 ┆ SOURCE ┆ IMPORTED_AT │
╞════════════╪══════╪══════════════════════════════════════════════════════════════════╪════════╪═════════════╡
│ 2026-08-31 ┆ 17 ┆ 7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3 ┆ - ┆ 0 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-02-27 ┆ 18 ┆ 7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547 ┆ - ┆ 0 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-01-30 ┆ 18 ┆ efb3d8fb19c9e0d803dc1b1734c8389356605d0f78d73db6729fb76d3d75753d ┆ - ┆ 0 │
└────────────┴──────┴──────────────────────────────────────────────────────────────────┴────────┴─────────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"action":"mapped","alias":"SALIM ANTHONI","entity":"ANTHONI SALIM"}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
Mapped alias 'SALIM ANTHONI' -> 'ANTHONI SALIM'.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"action":"merged","alias_updates":1,"bing_holdings_updates":0,"keep":1,"ksei_holdings_updates":1,"merge":2}
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
Merged entity 2 into 1 (aliases: 1, ksei_holdings: 1, bing_holdings: 0).
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[]
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
No unresolved or low-confidence aliases found.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null}]
--- stderr

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
┌───────────────┬──────┬─────────┐
│ ENTITY ┆ TYPE ┆ COUNTRY │
╞═══════════════╪══════╪═════════╡
│ ANTHONI SALIM ┆ - ┆ - │
└───────────────┴──────┴─────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: BBCA"}

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
Error: symbol not found: BBCA

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"ksei_as_of":"2026-08-31","bing_as_of":null,"holders":[{"rank":1,"source":"ksei","name":"DWIMURIA INVESTAMA ANDALAN","entity_id":14,"investor_type":"CORPORATE","locality":"Local","shares":67729950000,"percentage_bps":5494,"signal":null},{"rank":2,"source":"ksei","name":"ANTHONI SALIM","entity_id":17,"investor_type":"INDIVIDUAL","locality":"Local","shares":1416306835,"percentage_bps":115,"signal":null},{"rank":3,"source":"ksei","name":"TRICIPTA MANDHALA GUMILANG","entity_id":16,"investor_type":"CORPORATE","locality":"Local","shares":1313250000,"percentage_bps":107,"signal":null},{"rank":4,"source":"ksei","name":"CATURGUWIRATNA SUMAPALA","entity_id":15,"investor_type":"CORPORATE","locality":"Local","shares":1261750000,"percentage_bps":102,"signal":null}],"concentration":{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4},"flow":null}
--- stderr

View file

@ -0,0 +1,16 @@
exit: 0
--- stdout
Ownership: BBCA KSEI=2026-08-31 Bing=-
┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐
│ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │
╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡
│ 1 ┆ KSEI ┆ DWIMURIA INVESTAMA ANDALAN ┆ CORPORATE ┆ L ┆ 67,729,950,000 ┆ 54.94% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 2 ┆ KSEI ┆ ANTHONI SALIM ┆ INDIVIDUAL ┆ L ┆ 1,416,306,835 ┆ 1.15% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 3 ┆ KSEI ┆ TRICIPTA MANDHALA GUMILANG ┆ CORPORATE ┆ L ┆ 1,313,250,000 ┆ 1.07% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │
└──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘
Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"ksei_as_of":"2026-08-31","bing_as_of":null,"holders":[{"rank":1,"source":"ksei","name":"DWIMURIA INVESTAMA ANDALAN","entity_id":14,"investor_type":"CORPORATE","locality":"Local","shares":67729950000,"percentage_bps":5494,"signal":null},{"rank":2,"source":"ksei","name":"ANTHONI SALIM","entity_id":17,"investor_type":"INDIVIDUAL","locality":"Local","shares":1416306835,"percentage_bps":115,"signal":null},{"rank":3,"source":"ksei","name":"TRICIPTA MANDHALA GUMILANG","entity_id":16,"investor_type":"CORPORATE","locality":"Local","shares":1313250000,"percentage_bps":107,"signal":null},{"rank":4,"source":"ksei","name":"CATURGUWIRATNA SUMAPALA","entity_id":15,"investor_type":"CORPORATE","locality":"Local","shares":1261750000,"percentage_bps":102,"signal":null}],"concentration":{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4},"flow":null}
--- stderr

View file

@ -0,0 +1,16 @@
exit: 0
--- stdout
Ownership: BBCA KSEI=2026-08-31 Bing=-
┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐
│ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │
╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡
│ 1 ┆ KSEI ┆ DWIMURIA INVESTAMA ANDALAN ┆ CORPORATE ┆ L ┆ 67,729,950,000 ┆ 54.94% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 2 ┆ KSEI ┆ ANTHONI SALIM ┆ INDIVIDUAL ┆ L ┆ 1,416,306,835 ┆ 1.15% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 3 ┆ KSEI ┆ TRICIPTA MANDHALA GUMILANG ┆ CORPORATE ┆ L ┆ 1,313,250,000 ┆ 1.07% ┆ - │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │
└──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘
Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"},{"symbol":"BBRI.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"}]
--- stderr

View file

@ -0,0 +1,23 @@
exit: 0
--- stdout
Fundamental Comparison
┌────────────────┬───────────────┬───────────────┐
│ METRIC ┆ BBCA.JK ┆ BBRI.JK │
╞════════════════╪═══════════════╪═══════════════╡
│ Symbol ┆ BBCA.JK ┆ BBRI.JK │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Overall ┆ mixed ┆ mixed │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Growth ┆ mixed ┆ mixed │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Valuation ┆ expensive ┆ expensive │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Risk ┆ moderate risk ┆ moderate risk │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ P/E ┆ 25.40 ┆ 25.40 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ ROE ┆ +19.80% ┆ +19.80% │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Revenue Growth ┆ +8.10% ┆ +8.10% │
└────────────────┴───────────────┴───────────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"},{"symbol":"BBRI.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"}]
--- stderr

View file

@ -0,0 +1,23 @@
exit: 0
--- stdout
Fundamental Comparison
┌────────────────┬───────────────┬───────────────┐
│ METRIC ┆ BBCA.JK ┆ BBRI.JK │
╞════════════════╪═══════════════╪═══════════════╡
│ Symbol ┆ BBCA.JK ┆ BBRI.JK │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Overall ┆ mixed ┆ mixed │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Growth ┆ mixed ┆ mixed │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Valuation ┆ expensive ┆ expensive │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Risk ┆ moderate risk ┆ moderate risk │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ P/E ┆ 25.40 ┆ 25.40 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ ROE ┆ +19.80% ┆ +19.80% │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Revenue Growth ┆ +8.10% ┆ +8.10% │
└────────────────┴───────────────┴───────────────┘
--- stderr

Some files were not shown because too many files have changed in this diff Show more