Merge pull request #37 from 0xrsydn/refactor/idx-core

Release 0.3.0: idx-core library, idx-mcp server, agent-native output
This commit is contained in:
brutaljokerz 2026-10-15 03:05:21 +07:00 • committed by GitHub
commit 386a63c288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
271 changed files with 10644 additions and 2503 deletions

View file

@ -27,7 +27,7 @@ jobs:
- name: Restore and save Nix store
uses: nix-community/cache-nix-action@v7
with:
primary-key: nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-${{ hashFiles('src/**', 'tests/**', 'scripts/**', 'install.sh', 'README.md', 'LICENSE') }}
primary-key: nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-${{ hashFiles('crates/**', 'scripts/**', 'install.sh', 'README.md', 'LICENSE') }}
restore-prefixes-first-match: |
nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-
nix-v2-${{ runner.os }}-${{ runner.arch }}-

View file

@ -13,8 +13,10 @@ on:
paths:
- ".github/workflows/release.yml"
- "Cargo.toml"
- "crates/*/Cargo.toml"
- "Cargo.lock"
- "package.json"
- "npm/idx-mcp/package.json"
- "scripts/install.js"
- "install.sh"
@ -68,19 +70,25 @@ jobs:
# Bundled SQLite and ring compile C code; build it against musl too.
export "CC_${target//-/_}=musl-gcc"
fi
cargo build --release --locked --target "$target"
cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}"
chmod 0755 "idx-${{ matrix.asset }}"
cargo build --release --locked --target "$target" -p idx-cli -p idx-mcp
for bin in idx idx-mcp; do
cp "target/${{ matrix.target }}/release/$bin" "$bin-${{ matrix.asset }}"
chmod 0755 "$bin-${{ matrix.asset }}"
done
- name: Check binary runs
- name: Check binaries run
if: matrix.target != 'x86_64-apple-darwin'
run: ./idx-${{ matrix.asset }} --help >/dev/null
run: |
./idx-${{ matrix.asset }} --help >/dev/null
./idx-mcp-${{ matrix.asset }} --help >/dev/null
- name: Upload target artifact
- name: Upload target artifacts
uses: actions/upload-artifact@v4
with:
name: idx-${{ matrix.asset }}
path: idx-${{ matrix.asset }}
path: |
idx-${{ matrix.asset }}
idx-mcp-${{ matrix.asset }}
if-no-files-found: error
publish:
@ -104,7 +112,11 @@ jobs:
merge-multiple: true
- name: Write checksums
run: sha256sum idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 idx-darwin-x64 > SHA256SUMS
run: |
sha256sum \
idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 idx-darwin-x64 \
idx-mcp-linux-x64 idx-mcp-linux-arm64 idx-mcp-darwin-arm64 idx-mcp-darwin-x64 \
> SHA256SUMS
- name: Attach binaries to the version tag
uses: softprops/action-gh-release@v2
@ -115,5 +127,9 @@ jobs:
idx-linux-arm64
idx-darwin-arm64
idx-darwin-x64
idx-mcp-linux-x64
idx-mcp-linux-arm64
idx-mcp-darwin-arm64
idx-mcp-darwin-x64
SHA256SUMS
src/install.sh

View file

@ -1,8 +1,11 @@
repos:
- repo: builtin
hooks:
# Golden files pin exact CLI output, including trailing spaces.
- id: trailing-whitespace
exclude: ^crates/idx-cli/tests/golden/.*\.golden$
- id: end-of-file-fixer
exclude: ^crates/idx-cli/tests/golden/.*\.golden$
- id: check-merge-conflict
- repo: local

View file

@ -1,19 +1,27 @@
# AGENTS.md
`idx-cli` is a Rust CLI (binary `idx`) for Indonesian stock market analysis
(`stocks`) and KSEI shareholder ownership (`ownership`).
(`stocks`) and KSEI shareholder ownership (`ownership`), plus an MCP server
(binary `idx-mcp`) over the same library.
## Navigation
```text
src/
├── main.rs, config.rs, cache.rs, error.rs, curl_impersonate.rs
├── cli/ clap commands: stocks, ownership, config, cache
├── api/ providers: msn/ (default), yahoo/ (history/OHLCV)
├── analysis/ technical, fundamental, signals
├── ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
crates/idx-core/ library: all data access and analysis
├── src/client.rs Idx entry point, CachePolicy, Fetched<T>
├── src/{market,financial,investor}.rs domain APIs over providers and the DB
├── src/api/ providers: msn/ (default), yahoo/ (history/OHLCV)
├── src/analysis/ technical, fundamental, signals
├── src/ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
│ snapshot sync, db (SQLite/FTS5), entities, search, graph
└── output/ table.rs, json.rs
tests/cli.rs CLI integration tests; fixtures in tests/fixtures/
├── src/{cache,config,error,curl_impersonate}.rs, lib.rs (runtime)
└── tests/fixtures/ shared fixtures (also read by the mock provider)
crates/idx-cli/ binary `idx`
├── src/cli/ clap commands: stocks, ownership, config, cache
├── src/config.rs IdxConfig: output format and color resolution
├── src/output/ table.rs, json.rs, style.rs
└── tests/ cli.rs (behavior), golden.rs + golden/ (output parity)
crates/idx-mcp/ binary `idx-mcp`: stdio MCP tools (server.rs), inputs (params.rs)
└── tests/mcp.rs JSON-RPC over stdio against the mock provider
scripts/ smoke, installer, and ownership snapshot publisher
```
@ -22,21 +30,26 @@ Docs:
- `docs/CONVENTIONS.md`: types, naming, testing, and git rules
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
- `ROADMAP.md`: open work
## Rules
1. Keep data access provider-driven; no ad hoc fetches in `cli/`.
2. Keep parse/normalize functions pure.
3. Tests use fixtures and `IDX_USE_MOCK_PROVIDER=1`; never hit the live network.
3. Tests use fixtures and `IDX_USE_MOCK_PROVIDER=1` (needs idx-core's `testing`
feature, on under `cargo test`); never hit the live network.
4. Output contract: tables on stdout, one JSON document with `-o json`, errors
on stderr (JSON-shaped in JSON mode), stdout empty on failure.
5. Keep ownership queries (`releases`, `ticker`, `changes`, ...) working across
every ingest path: XLSX, legacy PDF, archive ZIP/TXT, and snapshot sync.
6. `ownership import --fetch-bing` is intentionally unsupported.
7. Output parity is a contract: never edit golden files to make a change pass.
After changing CLI behavior, run the live check and report its summary;
report IDX `403`s explicitly rather than counting them as passes.
## Verify
```bash
nix flake check -L # canonical gate (fmt, clippy, tests, package)
nix develop --command cargo test <name> # focused loop
scripts/live-smoke.sh # opt-in live provider drift check
scripts/live-smoke.sh --compare <old> # live drift check, A/B against an older build
```

479
Cargo.lock generated
View file

