mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
22b6dabaeb
commit
15947de51a
238 changed files with 544 additions and 375 deletions
43
crates/idx-cli/Cargo.toml
Normal file
43
crates/idx-cli/Cargo.toml
Normal 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
1
crates/idx-cli/LICENSE
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
||||
68
crates/idx-cli/src/cli/cache.rs
Normal file
68
crates/idx-cli/src/cli/cache.rs
Normal 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(())
|
||||
}
|
||||
45
crates/idx-cli/src/cli/config.rs
Normal file
45
crates/idx-cli/src/cli/config.rs
Normal 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(())
|
||||
}
|
||||
63
crates/idx-cli/src/cli/mod.rs
Normal file
63
crates/idx-cli/src/cli/mod.rs
Normal 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,
|
||||
}
|
||||
1211
crates/idx-cli/src/cli/ownership.rs
Normal file
1211
crates/idx-cli/src/cli/ownership.rs
Normal file
File diff suppressed because it is too large
Load diff
1353
crates/idx-cli/src/cli/stocks.rs
Normal file
1353
crates/idx-cli/src/cli/stocks.rs
Normal file
File diff suppressed because it is too large
Load diff
167
crates/idx-cli/src/config.rs
Normal file
167
crates/idx-cli/src/config.rs
Normal 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
104
crates/idx-cli/src/main.rs
Normal 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())
|
||||
}
|
||||
44
crates/idx-cli/src/output/json.rs
Normal file
44
crates/idx-cli/src/output/json.rs
Normal 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(())
|
||||
}
|
||||
257
crates/idx-cli/src/output/mod.rs
Normal file
257
crates/idx-cli/src/output/mod.rs
Normal 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()));
|
||||
}
|
||||
41
crates/idx-cli/src/output/style.rs
Normal file
41
crates/idx-cli/src/output/style.rs
Normal 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 {}
|
||||
967
crates/idx-cli/src/output/table.rs
Normal file
967
crates/idx-cli/src/output/table.rs
Normal 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(§ion.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("es, 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
2321
crates/idx-cli/tests/cli.rs
Normal file
File diff suppressed because it is too large
Load diff
240
crates/idx-cli/tests/golden.rs
Normal file
240
crates/idx-cli/tests/golden.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
8
crates/idx-cli/tests/golden/auto-format-env-table.golden
Normal file
8
crates/idx-cli/tests/golden/auto-format-env-table.golden
Normal 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
|
||||
4
crates/idx-cli/tests/golden/auto-format-piped.golden
Normal file
4
crates/idx-cli/tests/golden/auto-format-piped.golden
Normal 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
|
||||
4
crates/idx-cli/tests/golden/cache-clear.json.golden
Normal file
4
crates/idx-cli/tests/golden/cache-clear.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"failed":[],"removed":0}
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/cache-clear.table.golden
Normal file
4
crates/idx-cli/tests/golden/cache-clear.table.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
cleared 0 files
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/cache-info-empty.json.golden
Normal file
4
crates/idx-cli/tests/golden/cache-info-empty.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"files":0,"newest":null,"oldest":null,"path":"<ROOT>/cache/idx","size_bytes":0}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
path: <ROOT>/cache/idx
|
||||
files: 0
|
||||
size_bytes: 0
|
||||
oldest: -
|
||||
newest: -
|
||||
--- stderr
|
||||
382
crates/idx-cli/tests/golden/cases.toml
Normal file
382
crates/idx-cli/tests/golden/cases.toml
Normal 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
|
||||
2851
crates/idx-cli/tests/golden/completions-bash.golden
Normal file
2851
crates/idx-cli/tests/golden/completions-bash.golden
Normal file
File diff suppressed because it is too large
Load diff
4
crates/idx-cli/tests/golden/config-get.golden
Normal file
4
crates/idx-cli/tests/golden/config-get.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
msn
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/config-init.golden
Normal file
4
crates/idx-cli/tests/golden/config-init.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
<ROOT>/config/idx/config.toml
|
||||
--- stderr
|
||||
|
|
@ -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
|
||||
4
crates/idx-cli/tests/golden/config-path.golden
Normal file
4
crates/idx-cli/tests/golden/config-path.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
<ROOT>/config/idx/config.toml
|
||||
--- stderr
|
||||
|
|
@ -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'"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
ok
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/config-set.golden
Normal file
4
crates/idx-cli/tests/golden/config-set.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
ok
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: provider unavailable
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"UNSUPPORTED","error":true,"message":"unsupported: --fetch-bing import is not implemented yet"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: unsupported: --fetch-bing import is not implemented yet
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 1
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled
|
||||
|
|
@ -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'"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: config error: invalid provider 'bogus' (expected yahoo or msn)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"CACHEMISS","error":true,"message":"cache miss: msn-quote/BBCA.JK"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: cache miss: msn-quote/BBCA.JK
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: cannot combine --offline with --no-cache"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: cannot combine --offline with --no-cache
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: provider unavailable
|
||||
|
|
@ -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)"}
|
||||
|
|
@ -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)"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: no symbols read from stdin"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: no symbols read from stdin
|
||||
|
|
@ -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'.
|
||||
|
|
@ -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'.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: command requires --provider msn"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: unsupported: BBCA.JK: command requires --provider msn
|
||||
20
crates/idx-cli/tests/golden/help-cache.golden
Normal file
20
crates/idx-cli/tests/golden/help-cache.golden
Normal 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
|
||||
22
crates/idx-cli/tests/golden/help-config.golden
Normal file
22
crates/idx-cli/tests/golden/help-config.golden
Normal 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
|
||||
58
crates/idx-cli/tests/golden/help-ownership.golden
Normal file
58
crates/idx-cli/tests/golden/help-ownership.golden
Normal 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
|
||||
40
crates/idx-cli/tests/golden/help-root.golden
Normal file
40
crates/idx-cli/tests/golden/help-root.golden
Normal 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
|
||||
33
crates/idx-cli/tests/golden/help-stocks.golden
Normal file
33
crates/idx-cli/tests/golden/help-stocks.golden
Normal 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
|
||||
|
|
@ -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
|
||||
16
crates/idx-cli/tests/golden/ownership-changes.jsonl.golden
Normal file
16
crates/idx-cli/tests/golden/ownership-changes.jsonl.golden
Normal 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
|
||||
32
crates/idx-cli/tests/golden/ownership-changes.table.golden
Normal file
32
crates/idx-cli/tests/golden/ownership-changes.table.golden
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
4
crates/idx-cli/tests/golden/ownership-entity.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-entity.json.golden
Normal 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
|
||||
10
crates/idx-cli/tests/golden/ownership-entity.table.golden
Normal file
10
crates/idx-cli/tests/golden/ownership-entity.table.golden
Normal 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
|
||||
4
crates/idx-cli/tests/golden/ownership-flow.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-flow.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
null
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-flow.table.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-flow.table.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No institutional flow data available for BBCA.
|
||||
--- stderr
|
||||
|
|
@ -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
|
||||
20
crates/idx-cli/tests/golden/ownership-graph-dot.table.golden
Normal file
20
crates/idx-cli/tests/golden/ownership-graph-dot.table.golden
Normal 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
|
||||
4
crates/idx-cli/tests/golden/ownership-graph.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-graph.json.golden
Normal 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
|
||||
10
crates/idx-cli/tests/golden/ownership-graph.table.golden
Normal file
10
crates/idx-cli/tests/golden/ownership-graph.table.golden
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Imported 18 rows for 1 tickers (as of 2026-02-27).
|
||||
--- stderr
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Imported 17 rows for 4 tickers (as of 2026-08-31).
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No ownership releases imported yet.
|
||||
--- stderr
|
||||
|
|
@ -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
|
||||
12
crates/idx-cli/tests/golden/ownership-releases.table.golden
Normal file
12
crates/idx-cli/tests/golden/ownership-releases.table.golden
Normal 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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"action":"mapped","alias":"SALIM ANTHONI","entity":"ANTHONI SALIM"}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Mapped alias 'SALIM ANTHONI' -> 'ANTHONI SALIM'.
|
||||
--- stderr
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Merged entity 2 into 1 (aliases: 1, ksei_holdings: 1, bing_holdings: 0).
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No unresolved or low-confidence aliases found.
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-search.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-search.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null}]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌───────────────┬──────┬─────────┐
|
||||
│ ENTITY ┆ TYPE ┆ COUNTRY │
|
||||
╞═══════════════╪══════╪═════════╡
|
||||
│ ANTHONI SALIM ┆ - ┆ - │
|
||||
└───────────────┴──────┴─────────┘
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: BBCA"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: symbol not found: BBCA
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
4
crates/idx-cli/tests/golden/ownership-ticker.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-ticker.json.golden
Normal 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
|
||||
16
crates/idx-cli/tests/golden/ownership-ticker.table.golden
Normal file
16
crates/idx-cli/tests/golden/ownership-ticker.table.golden
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
4
crates/idx-cli/tests/golden/stocks-compare.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-compare.json.golden
Normal 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
|
||||
23
crates/idx-cli/tests/golden/stocks-compare.table.golden
Normal file
23
crates/idx-cli/tests/golden/stocks-compare.table.golden
Normal 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
Loading…
Add table
Add a link
Reference in a new issue