@ -38,6 +38,25 @@ dependencies = [
"libc",
]
[[package]]
name = "ansi-str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
dependencies = [
"ansitok",
]
[[package]]
name = "ansitok"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
dependencies = [
"nom",
"vte",
]
[[package]]
name = "anstream"
version = "0.6.21"
@ -97,6 +116,12 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "assert_cmd"
version = "2.1.2"
@ -232,7 +257,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -253,11 +278,25 @@ version = "7.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
dependencies = [
"ansi-str",
"console",
"crossterm",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "console"
version = "0.16.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "cookie"
version = "0.18.1"
@ -371,6 +410,40 @@ dependencies = [
"memchr",
]
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.9",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.9",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -388,7 +461,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -436,7 +509,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -448,6 +521,18 @@ dependencies = [
"litrs",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -516,6 +601,94 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@ -537,6 +710,17 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@ -688,6 +872,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@ -711,17 +901,32 @@ dependencies = [
[[package]]
name = "idx-cli"
version = "0.2.4"
version = "0.3.0"
dependencies = [
"assert_cmd",
"chrono",
"clap",
"clap_complete",
"comfy-table",
"directories",
"fastrand",
"idx-core",
"owo-colors",
"predicates",
"rusqlite",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "idx-core"
version = "0.3.0"
dependencies = [
"chrono",
"clap",
"directories",
"fastrand",
"quick-xml",
"rusqlite",
"serde",
@ -733,6 +938,19 @@ dependencies = [
"zip",
]
[[package]]
name = "idx-mcp"
version = "0.3.0"
dependencies = [
"clap",
"idx-core",
"rmcp",
"schemars",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@ -741,6 +959,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@ -830,6 +1050,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -840,6 +1066,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
@ -908,12 +1144,24 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -992,6 +1240,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1007,11 +1261,31 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "regex"
version = "1.12.3"
@ -1049,12 +1323,47 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
dependencies = [
"chrono",
"futures",
"indexmap",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn 3.0.9",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@ -1135,6 +1444,32 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schemars"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.9",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1168,7 +1503,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
@ -1216,6 +1562,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
@ -1251,6 +1603,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
@ -1259,7 +1622,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1294,7 +1657,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1305,7 +1668,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1349,6 +1712,42 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
@ -1390,6 +1789,37 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.19.0"
@ -1489,6 +1919,7 @@ version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
@ -1505,6 +1936,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
@ -1552,7 +1993,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"wasm-bindgen-shared",
]
@ -1617,7 +2058,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1628,7 +2069,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1837,7 +2278,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@ -1858,7 +2299,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1878,7 +2319,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@ -1918,7 +2359,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]

View file

@ -1,50 +1,23 @@
[package]
name = "idx-cli"
version = "0.2.4"
[workspace]
resolver = "3"
members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"]
[workspace.package]
version = "0.3.0"
edition = "2024"
rust-version = "1.85"
description = "CLI tool for Indonesian stock market (IDX) analysis"
license = "MIT"
repository = "https://github.com/0xrsydn/idx-cli"
homepage = "https://github.com/0xrsydn/idx-cli"
keywords = ["idx", "stocks", "indonesia", "cli", "finance"]
categories = ["command-line-utilities", "finance"]
include = [
"/Cargo.toml",
"/Cargo.lock",
"/LICENSE",
"/README.md",
"/src/**",
"/tests/cli.rs",
"/tests/fixtures/**",
]
[[bin]]
name = "idx"
path = "src/main.rs"
[features]
default = ["ownership"]
ownership = ["dep:rusqlite", "dep:quick-xml", "dep:sha2", "dep:zip"]
[dependencies]
[workspace.dependencies]
idx-core = { path = "crates/idx-core", version = "=0.3.0" }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
ureq = { version = "3", features = ["json", "cookies"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
comfy-table = "7"
owo-colors = "4"
serde_json = { version = "1", features = ["raw_value"] }
toml = "0.8"
directories = "5"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
fastrand = "2"
rusqlite = { version = "0.32", features = ["bundled-full"], optional = true }
quick-xml = { version = "0.37", optional = true }
sha2 = { version = "0.10", optional = true }
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
ureq = { version = "3", features = ["json", "cookies"] }
rusqlite = { version = "0.32", features = ["bundled-full"] }
sha2 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }

View file

@ -13,7 +13,7 @@ curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | s
Downloads the prebuilt `idx` binary for your platform from GitHub Releases,
verifies it against the release `SHA256SUMS`, and installs it into
`~/.local/bin` without sudo. Pin a version or change the directory with
`sh -s -- --version v0.2.4 --dir ~/bin`. See [docs/INSTALL.md](docs/INSTALL.md).
`sh -s -- --version v0.3.0 --dir ~/bin`. See [docs/INSTALL.md](docs/INSTALL.md).
### npm / npx
@ -112,7 +112,11 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
### Output
- Human-readable tables on stdout, JSON with `-o json`, errors on stderr
- Tables when you run idx in a terminal; compact JSON when its output is piped
or captured, so scripts and AI agents get structured data with no flag
- `-o table|json|jsonl` (or `IDX_OUTPUT`, or `general.output` in the config)
overrides that; `jsonl` prints one array item per line
- Errors go to stderr, as one JSON object in JSON modes
## Configuration
@ -133,12 +137,51 @@ Precedence order:
3. Config file
4. Built-in defaults
## Agent-friendly usage
## Scripting and agents
- Use `idx --help` and subcommand help for discoverability
- Use `-o json` / `--output json` for structured output
idx composes with `jq`, Python, and other shell tools:
```bash
idx stocks quote BBCA BBRI | jq -r '.[] | "\(.symbol) \(.price)"'
idx -o jsonl stocks history BBCA --period 1y | tail -5
idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compare -
```
- stdout carries only data; warnings and errors go to stderr
- `-` reads symbols from stdin for `stocks quote` and `stocks compare`
- No color codes when output is not a terminal
- Exit codes: `0` success, `2` invalid input or config, `3` not found or no
cached data, `4` upstream unavailable (retry later), `5` unsupported, `1` other
- `idx completions <shell>` generates shell completions
If you ran `idx config init` before v0.3, your config pins `output = "table"`;
change it to `auto` with `idx config set general.output auto`.
## MCP server
`idx-mcp` serves the same data to MCP clients over stdio, as 11 tools:
`get_quotes`, `get_price_history`, `screen_stocks`, `get_company`,
`analyze_stock`, `compare_stocks`, `get_shareholders`,
`get_investor_holdings`, `search_investors`, `get_ownership_changes`, and
`ownership_status`. Live results come wrapped as
`{data, source, fetched_at, stale}`; errors use the CLI's `{error, code,
message}` body. It shares idx's config, cache, and ownership database.
Install it like `idx` (npm, Cargo, or Nix), then register it with your MCP
client:
```bash
claude mcp add idx -- npx -y idx-mcp # Claude Code, via npm
cargo install idx-mcp # or build it (Rust 1.88+)
nix profile install github:0xrsydn/idx-cli#idx-mcp
```
Other clients run the `idx-mcp` command over stdio.
Ownership tools need a populated database: run `idx ownership sync` once, or
start the server with `idx-mcp --allow-sync` to expose an `ownership_sync`
tool (it downloads several MB).
## Documentation
- [docs/INSTALL.md](docs/INSTALL.md): install script options

View file

@ -15,12 +15,10 @@ history lives in git.
## Workflow
- `watchlist`: named symbol sets usable by other commands
- `alerts`: price alerts with a daemon
- CSV/TSV and JSONL output
- Stdin symbol input for chaining (`... | idx stocks fundamental --stdin`)
- CSV/TSV output
- Additional providers (Alpha Vantage, Twelve Data, IDX official)
## Agent integration
- MCP server exposing the existing commands as tools (planned as `idx-mcp`)
- JSON Schema export for command output
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
@ -29,5 +27,7 @@ history lives in git.
can still fail.
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
200 daily candles are returned.
- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream;
other filters work).
- MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the
default OHLCV source.

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

@ -0,0 +1,41 @@
[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"]
ownership = ["idx-core/ownership"]
[dependencies]
idx-core = { workspace = true, features = ["clap"] }
clap.workspace = true
clap_complete = "4"
serde.workspace = true
serde_json.workspace = true
comfy-table = { version = "7", features = ["custom_styling"] }
owo-colors = "4"
toml.workspace = true
chrono.workspace = true
[dev-dependencies]
# Tests drive the binary with the mock provider; release builds exclude it.
idx-core = { workspace = true, features = ["testing"] }
assert_cmd = "2"
predicates = "3"
rusqlite.workspace = true
sha2.workspace = true
zip.workspace = true

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

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

View file

@ -1,9 +1,9 @@
use clap::{Args, Subcommand};
use crate::cache::Cache;
use crate::error::IdxError;
use crate::output::{OutputFormat, json};
use crate::runtime;
use idx_core::cache::Cache;
use idx_core::error::IdxError;
use idx_core::runtime;
#[derive(Debug, Args)]
#[command(about = "Manage local cache")]
@ -25,7 +25,7 @@ pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
match &cmd.command {
CacheSubcommand::Info => {
let info = cache.info()?;
if matches!(format, OutputFormat::Json) {
if format.is_json() {
return json::print_json(&serde_json::json!({
"path": info.path,
"files": info.files,
@ -52,7 +52,7 @@ pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
}
CacheSubcommand::Clear => {
let (removed, failed) = cache.clear()?;
if matches!(format, OutputFormat::Json) {
if format.is_json() {
return json::print_json(&serde_json::json!({
"removed": removed,
"failed": failed,

View file

@ -1,7 +1,7 @@
use clap::{Args, Subcommand};
use crate::config::{config_path, ensure_default_config, get_config_value, set_config_value};
use crate::error::IdxError;
use idx_core::error::IdxError;
#[derive(Debug, Args)]
#[command(about = "Manage configuration")]

View file

@ -6,7 +6,7 @@ pub mod stocks;
use clap::{Parser, Subcommand, ValueEnum};
use crate::output::OutputFormat;
use crate::output::OutputSetting;
#[derive(Debug, Clone, ValueEnum)]
pub enum Shell {
@ -23,8 +23,14 @@ pub enum Shell {
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")]
pub output: Option<OutputFormat>,
#[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")]

View file

@ -1,23 +1,20 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use chrono::Utc;
use clap::{Args, Subcommand};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use owo_colors::OwoColorize;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::config::IdxConfig;
use crate::error::IdxError;
use crate::output::OutputFormat;
use crate::output::json;
use crate::output::style::Paint;
use crate::output::table::format_idr;
use crate::ownership::types::{
ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource,
use idx_core::error::IdxError;
use idx_core::investor::{
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
};
use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx};
use idx_core::ownership::graph;
use idx_core::ownership::snapshot;
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
#[derive(Debug, Args)]
#[command(
@ -197,29 +194,29 @@ pub enum ResolveCommand {
}
pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> {
let investor = Investor::new(&config.core);
let inv = &investor;
match cmd {
OwnershipCommand::Discover(args) => handle_discover(args, config),
OwnershipCommand::Import(args) => handle_import(args, config),
OwnershipCommand::Sync(args) => handle_sync(args, config),
OwnershipCommand::Ticker(args) => handle_ticker(args, config),
OwnershipCommand::Entity(args) => handle_entity(args, config),
OwnershipCommand::Search(args) => handle_search(args, config),
OwnershipCommand::CrossHolders(args) => handle_cross_holders(args, config),
OwnershipCommand::Concentration(args) => handle_concentration(args, config),
OwnershipCommand::Flow(args) => handle_flow(args, config),
OwnershipCommand::Changes(args) => handle_changes(args, config),
OwnershipCommand::Graph(args) => handle_graph(args, config),
OwnershipCommand::Resolve(args) => handle_resolve(args, config),
OwnershipCommand::Releases => handle_releases(config),
OwnershipCommand::Discover(args) => handle_discover(inv, args, config),
OwnershipCommand::Import(args) => handle_import(inv, args, config),
OwnershipCommand::Sync(args) => handle_sync(inv, args, config),
OwnershipCommand::Ticker(args) => handle_ticker(inv, args, config),
OwnershipCommand::Entity(args) => handle_entity(inv, args, config),
OwnershipCommand::Search(args) => handle_search(inv, args, config),
OwnershipCommand::CrossHolders(args) => handle_cross_holders(inv, args, config),
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
OwnershipCommand::Releases => handle_releases(inv, config),
}
}
fn handle_sync(args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> {
let manifest_source = snapshot::resolve_manifest_source(args.manifest.as_deref())?;
let db_path = db::db_path(config)?;
let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?;
fn handle_sync(investor: &Investor, args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> {
let result = investor.sync(args.manifest.as_deref(), args.force)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&result);
}
@ -259,17 +256,21 @@ fn handle_sync(args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> {
Ok(())
}
fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxError> {
fn handle_discover(
investor: &Investor,
args: &DiscoverArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
if args.limit == 0 {
return Err(IdxError::ParseError(
"--limit must be greater than 0".to_string(),
));
}
let family = parse_discovery_family(&args.family)?;
let reports = remote::discover_idx_ownership_reports(family, args.limit)?;
let family = parse_report_family(&args.family)?;
let reports = investor.discover(family, args.limit)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&reports);
}
@ -301,43 +302,15 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr
Ok(())
}
fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let symbol = args.symbol.trim().to_uppercase();
let source = args.source.trim().to_lowercase();
if !["all", "ksei", "bing"].contains(&source.as_str()) {
return Err(IdxError::ParseError(
"invalid --source, expected: all|ksei|bing".to_string(),
));
}
fn handle_ticker(
investor: &Investor,
args: &TickerArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let source: HolderSource = args.source.parse()?;
let data = investor.ticker(&args.symbol, source)?;
let mut data = db::query_ticker_holdings(&conn, &symbol)?;
if data.holders.is_empty() {
return Err(IdxError::ParseError(format!(
"no ownership data found for symbol {symbol}"
)));
}
if source != "all" {
data.holders.retain(|h| match source.as_str() {
"ksei" => matches!(h.source, OwnershipSource::Ksei),
"bing" => matches!(h.source, OwnershipSource::Bing),
_ => true,
});
for (i, row) in data.holders.iter_mut().enumerate() {
row.rank = i + 1;
}
let percentages: Vec<i64> = data.holders.iter().map(|h| h.percentage_bps).collect();
data.concentration = db::compute_concentration(&percentages);
}
if data.holders.is_empty() {
return Err(IdxError::ParseError(format!(
"no {source} ownership data found for symbol {symbol}"
)));
}
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&data);
}
@ -369,8 +342,8 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError>
Cell::new(&h.name),
Cell::new(h.investor_type.clone().unwrap_or_else(|| "-".to_string())),
Cell::new(match h.locality {
Some(crate::ownership::types::Locality::Local) => "L",
Some(crate::ownership::types::Locality::Foreign) => "F",
Some(idx_core::ownership::types::Locality::Local) => "L",
Some(idx_core::ownership::types::Locality::Foreign) => "F",
None => "-",
}),
Cell::new(format_idr(h.shares)),
@ -393,25 +366,22 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError>
Ok(())
}
fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let q = format!("%{}%", args.name.trim());
fn handle_entity(
investor: &Investor,
args: &EntityArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let data = investor.entity(&args.name)?;
let entity = conn
.query_row(
"SELECT id, canonical_name FROM entities WHERE canonical_name LIKE ?1 COLLATE NOCASE ORDER BY LENGTH(canonical_name) ASC LIMIT 1",
rusqlite::params![q],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.map_err(|_| IdxError::ParseError(format!("entity not found: {}", args.name)))?;
let data = db::query_entity_holdings(&conn, entity.0)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&data);
}
println!("{} {}", "Entity:".bold(), entity.1.bold());
println!(
"{} {}",
"Entity:".bold(),
data.entity.canonical_name.as_str().bold()
);
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
@ -436,11 +406,14 @@ fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError>
Ok(())
}
fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let entities = search::fts_search(&conn, &args.query, args.limit)?;
fn handle_search(
investor: &Investor,
args: &SearchArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let entities = investor.search(&args.query, args.limit)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&entities);
}
@ -471,11 +444,14 @@ fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError>
Ok(())
}
fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?;
fn handle_cross_holders(
investor: &Investor,
args: &CrossHolderArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let rows = investor.cross_holders(args.min_tickers, args.top)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&rows);
}
@ -498,18 +474,15 @@ fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<()
Ok(())
}
fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let by = args.by.trim().to_lowercase();
if !["top1", "top3", "hhi"].contains(&by.as_str()) {
return Err(IdxError::ParseError(
"invalid --by, expected: top1|top3|hhi".to_string(),
));
}
fn handle_concentration(
investor: &Investor,
args: &ConcentrationArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let by: ConcentrationRank = args.by.parse()?;
let rows = investor.concentration(by, args.top)?;
let rows = db::query_concentration(&conn, &by, args.top)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&rows);
}
@ -543,22 +516,20 @@ fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<
Ok(())
}
fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
fn handle_flow(investor: &Investor, args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> {
let symbol = args.symbol.trim().to_uppercase();
let ticker_id = db::get_ticker_id(&conn, &symbol)?
.ok_or_else(|| IdxError::SymbolNotFound(symbol.clone()))?;
let flow = db::query_bing_flow(&conn, ticker_id)?;
let flow = investor.flow(&symbol)?;
let Some(flow) = flow else {
if matches!(config.output, OutputFormat::Json) {
return json::print_json(&Option::<crate::ownership::types::InstitutionalFlow>::None);
if config.output.is_json() {
return json::print_json(
&Option::<idx_core::ownership::types::InstitutionalFlow>::None,
);
}
println!("No institutional flow data available for {symbol}.");
return Ok(());
};
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&flow);
}
@ -595,15 +566,14 @@ fn print_flow_section(title: &str, rows: &[HolderRow]) {
println!("{table}");
}
fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let (nodes, edges) = graph::query_ownership_graph(&conn, &args.root, args.depth)?;
fn handle_graph(investor: &Investor, args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> {
let (nodes, edges) = investor.graph(&args.root, args.depth)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
#[derive(Serialize)]
struct GraphOutput<'a> {
nodes: &'a [crate::ownership::types::GraphNode],
edges: &'a [crate::ownership::types::GraphEdge],
nodes: &'a [idx_core::ownership::types::GraphNode],
edges: &'a [idx_core::ownership::types::GraphEdge],
}
return json::print_json(&GraphOutput {
nodes: &nodes,
@ -626,11 +596,14 @@ fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> {
}
}
fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let changes = db::query_changes(&conn, &args.from, &args.to)?;
fn handle_changes(
investor: &Investor,
args: &ChangesArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
let changes = investor.changes(&args.from, &args.to, None)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&changes);
}
@ -672,14 +645,16 @@ fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError
Ok(())
}
fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
fn handle_resolve(
investor: &Investor,
args: &ResolveArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
match &args.command {
ResolveCommand::Unresolved { limit } => {
let rows = db::list_unresolved(&conn, *limit)?;
let rows = investor.unresolved(*limit)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&rows);
}
@ -717,8 +692,8 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
Ok(())
}
ResolveCommand::Map { alias, entity } => {
db::manual_map(&conn, alias, entity)?;
if matches!(config.output, OutputFormat::Json) {
investor.map_alias(alias, entity)?;
if config.output.is_json() {
return json::print_json(&serde_json::json!({
"action": "mapped",
"alias": alias,
@ -729,31 +704,12 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
Ok(())
}
ResolveCommand::Merge { keep, merge } => {
let alias_updates: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entity_aliases WHERE entity_id = ?1",
rusqlite::params![merge],
|row| row.get(0),
)
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let ksei_updates: i64 = conn
.query_row(
"SELECT COUNT(*) FROM ksei_holdings WHERE entity_id = ?1",
rusqlite::params![merge],
|row| row.get(0),
)
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let bing_updates: i64 = conn
.query_row(
"SELECT COUNT(*) FROM bing_holdings WHERE entity_id = ?1",
rusqlite::params![merge],
|row| row.get(0),
)
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let merged = investor.merge_entities(*keep, *merge)?;
let alias_updates = merged.alias_updates;
let ksei_updates = merged.ksei_holdings_updates;
let bing_updates = merged.bing_holdings_updates;
db::merge_entities(&conn, *keep, *merge)?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&serde_json::json!({
"action": "merged",
"keep": keep,
@ -772,7 +728,11 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
}
}
fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> {
fn handle_import(
investor: &Investor,
args: &ImportArgs,
config: &IdxConfig,
) -> Result<(), IdxError> {
if args.file.is_none() && args.url.is_none() && args.fetch_bing.is_none() {
return Err(IdxError::ParseError(
"provide one of: --file, --url, or --fetch-bing".to_string(),
@ -796,126 +756,36 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
));
}
let Some(import_input) = resolve_import_input(args)? else {
return Ok(());
let source = match (&args.file, &args.url) {
(Some(path), _) => ImportSource::File(path.clone()),
(None, Some(url)) => ImportSource::Url(url.clone()),
// An empty --fetch-bing list has nothing to import.
(None, None) => return Ok(()),
};
let result = investor.import(&source, args.force)?;
let conn = db::open_db(config)?;
let sha256 = sha256_file(&import_input.import_path)?;
if !args.force && db::release_exists(&conn, &sha256)? {
let result = OwnershipImportResult {
action: "skipped_existing",
inserted_rows: 0,
ticker_count: 0,
as_of_date: None,
sha256,
source_url: import_input.source_url,
};
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&result);
}
println!(
match result.as_of_date.as_deref() {
None => println!(
"Release already imported (sha256: {}). Use --force to re-import.",
result.sha256
);
return Ok(());
}
let drafts = match import_input.format {
ImportInputFormat::Pdf => {
let raw_rows = parser::parse_ksei_pdf(&import_input.import_path)?;
if raw_rows.is_empty() {
return Err(IdxError::ParseError(
"no KSEI rows parsed from PDF".to_string(),
));
}
let mut drafts = Vec::with_capacity(raw_rows.len());
for raw in &raw_rows {
drafts.push(entities::normalize_ksei_row(raw)?);
}
drafts
}
ImportInputFormat::Archive => archive::parse_balancepos_file(&import_input.import_path)?,
ImportInputFormat::Xlsx => xlsx::parse_above1_xlsx_file(&import_input.import_path)?,
};
let mut holdings = Vec::with_capacity(drafts.len());
let mut ticker_ids = HashSet::new();
for draft in drafts {
let ticker_id = db::upsert_ticker(&conn, &draft.ticker_code, draft.issuer_name.as_deref())?;
let entity_id =
entities::resolve_entity(&conn, &draft.raw_investor_name, OwnershipSource::Ksei)?;
ticker_ids.insert(ticker_id);
holdings.push(KseiHolding {
id: 0,
ticker_id,
entity_id: Some(entity_id),
raw_investor_name: draft.raw_investor_name,
investor_type: draft.investor_type,
locality: draft.locality,
nationality: draft.nationality,
domicile: draft.domicile,
holdings_scripless: draft.holdings_scripless,
holdings_scrip: draft.holdings_scrip,
total_shares: draft.total_shares,
percentage_bps: draft.percentage_bps,
report_date: draft.report_date,
release_sha256: sha256.clone(),
});
}
let as_of_date = holdings
.iter()
.map(|h| h.report_date)
.max()
.ok_or_else(|| {
IdxError::ParseError("missing report_date in parsed holdings".to_string())
})?;
let release = OwnershipRelease {
id: 0,
source_url: import_input.source_url,
sha256,
as_of_date,
row_count: holdings.len(),
imported_at: Utc::now().timestamp(),
};
let inserted_rows = db::write_ksei_release(&conn, &release, &holdings, args.force)?;
let result = OwnershipImportResult {
action: if args.force { "reimported" } else { "imported" },
inserted_rows,
ticker_count: ticker_ids.len(),
as_of_date: Some(as_of_date.format("%Y-%m-%d").to_string()),
sha256: release.sha256.clone(),
source_url: release.source_url.clone(),
};
if matches!(config.output, OutputFormat::Json) {
return json::print_json(&result);
}
println!(
),
Some(as_of_date) => println!(
"Imported {} rows for {} tickers (as of {}).",
result.inserted_rows,
result.ticker_count,
as_of_date.format("%Y-%m-%d")
);
result.inserted_rows, result.ticker_count, as_of_date
),
}
Ok(())
}
fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?;
let releases = db::query_releases(&conn)?;
fn handle_releases(investor: &Investor, config: &IdxConfig) -> Result<(), IdxError> {
let releases = investor.releases()?;
if matches!(config.output, OutputFormat::Json) {
if config.output.is_json() {
return json::print_json(&releases);
}
@ -944,238 +814,6 @@ fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> {
Ok(())
}
fn parse_discovery_family(raw: &str) -> Result<Option<remote::OwnershipReportFamily>, IdxError> {
match raw.trim().to_ascii_lowercase().as_str() {
"all" => Ok(None),
"above1" | "above-1" | "above_1" => {
Ok(Some(remote::OwnershipReportFamily::AboveOnePercent))
}
"above5" | "above-5" | "above_5" => {
Ok(Some(remote::OwnershipReportFamily::AboveFivePercent))
}
"investor-type" | "investor_type" | "investortype" => {
Ok(Some(remote::OwnershipReportFamily::InvestorTypeBreakdown))
}
_ => Err(IdxError::ParseError(
"invalid --family, expected: all|above1|above5|investor-type".to_string(),
)),
}
}
struct ResolvedImportInput {
import_path: PathBuf,
source_url: Option<String>,
format: ImportInputFormat,
}
#[derive(Debug, Serialize)]
struct OwnershipImportResult {
action: &'static str,
inserted_rows: usize,
ticker_count: usize,
as_of_date: Option<String>,
sha256: String,
source_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImportInputFormat {
Pdf,
Archive,
Xlsx,
}
fn resolve_import_input(args: &ImportArgs) -> Result<Option<ResolvedImportInput>, IdxError> {
if let Some(path) = &args.file {
if !path.exists() {
return Err(IdxError::Io(format!(
"input ownership file not found: {}",
path.display()
)));
}
return Ok(Some(ResolvedImportInput {
import_path: path.clone(),
source_url: None,
format: detect_local_import_format(path)?,
}));
}
if let Some(url) = &args.url {
let trimmed = url.trim();
let format = validate_import_url(trimmed)?;
let target = cache_download_path(trimmed, format)?;
match format {
ImportInputFormat::Xlsx => download_xlsx(trimmed, &target)?,
_ => download_pdf(trimmed, &target)?,
}
return Ok(Some(ResolvedImportInput {
import_path: target,
source_url: Some(trimmed.to_string()),
format,
}));
}
Ok(None)
}
fn detect_local_import_format(path: &Path) -> Result<ImportInputFormat, IdxError> {
match path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("pdf") => Ok(ImportInputFormat::Pdf),
Some("xlsx") if xlsx::supports_xlsx_file(path) => Ok(ImportInputFormat::Xlsx),
Some("zip") | Some("txt") if archive::supports_local_archive_file(path) => {
Ok(ImportInputFormat::Archive)
}
_ => Err(IdxError::InvalidInput(format!(
"unsupported local ownership file {}; expected a .xlsx, .pdf, .zip, or .txt input",
path.display()
))),
}
}
fn cache_download_path(url: &str, format: ImportInputFormat) -> Result<PathBuf, IdxError> {
let extension = match format {
ImportInputFormat::Xlsx => ".xlsx",
_ => ".pdf",
};
let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw");
fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?;
let mut file_name = url
.rsplit('/')
.next()
.unwrap_or("ksei-ownership.pdf")
.split('?')
.next()
.unwrap_or("ksei-ownership.pdf")
.to_string();
if file_name.is_empty() || file_name == "/" {
file_name = format!("ksei-{}{extension}", Utc::now().timestamp());
}
if !file_name.to_ascii_lowercase().ends_with(extension) {
file_name.push_str(extension);
}
Ok(raw_dir.join(file_name))
}
fn validate_import_url(url: &str) -> Result<ImportInputFormat, IdxError> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(IdxError::InvalidInput(
"ownership import --url accepts direct XLSX or PDF URLs only".to_string(),
));
}
let normalized = trimmed.to_ascii_lowercase();
let normalized = normalized
.split(['?', '#'])
.next()
.unwrap_or(normalized.as_str());
if normalized.ends_with(".pdf") {
return Ok(ImportInputFormat::Pdf);
}
if normalized.ends_with(".xlsx") {
return Ok(ImportInputFormat::Xlsx);
}
Err(IdxError::InvalidInput(
"ownership import --url accepts direct XLSX or PDF URLs only; run `idx ownership discover` first to find the current supported file".to_string(),
))
}
fn download_pdf(url: &str, target: &Path) -> Result<(), IdxError> {
if is_idx_url(url) {
return remote::download_idx_pdf(url, target);
}
let response = ureq::get(url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
)
.header("Accept", "application/pdf,application/octet-stream,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://www.idx.co.id/")
.call()
.map_err(|e| IdxError::Http(format!("failed to download PDF: {e}")))?;
let bytes = response
.into_body()
.with_config()
.limit(REMOTE_REPORT_LIMIT_BYTES)
.read_to_vec()
.map_err(|e| IdxError::Http(format!("failed reading PDF body: {e}")))?;
remote::validate_pdf_payload(&bytes)?;
fs::write(target, &bytes).map_err(|e| {
IdxError::Io(format!(
"failed writing cached PDF {}: {e}",
target.display()
))
})?;
Ok(())
}
/// Above ureq's 10 MiB default: the monthly holder-register PDF can grow past it.
const REMOTE_REPORT_LIMIT_BYTES: u64 = 256 * 1024 * 1024;
fn download_xlsx(url: &str, target: &Path) -> Result<(), IdxError> {
if is_idx_url(url) {
return remote::download_idx_xlsx(url, target);
}
let response = ureq::get(url)
.header(
"Accept",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/octet-stream,*/*;q=0.8",
)
.call()
.map_err(|e| IdxError::Http(format!("failed to download XLSX: {e}")))?;
let bytes = response
.into_body()
.with_config()
.limit(REMOTE_REPORT_LIMIT_BYTES)
.read_to_vec()
.map_err(|e| IdxError::Http(format!("failed reading XLSX body: {e}")))?;
remote::validate_xlsx_payload(&bytes)?;
fs::write(target, &bytes).map_err(|e| {
IdxError::Io(format!(
"failed writing cached XLSX {}: {e}",
target.display()
))
})
}
fn is_idx_url(url: &str) -> bool {
let normalized = url.trim().to_ascii_lowercase();
normalized.starts_with("https://www.idx.co.id/")
|| normalized.starts_with("http://www.idx.co.id/")
|| normalized.starts_with("https://idx.co.id/")
|| normalized.starts_with("http://idx.co.id/")
}
fn sha256_file(path: &Path) -> Result<String, IdxError> {
let bytes = fs::read(path).map_err(|e| {
IdxError::Io(format!(
"failed to read file for sha256 {}: {e}",
path.display()
))
})?;
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
Ok(digest.iter().map(|b| format!("{b:02x}")).collect())
}
fn format_bps(bps: i64) -> String {
format!("{:.2}%", bps as f64 / 100.0)
}

View file

@ -0,0 +1,333 @@
use std::io::Read;
use clap::{Args, Subcommand};
use crate::config::{HistoryProviderKind, IdxConfig};
use crate::output::{
render_compare, render_earnings, render_financials, render_fundamental, render_growth,
render_history, render_insights, render_news, render_profile, render_quotes, render_risk,
render_screener, render_sentiment, render_technical, render_valuation,
};
use idx_core::api::types::{Interval, Period};
use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
use idx_core::market::ScreenQuery;
use idx_core::{CachePolicy, Idx, runtime};
/// Splits comma-separated symbol arguments; `-` reads symbols from stdin,
/// separated by whitespace or commas.
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
let mut symbols = Vec::new();
for arg in args {
if arg == "-" {
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.map_err(|e| IdxError::Io(e.to_string()))?;
let from_stdin: Vec<String> = input
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if from_stdin.is_empty() {
return Err(IdxError::InvalidInput(
"no symbols read from stdin".to_string(),
));
}
symbols.extend(from_stdin);
} else {
symbols.extend(arg.split(',').map(String::from));
}
}
Ok(symbols)
}
#[derive(Debug, Clone, Args, Default)]
pub struct FinancialsFilterArgs {
/// Limit output to one or more statements.
#[arg(long, value_enum, value_delimiter = ',', num_args = 1..)]
statement: Vec<StatementKind>,
}
#[derive(Debug, Clone, Copy, Args, Default)]
pub struct EarningsFilterArgs {
/// Only include forward earnings rows.
#[arg(long)]
forecast: bool,
/// Only include historical earnings rows.
#[arg(long)]
history: bool,
/// Only include annual periods.
#[arg(long)]
annual: bool,
/// Only include quarterly periods.
#[arg(long)]
quarterly: bool,
}
impl From<EarningsFilterArgs> for EarningsFilter {
fn from(args: EarningsFilterArgs) -> Self {
Self {
forecast: args.forecast,
history: args.history,
annual: args.annual,
quarterly: args.quarterly,
}
}
}
#[derive(Debug, Args)]
#[command(about = "Stock data and analysis")]
pub struct StocksCmd {
#[command(subcommand)]
pub command: StocksSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum StocksSubcommand {
#[command(
about = "Get real-time stock quotes",
after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -"
)]
Quote {
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
#[arg(required = true)]
symbols: Vec<String>,
},
#[command(
about = "Get historical OHLC data",
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
)]
History {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
#[arg(long, value_enum, default_value_t = Period::ThreeMonths)]
period: Period,
#[arg(long, value_enum, default_value_t = Interval::Day)]
interval: Interval,
#[arg(long, value_enum)]
history_provider: Option<HistoryProviderKind>,
},
#[command(
about = "Run technical analysis on a stock",
after_help = "Examples:\n idx stocks technical BBCA\n idx -o json stocks technical BBCA"
)]
Technical {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
#[arg(long, value_enum)]
history_provider: Option<HistoryProviderKind>,
},
#[command(
about = "Run growth analysis on a stock",
after_help = "Examples:\n idx stocks growth BBCA\n idx -o json stocks growth BBCA"
)]
Growth {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[command(
about = "Run valuation analysis on a stock",
after_help = "Examples:\n idx stocks valuation BBCA\n idx -o json stocks valuation BBCA"
)]
Valuation {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[command(
about = "Run risk analysis on a stock",
after_help = "Examples:\n idx stocks risk BBCA\n idx -o json stocks risk BBCA"
)]
Risk {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[command(
about = "Run full fundamental analysis on a stock",
after_help = "Examples:\n idx stocks fundamental BBCA\n idx -o json stocks fundamental BBCA"
)]
Fundamental {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[command(about = "Get company profile")]
Profile { symbol: String },
#[command(
about = "Get financial statements",
after_help = "Examples:\n idx stocks financials BBCA\n idx stocks financials BBCA --statement income\n idx stocks financials BBCA --statement income,balance"
)]
Financials {
symbol: String,
#[command(flatten)]
filters: FinancialsFilterArgs,
},
#[command(
about = "Get earnings report",
after_help = "Examples:\n idx stocks earnings BBCA\n idx stocks earnings BBCA --history --quarterly\n idx -o json stocks earnings BBCA --forecast --annual"
)]
Earnings {
symbol: String,
#[command(flatten)]
filters: EarningsFilterArgs,
},
#[command(about = "Get crowd sentiment")]
Sentiment { symbol: String },
#[command(about = "Get AI insights")]
Insights { symbol: String },
#[command(about = "Get stock news")]
News {
symbol: String,
#[arg(long, default_value_t = 10)]
limit: usize,
},
#[command(about = "MSN screener")]
Screen {
#[arg(long, default_value = "top-performers")]
filter: String,
#[arg(long, default_value = "id")]
region: String,
#[arg(long, default_value_t = 50)]
limit: usize,
},
#[command(
about = "Compare fundamentals across stocks",
after_help = "Examples:\n idx stocks compare BBCA BBRI BMRI\n idx stocks compare BBCA,BBRI,BMRI\n idx -o json stocks compare BBCA,BBRI\n idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare -"
)]
Compare {
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
#[arg(required = true)]
symbols: Vec<String>,
},
}
pub fn handle(
cmd: &StocksCmd,
config: &IdxConfig,
offline: bool,
no_cache: bool,
) -> Result<(), IdxError> {
let policy = CachePolicy::from_flags(offline, no_cache)?;
let idx = Idx::new(config.core.clone())?;
let format = &config.output;
let no_color = config.no_color;
match &cmd.command {
StocksSubcommand::Quote { symbols } => {
let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?;
let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect();
render_quotes(&quotes, format, no_color)
}
StocksSubcommand::History {
symbol,
period,
interval,
history_provider,
} => {
let source = idx.market().history_source(*history_provider);
announce_history_fallback(&idx, source, config);
let history = idx
.market()
.history(symbol, period, interval, source, policy)?;
let resolved = idx_core::api::resolve_symbol(symbol, &config.core.exchange)?;
render_history(&resolved, &history.data, format)
}
StocksSubcommand::Technical {
symbol,
history_provider,
} => {
let source = idx.market().history_source(*history_provider);
announce_history_fallback(&idx, source, config);
let report = idx.analysis().technical(symbol, source, policy)?;
render_technical(&report.data, format, no_color)
}
StocksSubcommand::Growth { symbol } => {
let report = idx.analysis().growth(symbol, policy)?;
render_growth(&resolve(symbol, config)?, &report.data, format, no_color)
}
StocksSubcommand::Valuation { symbol } => {
let report = idx.analysis().valuation(symbol, policy)?;
render_valuation(&resolve(symbol, config)?, &report.data, format, no_color)
}
StocksSubcommand::Risk { symbol } => {
let report = idx.analysis().risk(symbol, policy)?;
render_risk(&resolve(symbol, config)?, &report.data, format, no_color)
}
StocksSubcommand::Fundamental { symbol } => {
let report = idx.analysis().fundamental(symbol, policy)?;
render_fundamental(&report.data, format, no_color)
}
StocksSubcommand::Profile { symbol } => {
let profile = idx.financial().profile(symbol, policy)?;
render_profile(&profile.data, format)
}
StocksSubcommand::Financials { symbol, filters } => {
let filter = StatementFilter {
statements: filters.statement.clone(),
};
let financials = idx.financial().statements(symbol, &filter, policy)?;
render_financials(&financials.data, format)
}
StocksSubcommand::Earnings { symbol, filters } => {
let earnings =
idx.financial()
.earnings(symbol, EarningsFilter::from(*filters), policy)?;
render_earnings(&earnings.data, format)
}
StocksSubcommand::Sentiment { symbol } => {
let sentiment = idx.financial().sentiment(symbol, policy)?;
render_sentiment(&sentiment.data, format)
}
StocksSubcommand::Insights { symbol } => {
let insights = idx.financial().insights(symbol, policy)?;
render_insights(&insights.data, format)
}
StocksSubcommand::News { symbol, limit } => {
let news = idx.financial().news(symbol, *limit, policy)?;
render_news(&news.data, format)
}
StocksSubcommand::Screen {
filter,
region,
limit,
} => {
let query = ScreenQuery {
filter: filter.parse()?,
region: region.parse()?,
limit: *limit,
};
let quotes = idx.market().screen(&query, policy)?;
render_screener(&quotes.data, format, no_color)
}
StocksSubcommand::Compare { symbols } => {
let comparison = idx.analysis().compare(&expand_symbols(symbols)?, policy)?;
// Partial success needs diagnostics; total failure must leave stderr
// available for the single structured error emitted by the caller.
for (resolved, err) in comparison.failures {
runtime::warn(format!(
"failed to fetch fundamentals for {resolved}: {err}"
));
}
let reports: Vec<_> = comparison.reports.into_iter().map(|r| r.data).collect();
render_compare(&reports, format, no_color)
}
}
}
fn resolve(symbol: &str, config: &IdxConfig) -> Result<String, IdxError> {
idx_core::api::resolve_symbol(symbol, &config.core.exchange)
}
/// Table mode notes when `auto` history falls back to another provider.
fn announce_history_fallback(
idx: &Idx,
source: idx_core::market::HistorySource,
config: &IdxConfig,
) {
if source.is_fallback && !config.output.is_json() {
runtime::info(format!(
"history provider fallback active ({} -> {})",
idx.config().provider.as_str(),
source.provider.as_str()
));
}
}

View file

@ -0,0 +1,97 @@
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;
/// CLI settings: core data-access settings plus presentation.
#[derive(Debug, Clone)]
pub struct IdxConfig {
pub core: CoreConfig,
pub output: OutputFormat,
pub no_color: bool,
}
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
general: Option<FileGeneral>,
}
#[derive(Debug, Deserialize, Default)]
struct FileGeneral {
output: Option<OutputSetting>,
color: Option<bool>,
}
impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut core = CoreConfig::load()?;
core.verbose = cli.verbose > 0;
let mut output = OutputSetting::Auto.resolve(stdout_is_terminal());
let mut no_color = false;
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(setting) = general.output {
output = setting.resolve(stdout_is_terminal());
}
if let Some(color) = general.color {
no_color = !color;
}
}
}
if let Ok(value) = std::env::var("IDX_OUTPUT") {
let setting = OutputSetting::parse(&value).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{value}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
output = setting.resolve(stdout_is_terminal());
}
if let Ok(value) = std::env::var("IDX_NO_COLOR") {
no_color = value == "1" || value.eq_ignore_ascii_case("true");
}
if let Some(setting) = cli.output {
output = setting.resolve(stdout_is_terminal());
}
no_color = no_color || cli.no_color || !stdout_is_terminal();
Ok(Self {
core,
output,
no_color,
})
}
}
pub fn stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
#[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);
}
}

View file

@ -1,50 +1,17 @@
pub mod analysis;
mod api;
mod cache;
mod cli;
mod config;
mod curl_impersonate;
mod error;
mod output;
#[cfg(feature = "ownership")]
pub mod ownership;
pub mod runtime {
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
static QUIET: AtomicBool = AtomicBool::new(false);
pub fn set_quiet(quiet: bool) {
QUIET.store(quiet, Ordering::Relaxed);
}
pub fn is_quiet() -> bool {
QUIET.load(Ordering::Relaxed)
}
pub fn info(message: impl Display) {
if !is_quiet() {
eprintln!("info: {message}");
}
}
pub fn warn(message: impl Display) {
if !is_quiet() {
eprintln!("warning: {message}");
}
}
}
use idx_core::runtime;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::{generate, shells};
use crate::api::default_provider;
use crate::cli::{Cli, Commands};
use crate::config::IdxConfig;
use crate::error::IdxError;
use crate::output::{OutputFormat, emit_error};
use crate::output::{OutputFormat, OutputSetting, emit_error};
use idx_core::error::IdxError;
fn main() {
if let Err(err) = run() {
@ -62,16 +29,19 @@ fn run() -> Result<(), IdxError> {
return Err(err);
}
};
output::json::configure(config.output, config::stdout_is_terminal());
output::style::set_color(!config.no_color);
match &cli.command {
Commands::Version => match config.output {
OutputFormat::Json => {
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"));
}
}
OutputFormat::Table => println!("{}", env!("CARGO_PKG_VERSION")),
},
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let name = cmd.get_name().to_owned();
@ -82,15 +52,7 @@ fn run() -> Result<(), IdxError> {
}
}
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,
) {
if let Err(err) = cli::stocks::handle(stocks, &config, cli.offline, cli.no_cache) {
emit_error(&err, &config.output);
return Err(err);
}
@ -119,13 +81,15 @@ fn run() -> Result<(), IdxError> {
Ok(())
}
/// Output format for errors raised before the configuration has loaded.
fn bootstrap_output_format(cli: &Cli) -> OutputFormat {
if let Some(output) = cli.output {
return output;
}
match std::env::var("IDX_OUTPUT") {
Ok(value) if value.eq_ignore_ascii_case("json") => OutputFormat::Json,
_ => OutputFormat::Table,
}
let setting = cli
.output
.or_else(|| {
std::env::var("IDX_OUTPUT")
.ok()
.and_then(|value| OutputSetting::parse(&value))
})
.unwrap_or_default();
setting.resolve(config::stdout_is_terminal())
}

View file

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

View file

@ -1,52 +1,64 @@
pub mod json;
pub mod style;
pub mod table;
use chrono::NaiveDate;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::io::IsTerminal;
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
use crate::analysis::signals::TechnicalSignal;
use crate::api::types::{
use clap::ValueEnum;
use serde::Serialize;
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::report::TechnicalReport;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData,
};
use crate::error::IdxError;
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,
}
#[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>,
impl OutputFormat {
/// True for every machine-readable format (JSON and JSON Lines).
pub fn is_json(self) -> bool {
matches!(self, Self::Json | Self::Jsonl)
}
}
pub fn render_quotes(
@ -56,7 +68,7 @@ pub fn render_quotes(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json => json::print_json(quotes),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
}
}
@ -67,7 +79,7 @@ pub fn render_history(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_history(symbol, history),
OutputFormat::Json => json::print_json(history),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(history),
}
}
@ -78,7 +90,7 @@ pub fn render_technical(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_technical(report, no_color),
OutputFormat::Json => json::print_json(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
@ -90,7 +102,7 @@ pub fn render_growth(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_growth(symbol, report, no_color),
OutputFormat::Json => json::print_json(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
@ -102,7 +114,7 @@ pub fn render_valuation(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_valuation(symbol, report, no_color),
OutputFormat::Json => json::print_json(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
@ -114,7 +126,7 @@ pub fn render_risk(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_risk(symbol, report, no_color),
OutputFormat::Json => json::print_json(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
@ -125,7 +137,7 @@ pub fn render_fundamental(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_fundamental(report, no_color),
OutputFormat::Json => json::print_json(report),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(report),
}
}
@ -136,14 +148,14 @@ pub fn render_compare(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_compare(reports, no_color),
OutputFormat::Json => json::print_json(reports),
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 => json::print_json(profile),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(profile),
}
}
@ -153,35 +165,35 @@ pub fn render_financials(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_financials(financials),
OutputFormat::Json => json::print_json(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 => json::print_json(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 => json::print_json(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 => json::print_json(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 => json::print_json(items),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(items),
}
}
@ -192,23 +204,25 @@ pub fn render_screener(
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json => json::print_json(quotes),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
}
}
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
match format {
OutputFormat::Table => eprintln!("Error: {err}"),
OutputFormat::Json => {
if !format.is_json() {
eprintln!("Error: {err}");
return;
}
let payload = serde_json::json!({
"error": true,
"code": format!("{:?}", err.code()).to_uppercase(),
"code": err.code().to_string(),
"message": err.to_string()
});
eprintln!(
"{}",
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
);
}
}
let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal();
let rendered = if pretty {
serde_json::to_string_pretty(&payload)
} else {
serde_json::to_string(&payload)
};
eprintln!("{}", rendered.unwrap_or_else(|_| "{}".to_string()));
}

View file

@ -0,0 +1,43 @@
//! 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())
}
// Only ownership output uses blue.
#[cfg_attr(not(feature = "ownership"), allow(dead_code))]
fn blue(self) -> String {
paint(self, |v| OwoColorize::blue(v).to_string())
}
}
impl<T: Display> Paint for T {}

View file

@ -1,14 +1,16 @@
use crate::output::style::Paint;
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use owo_colors::OwoColorize;
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
use crate::analysis::signals::Signal;
use crate::api::types::{
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::report::TechnicalReport;
use idx_core::analysis::signals::Signal;
use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
Quote, SentimentData,
};
use crate::error::IdxError;
use crate::output::TechnicalReport;
use idx_core::error::IdxError;
pub fn format_idr(value: i64) -> String {
let sign = if value.is_negative() { "-" } else { "" };
@ -708,7 +710,7 @@ pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> {
}
pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> {
let print_section = |label: &str, section: &crate::api::types::StatementSection| {
let print_section = |label: &str, section: &idx_core::api::types::StatementSection| {
println!("\n── {label} ({}) ──", format_table_date(&section.end_date));
let mut t = Table::new();
let value_header = format!("VALUE ({})", section.currency);
@ -887,8 +889,8 @@ mod tests {
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
humanize_metric_key, stale_quote_notes,
};
use crate::analysis::signals::Signal;
use crate::api::types::Quote;
use idx_core::analysis::signals::Signal;
use idx_core::api::types::Quote;
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
Quote {

View file

@ -26,7 +26,7 @@ fn resolve_idx_binary_path() -> PathBuf {
}
let target_debug = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("../../target")
.join("debug")
.join(format!("idx{}", std::env::consts::EXE_SUFFIX));
if candidate_is_idx_binary(&target_debug) {
@ -73,7 +73,10 @@ fn candidate_is_idx_binary(path: &Path) -> bool {
return false;
}
let Ok(output) = std::process::Command::new(path).arg("version").output() else {
let Ok(output) = std::process::Command::new(path)
.args(["-o", "table", "version"])
.output()
else {
return false;
};
@ -110,7 +113,12 @@ fn bin_with_root(root: &Path) -> Command {
fs::create_dir_all(&cache_home).expect("create cache dir");
let mut cmd = bin();
cmd.current_dir(env!("CARGO_MANIFEST_DIR"));
// The mock provider reads fixtures relative to the working directory.
cmd.current_dir(core_dir());
// Tests run with piped stdout, where the default (auto) format is JSON.
// Pin table so tests that assert table text keep doing so; `-o` and
// per-test IDX_OUTPUT still override this.
cmd.env("IDX_OUTPUT", "table");
cmd.env("XDG_CONFIG_HOME", &config_home);
cmd.env("XDG_CACHE_HOME", &cache_home);
cmd
@ -121,11 +129,13 @@ fn test_bin(name: &str) -> Command {
bin_with_root(&root)
}
/// idx-core crate root, which owns the shared test fixtures.
fn core_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../idx-core")
}
fn fixture_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
core_dir().join("tests").join("fixtures").join(name)
}
fn run_success_stdout(cmd: &mut Command) -> String {
@ -368,11 +378,11 @@ fn prepare_snapshot_fixture(root: &Path) -> (PathBuf, PathBuf) {
&[
(
"release-2026-01-31.pdf",
include_str!("fixtures/ksei_above1_stext_excerpt_prev.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt_prev.xml"),
),
(
"release-2026-02-27.pdf",
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
),
],
);
@ -1057,7 +1067,7 @@ const UNREACHABLE_DATA_PAGE_URL: &str = "http://127.0.0.1:9/data-kepemilikan-sah
#[test]
fn ownership_discover_finds_xlsx_on_share_ownership_page() {
let page = fs::read_to_string("tests/fixtures/idx_share_ownership_page_excerpt.html")
let page = fs::read_to_string(fixture_path("idx_share_ownership_page_excerpt.html"))
.expect("read share ownership page fixture");
let page_url = spawn_single_response_server("text/html", page);
@ -1091,7 +1101,7 @@ fn ownership_discover_finds_xlsx_on_share_ownership_page() {
#[test]
fn ownership_discover_lists_fixture_candidates() {
let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json")
let body = fs::read_to_string(fixture_path("idx_announcement_kepemilikan.json"))
.expect("read ownership discovery fixture");
let json_url = spawn_single_response_server("application/json", body);
@ -1271,6 +1281,7 @@ fn ownership_discover_json_includes_status() {
}"#;
let json_url = spawn_single_response_server("application/json", body.to_string());
let discovered = run_success_json(
test_bin("ownership-discover-json-status")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
@ -1279,10 +1290,9 @@ fn ownership_discover_json_includes_status() {
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
"http://127.0.0.1/pengumuman",
)
.args(["-o", "json", "ownership", "discover", "--limit", "1"])
.assert()
.success()
.stdout(predicate::str::contains("\"status\": \"supported\""));
.args(["-o", "json", "ownership", "discover", "--limit", "1"]),
);
assert_eq!(discovered[0]["status"], "supported");
}
#[test]
@ -1325,7 +1335,7 @@ fn ownership_import_url_supported_pdf_succeeds_with_fake_mutool() {
let db_path = root.join("ownership.db");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
);
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "supported");
@ -1362,7 +1372,7 @@ fn ownership_import_url_caches_download_under_xdg_cache_home() {
let cache_home = root.join("xdg-cache");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
);
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "cached-raw");
@ -1401,7 +1411,7 @@ fn ownership_import_url_duplicate_release_is_skipped() {
let db_path = root.join("ownership.db");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
);
let first_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let second_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
@ -1439,7 +1449,7 @@ fn ownership_import_file_xlsx_supports_ticker_and_releases() {
let xlsx_path = root.join("peng-2026-08-00017-satu-persen.xlsx");
fs::write(
&xlsx_path,
include_bytes!("fixtures/ksei_above1_20260831_excerpt.xlsx"),
include_bytes!("../../idx-core/tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
)
.expect("write xlsx fixture");
@ -1488,7 +1498,7 @@ fn ownership_import_file_rejects_above5_xlsx() {
let xlsx_path = root.join("peng-2026-09-23-00080-lima-persen.xlsx");
fs::write(
&xlsx_path,
include_bytes!("fixtures/ksei_above5_20260923_excerpt.xlsx"),
include_bytes!("../../idx-core/tests/fixtures/ksei_above5_20260923_excerpt.xlsx"),
)
.expect("write xlsx fixture");
@ -1516,7 +1526,7 @@ fn ownership_import_force_reimports_existing_release() {
let pdf_path = root.join("force.pdf");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
);
fs::write(&pdf_path, fake_pdf_bytes()).expect("write local pdf fixture");
@ -1564,7 +1574,7 @@ fn ownership_import_url_rejects_legacy_above5_pdf_schema() {
let data_home = root.join("data");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_above5_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_above5_stext_excerpt.xml"),
);
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "legacy-above5");
@ -1586,7 +1596,7 @@ fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() {
let data_home = root.join("data");
let fake_mutool_dir = install_fake_mutool(
&root,
include_str!("fixtures/ksei_investor_type_stext_excerpt.xml"),
include_str!("../../idx-core/tests/fixtures/ksei_investor_type_stext_excerpt.xml"),
);
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "legacy-investor-type");
@ -1609,7 +1619,7 @@ fn ownership_import_file_txt_archive_succeeds() {
let txt_path = root.join("Balancepos20260227.txt");
fs::write(
&txt_path,
include_str!("fixtures/ksei_balancepos_20260227_excerpt.txt"),
include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
)
.expect("write txt archive fixture");
@ -1647,12 +1657,12 @@ fn ownership_import_file_zip_archive_supports_releases_ticker_and_changes() {
write_zip_with_text(
&jan_zip,
"Balancepos20260130.txt",
include_str!("fixtures/ksei_balancepos_20260130_excerpt.txt"),
include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260130_excerpt.txt"),
);
write_zip_with_text(
&feb_zip,
"Balancepos20260227.txt",
include_str!("fixtures/ksei_balancepos_20260227_excerpt.txt"),
include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
);
bin_with_root(&root)

View file

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

View file

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

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
--- stderr

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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