mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
commit
386a63c288
271 changed files with 10644 additions and 2503 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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 }}-
|
||||
|
|
|
|||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
AGENTS.md
35
AGENTS.md
|
|
@ -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
479
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
53
Cargo.toml
53
Cargo.toml
|
|
@ -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"] }
|
||||
|
|
|
|||
53
README.md
53
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
41
crates/idx-cli/Cargo.toml
Normal 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
1
crates/idx-cli/LICENSE
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
||||
|
|
@ -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,
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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)
|
||||
}
|
||||
333
crates/idx-cli/src/cli/stocks.rs
Normal file
333
crates/idx-cli/src/cli/stocks.rs
Normal 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("es, 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("es.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()
|
||||
));
|
||||
}
|
||||
}
|
||||
97
crates/idx-cli/src/config.rs
Normal file
97
crates/idx-cli/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
44
crates/idx-cli/src/output/json.rs
Normal file
44
crates/idx-cli/src/output/json.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
const PRETTY: u8 = 0;
|
||||
const COMPACT: u8 = 1;
|
||||
const LINES: u8 = 2;
|
||||
|
||||
static STYLE: AtomicU8 = AtomicU8::new(PRETTY);
|
||||
|
||||
/// Pretty JSON only for a human at a terminal; compact for pipes and
|
||||
/// captured output; JSON Lines when requested.
|
||||
pub fn configure(format: super::OutputFormat, stdout_is_terminal: bool) {
|
||||
let style = match format {
|
||||
super::OutputFormat::Jsonl => LINES,
|
||||
_ if stdout_is_terminal => PRETTY,
|
||||
_ => COMPACT,
|
||||
};
|
||||
STYLE.store(style, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), IdxError> {
|
||||
let to_err = |e: serde_json::Error| IdxError::ParseError(e.to_string());
|
||||
match STYLE.load(Ordering::Relaxed) {
|
||||
PRETTY => println!("{}", serde_json::to_string_pretty(value).map_err(to_err)?),
|
||||
COMPACT => println!("{}", serde_json::to_string(value).map_err(to_err)?),
|
||||
_ => {
|
||||
// Split an array into one line per item without re-serializing,
|
||||
// so each line keeps the same key order as `-o json`.
|
||||
let compact = serde_json::to_string(value).map_err(to_err)?;
|
||||
match serde_json::from_str::<Vec<&serde_json::value::RawValue>>(&compact) {
|
||||
Ok(items) => {
|
||||
for item in items {
|
||||
println!("{}", item.get());
|
||||
}
|
||||
}
|
||||
Err(_) => println!("{compact}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
impl OutputFormat {
|
||||
/// True for every machine-readable format (JSON and JSON Lines).
|
||||
pub fn is_json(self) -> bool {
|
||||
matches!(self, Self::Json | Self::Jsonl)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MacdSnapshot {
|
||||
pub line: Option<f64>,
|
||||
pub signal: Option<f64>,
|
||||
pub histogram: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeSnapshot {
|
||||
pub current: u64,
|
||||
pub average20: Option<f64>,
|
||||
pub ratio20: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn render_quotes(
|
||||
|
|
@ -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()));
|
||||
}
|
||||
43
crates/idx-cli/src/output/style.rs
Normal file
43
crates/idx-cli/src/output/style.rs
Normal 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 {}
|
||||
|
|
@ -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(§ion.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 {
|
||||
|
|
@ -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)
|
||||
240
crates/idx-cli/tests/golden.rs
Normal file
240
crates/idx-cli/tests/golden.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! Golden output tests: byte-for-byte parity of CLI stdout, stderr, and exit
|
||||
//! code for every shipped command (see "Parity and Verification" in
|
||||
//! docs/DESIGN.md).
|
||||
//!
|
||||
//! Cases live in `tests/golden/cases.toml`; expected output in
|
||||
//! `tests/golden/<case>.golden`. Regenerate only for an intentional, reviewed
|
||||
//! output change, and commit the diff with the reason:
|
||||
//!
|
||||
//! UPDATE_GOLDEN=1 cargo test --test golden
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const IDX: &str = env!("CARGO_BIN_EXE_idx");
|
||||
const CRATE: &str = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CaseFile {
|
||||
case: Vec<Case>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
args: Vec<String>,
|
||||
/// Output formats to expand the case into (`-o <format>` is prepended and
|
||||
/// `.<format>` appended to the name). Empty means run the args as given.
|
||||
#[serde(default = "default_formats")]
|
||||
formats: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
/// Seed the fixture ownership DB before running.
|
||||
#[serde(default)]
|
||||
ownership: bool,
|
||||
/// Commands run first in the same environment; their output is ignored.
|
||||
#[serde(default)]
|
||||
before: Vec<Vec<String>>,
|
||||
/// Text piped to the command's stdin.
|
||||
#[serde(default)]
|
||||
stdin: Option<String>,
|
||||
}
|
||||
|
||||
fn default_formats() -> Vec<String> {
|
||||
vec!["table".into(), "json".into()]
|
||||
}
|
||||
|
||||
/// Commands run from the idx-core crate root: the mock provider and the cases
|
||||
/// read fixtures as `tests/fixtures/...` relative to the working directory.
|
||||
fn core_dir() -> PathBuf {
|
||||
Path::new(CRATE)
|
||||
.join("../idx-core")
|
||||
.canonicalize()
|
||||
.expect("idx-core crate dir")
|
||||
}
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
Path::new(CRATE).join("tests").join("golden")
|
||||
}
|
||||
|
||||
fn work_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("idx-golden-{}", std::process::id()))
|
||||
}
|
||||
|
||||
/// Runs idx with an isolated home under `root` and only the given IDX_* env.
|
||||
fn idx(root: &Path, args: &[String], env: &BTreeMap<String, String>) -> std::process::Output {
|
||||
idx_with_stdin(root, args, env, None)
|
||||
}
|
||||
|
||||
fn idx_with_stdin(
|
||||
root: &Path,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
stdin: Option<&str>,
|
||||
) -> std::process::Output {
|
||||
let mut cmd = Command::new(IDX);
|
||||
cmd.current_dir(core_dir()).args(args);
|
||||
for (key, _) in std::env::vars_os() {
|
||||
if key.to_string_lossy().starts_with("IDX_") || key == "NO_COLOR" {
|
||||
cmd.env_remove(key);
|
||||
}
|
||||
}
|
||||
cmd.env("HOME", root)
|
||||
.env("XDG_CONFIG_HOME", root.join("config"))
|
||||
.env("XDG_CACHE_HOME", root.join("cache"))
|
||||
.env("XDG_DATA_HOME", root.join("data"))
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.envs(env)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn idx");
|
||||
let mut pipe = child.stdin.take().expect("stdin pipe");
|
||||
if let Some(input) = stdin {
|
||||
pipe.write_all(input.as_bytes()).expect("write stdin");
|
||||
}
|
||||
drop(pipe);
|
||||
child.wait_with_output().expect("run idx")
|
||||
}
|
||||
|
||||
/// Fixture ownership DB: the above-1% XLSX (2026-08-31) plus two KSEI archive
|
||||
/// months (2026-01-30, 2026-02-27), with wall-clock columns pinned.
|
||||
fn seeded_ownership_db() -> &'static Path {
|
||||
static DB: OnceLock<PathBuf> = OnceLock::new();
|
||||
DB.get_or_init(|| {
|
||||
let root = work_dir().join("seed");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).expect("create seed dir");
|
||||
// The importer derives the release from the IDX file name.
|
||||
let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx");
|
||||
fs::copy(
|
||||
core_dir().join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
|
||||
&xlsx,
|
||||
)
|
||||
.expect("copy xlsx fixture");
|
||||
let inputs = [
|
||||
xlsx,
|
||||
core_dir().join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"),
|
||||
core_dir().join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
|
||||
];
|
||||
for input in inputs {
|
||||
let args = ["ownership", "import", "--file", input.to_str().unwrap()]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
let out = idx(&root, &args, &BTreeMap::new());
|
||||
assert!(out.status.success(), "seed import failed: {out:?}");
|
||||
}
|
||||
let db = root.join("data/idx/ownership.db");
|
||||
let conn = rusqlite::Connection::open(&db).expect("open seed db");
|
||||
conn.execute_batch(
|
||||
"UPDATE ownership_releases SET imported_at = 0;
|
||||
UPDATE entities SET created_at = 0, updated_at = 0;
|
||||
UPDATE bing_holdings SET fetched_at = 0;",
|
||||
)
|
||||
.expect("pin timestamps");
|
||||
db
|
||||
})
|
||||
}
|
||||
|
||||
fn render(root: &Path, out: &std::process::Output) -> String {
|
||||
let normalize = |bytes: &[u8]| {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.replace(root.to_str().unwrap(), "<ROOT>")
|
||||
.replace(core_dir().to_str().unwrap(), "<REPO>")
|
||||
};
|
||||
format!(
|
||||
"exit: {}\n--- stdout\n{}--- stderr\n{}",
|
||||
out.status.code().map_or("signal".into(), |c| c.to_string()),
|
||||
normalize(&out.stdout),
|
||||
normalize(&out.stderr),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_output_matches_golden_files() {
|
||||
let raw = fs::read_to_string(golden_dir().join("cases.toml")).expect("read cases.toml");
|
||||
let cases: CaseFile = toml::from_str(&raw).expect("parse cases.toml");
|
||||
let update = std::env::var_os("UPDATE_GOLDEN").is_some();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for case in &cases.case {
|
||||
let runs: Vec<(String, Vec<String>)> = if case.formats.is_empty() {
|
||||
vec![(case.name.clone(), case.args.clone())]
|
||||
} else {
|
||||
case.formats
|
||||
.iter()
|
||||
.map(|format| {
|
||||
let mut args = vec!["-o".to_string(), format.clone()];
|
||||
args.extend(case.args.iter().cloned());
|
||||
(format!("{}.{format}", case.name), args)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (name, args) in runs {
|
||||
assert!(seen.insert(name.clone()), "duplicate golden case {name}");
|
||||
let root = work_dir().join(&name);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).expect("create case dir");
|
||||
if case.ownership {
|
||||
let target = root.join("data/idx/ownership.db");
|
||||
fs::create_dir_all(target.parent().unwrap()).expect("create data dir");
|
||||
fs::copy(seeded_ownership_db(), &target).expect("copy seeded db");
|
||||
}
|
||||
for before in &case.before {
|
||||
idx(&root, before, &case.env);
|
||||
}
|
||||
|
||||
let output = idx_with_stdin(&root, &args, &case.env, case.stdin.as_deref());
|
||||
let actual = render(&root, &output);
|
||||
let path = golden_dir().join(format!("{name}.golden"));
|
||||
if update {
|
||||
fs::write(&path, &actual).expect("write golden");
|
||||
} else {
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected) if expected == actual => {}
|
||||
Ok(expected) => failures.push(format!(
|
||||
"{name}: output differs\n--- expected\n{expected}\n--- actual\n{actual}"
|
||||
)),
|
||||
Err(_) => failures.push(format!("{name}: missing {}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(golden_dir()).expect("read golden dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
let Some(stem) = path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.strip_suffix(".golden")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !seen.contains(stem) {
|
||||
if update {
|
||||
fs::remove_file(&path).expect("remove orphan golden");
|
||||
} else {
|
||||
failures.push(format!("{stem}: golden file has no case in cases.toml"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(work_dir());
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} golden mismatch(es); if the change is intentional, rerun with UPDATE_GOLDEN=1 \
|
||||
and commit the diff with the reason\n\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n\n")
|
||||
);
|
||||
}
|
||||
8
crates/idx-cli/tests/golden/auto-format-env-table.golden
Normal file
8
crates/idx-cli/tests/golden/auto-format-env-table.golden
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐
|
||||
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │
|
||||
╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡
|
||||
│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │
|
||||
└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/auto-format-piped.golden
Normal file
4
crates/idx-cli/tests/golden/auto-format-piped.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/cache-clear.json.golden
Normal file
4
crates/idx-cli/tests/golden/cache-clear.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"failed":[],"removed":0}
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/cache-clear.table.golden
Normal file
4
crates/idx-cli/tests/golden/cache-clear.table.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
cleared 0 files
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/cache-info-empty.json.golden
Normal file
4
crates/idx-cli/tests/golden/cache-info-empty.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"files":0,"newest":null,"oldest":null,"path":"<ROOT>/cache/idx","size_bytes":0}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
path: <ROOT>/cache/idx
|
||||
files: 0
|
||||
size_bytes: 0
|
||||
oldest: -
|
||||
newest: -
|
||||
--- stderr
|
||||
382
crates/idx-cli/tests/golden/cases.toml
Normal file
382
crates/idx-cli/tests/golden/cases.toml
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# Golden output cases for tests/golden.rs.
|
||||
#
|
||||
# Each case runs once per entry in `formats` (default: table and json) with
|
||||
# `-o <format>` prepended; `formats = []` runs the args as given, which means
|
||||
# the auto format: stdout is a pipe here, so auto resolves to compact JSON.
|
||||
# Every run uses the mock provider (IDX_USE_MOCK_PROVIDER=1) and an isolated
|
||||
# home directory. `ownership = true` seeds the fixture DB; `before` runs setup
|
||||
# commands first; `stdin` is piped to the command.
|
||||
#
|
||||
# `idx version` is excluded on purpose: its output changes with every release.
|
||||
|
||||
# --- help and shell completions ---------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "help-root"
|
||||
args = ["--help"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "help-stocks"
|
||||
args = ["stocks", "--help"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "help-ownership"
|
||||
args = ["ownership", "--help"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "help-config"
|
||||
args = ["config", "--help"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "help-cache"
|
||||
args = ["cache", "--help"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "completions-bash"
|
||||
args = ["completions", "bash"]
|
||||
formats = []
|
||||
|
||||
# --- config and cache ---------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "config-path"
|
||||
args = ["config", "path"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "config-init"
|
||||
args = ["config", "init"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "config-get"
|
||||
args = ["config", "get", "general.provider"]
|
||||
formats = []
|
||||
before = [["config", "init"]]
|
||||
|
||||
[[case]]
|
||||
name = "config-set"
|
||||
args = ["config", "set", "general.provider", "yahoo"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "config-set-output-jsonl"
|
||||
args = ["config", "set", "general.output", "jsonl"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "config-set-output-invalid"
|
||||
args = ["config", "set", "general.output", "yaml"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "config-output-table-pins-format"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
formats = []
|
||||
before = [["config", "set", "general.output", "table"]]
|
||||
|
||||
[[case]]
|
||||
name = "cache-info-empty"
|
||||
args = ["cache", "info"]
|
||||
|
||||
[[case]]
|
||||
name = "cache-clear"
|
||||
args = ["cache", "clear"]
|
||||
|
||||
# --- stocks: MSN (default provider) --------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "stocks-quote"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-quote-multi"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "quote", "BBCA,BBRI"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-history"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "history", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-history-msn"
|
||||
args = ["stocks", "history", "BBCA", "--history-provider", "msn"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-technical"
|
||||
args = ["stocks", "technical", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-growth"
|
||||
args = ["stocks", "growth", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-valuation"
|
||||
args = ["stocks", "valuation", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-valuation-nonfinite"
|
||||
args = ["stocks", "valuation", "BBCA"]
|
||||
env = { IDX_MOCK_MSN_KEYRATIOS_FIXTURE = "tests/fixtures/msn_keyratios_infinity.json" }
|
||||
|
||||
[[case]]
|
||||
name = "stocks-risk"
|
||||
args = ["stocks", "risk", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-fundamental"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "fundamental", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-compare"
|
||||
args = ["stocks", "compare", "BBCA", "BBRI"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-profile"
|
||||
args = ["stocks", "profile", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-financials"
|
||||
args = ["stocks", "financials", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-financials-income"
|
||||
args = ["stocks", "financials", "BBCA", "--statement", "income"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-earnings"
|
||||
args = ["stocks", "earnings", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-earnings-history-quarterly"
|
||||
args = ["stocks", "earnings", "BBCA", "--history", "--quarterly"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-earnings-forecast-annual"
|
||||
args = ["stocks", "earnings", "BBCA", "--forecast", "--annual"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-sentiment"
|
||||
args = ["stocks", "sentiment", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-insights"
|
||||
args = ["stocks", "insights", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-news"
|
||||
args = ["stocks", "news", "BBCA", "--limit", "5"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-screen"
|
||||
args = ["stocks", "screen"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-screen-worst"
|
||||
args = ["stocks", "screen", "--filter", "worst-performers", "--limit", "5"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-screen-high-volume"
|
||||
args = ["stocks", "screen", "--filter", "high-volume"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-screen-large-cap"
|
||||
args = ["stocks", "screen", "--filter", "large-cap"]
|
||||
|
||||
[[case]]
|
||||
name = "auto-format-piped"
|
||||
args = ["stocks", "quote", "BBCA,BBRI"]
|
||||
formats = []
|
||||
|
||||
[[case]]
|
||||
name = "auto-format-env-table"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
formats = []
|
||||
env = { IDX_OUTPUT = "table" }
|
||||
|
||||
[[case]]
|
||||
name = "stocks-quote-stdin"
|
||||
args = ["stocks", "quote", "-"]
|
||||
stdin = "BBCA\nBBRI\n"
|
||||
|
||||
[[case]]
|
||||
name = "stocks-compare-stdin"
|
||||
args = ["stocks", "compare", "-"]
|
||||
stdin = "BBCA, BBRI"
|
||||
|
||||
[[case]]
|
||||
name = "error-stdin-empty"
|
||||
args = ["stocks", "quote", "-"]
|
||||
stdin = ""
|
||||
|
||||
[[case]]
|
||||
name = "error-invalid-output-env"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
formats = []
|
||||
env = { IDX_OUTPUT = "yaml" }
|
||||
|
||||
[[case]]
|
||||
name = "stocks-quote-offline-warm"
|
||||
args = ["--offline", "stocks", "quote", "BBCA"]
|
||||
before = [["stocks", "quote", "BBCA"]]
|
||||
|
||||
# --- stocks: Yahoo provider -----------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "yahoo-quote"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
[[case]]
|
||||
name = "yahoo-history"
|
||||
args = ["stocks", "history", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
[[case]]
|
||||
name = "yahoo-fundamental"
|
||||
args = ["stocks", "fundamental", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
# --- errors ---------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "error-usage-missing-symbol"
|
||||
args = ["stocks", "quote"]
|
||||
|
||||
[[case]]
|
||||
name = "error-invalid-provider"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
env = { IDX_PROVIDER = "bogus" }
|
||||
|
||||
[[case]]
|
||||
name = "error-yahoo-profile-unsupported"
|
||||
args = ["stocks", "profile", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
[[case]]
|
||||
name = "error-industry-only-fundamentals"
|
||||
args = ["stocks", "valuation", "BBCA"]
|
||||
env = { IDX_MOCK_MSN_KEYRATIOS_FIXTURE = "tests/fixtures/msn_keyratios_industry_only.json" }
|
||||
|
||||
[[case]]
|
||||
name = "error-screen-region"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["stocks", "screen", "--region", "eu"]
|
||||
|
||||
[[case]]
|
||||
name = "error-offline-no-cache-flags"
|
||||
args = ["--offline", "--no-cache", "stocks", "quote", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-offline-cache-miss"
|
||||
args = ["--offline", "stocks", "quote", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-provider-failure"
|
||||
args = ["stocks", "quote", "BBCA"]
|
||||
env = { IDX_MOCK_ERROR = "1" }
|
||||
|
||||
[[case]]
|
||||
name = "error-compare-total-failure"
|
||||
args = ["stocks", "compare", "BBCA", "BBRI"]
|
||||
env = { IDX_MOCK_ERROR = "1" }
|
||||
|
||||
[[case]]
|
||||
name = "error-fetch-bing-unsupported"
|
||||
args = ["ownership", "import", "--fetch-bing", "BBCA"]
|
||||
|
||||
# --- ownership --------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "ownership-releases-empty"
|
||||
args = ["ownership", "releases"]
|
||||
|
||||
[[case]]
|
||||
name = "ownership-ticker-empty"
|
||||
args = ["ownership", "ticker", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "ownership-import-xlsx"
|
||||
args = ["ownership", "import", "--file", "tests/fixtures/ksei_above1_20260831_excerpt.xlsx"]
|
||||
|
||||
[[case]]
|
||||
name = "ownership-import-archive-txt"
|
||||
args = ["ownership", "import", "--file", "tests/fixtures/ksei_balancepos_20260227_excerpt.txt"]
|
||||
|
||||
[[case]]
|
||||
name = "ownership-releases"
|
||||
args = ["ownership", "releases"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-ticker"
|
||||
args = ["ownership", "ticker", "BBCA"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-ticker-ksei"
|
||||
args = ["ownership", "ticker", "BBCA", "--source", "ksei"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-entity"
|
||||
args = ["ownership", "entity", "ANTHONI SALIM"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-search"
|
||||
args = ["ownership", "search", "salim"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-cross-holders"
|
||||
args = ["ownership", "cross-holders", "--min-tickers", "1"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-concentration"
|
||||
args = ["ownership", "concentration"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-flow"
|
||||
args = ["ownership", "flow", "BBCA"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-changes"
|
||||
formats = ["table", "json", "jsonl"]
|
||||
args = ["ownership", "changes", "--from", "2026-01-30", "--to", "2026-02-27"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-graph"
|
||||
args = ["ownership", "graph", "BBCA"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-graph-dot"
|
||||
args = ["ownership", "graph", "BBCA", "--format", "dot"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-resolve-unresolved"
|
||||
args = ["ownership", "resolve", "unresolved"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-resolve-map"
|
||||
args = ["ownership", "resolve", "map", "SALIM ANTHONI", "ANTHONI SALIM"]
|
||||
ownership = true
|
||||
|
||||
[[case]]
|
||||
name = "ownership-resolve-merge"
|
||||
args = ["ownership", "resolve", "merge", "1", "2"]
|
||||
ownership = true
|
||||
2851
crates/idx-cli/tests/golden/completions-bash.golden
Normal file
2851
crates/idx-cli/tests/golden/completions-bash.golden
Normal file
File diff suppressed because it is too large
Load diff
4
crates/idx-cli/tests/golden/config-get.golden
Normal file
4
crates/idx-cli/tests/golden/config-get.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
msn
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/config-init.golden
Normal file
4
crates/idx-cli/tests/golden/config-init.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
<ROOT>/config/idx/config.toml
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐
|
||||
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │
|
||||
╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡
|
||||
│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │
|
||||
└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/config-path.golden
Normal file
4
crates/idx-cli/tests/golden/config-path.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
<ROOT>/config/idx/config.toml
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid output format 'yaml': expected 'auto', 'table', 'json', or 'jsonl'"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
ok
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/config-set.golden
Normal file
4
crates/idx-cli/tests/golden/config-set.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
ok
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: provider unavailable
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"UNSUPPORTED","error":true,"message":"unsupported: --fetch-bing import is not implemented yet"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: unsupported: --fetch-bing import is not implemented yet
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 1
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"PARSEERROR","error":true,"message":"parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 1
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"CONFIGERROR","error":true,"message":"config error: invalid IDX_OUTPUT value: 'yaml', expected 'auto', 'table', 'json', or 'jsonl'"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: config error: invalid provider 'bogus' (expected yahoo or msn)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"CACHEMISS","error":true,"message":"cache miss: msn-quote/BBCA.JK"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: cache miss: msn-quote/BBCA.JK
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: cannot combine --offline with --no-cache"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: cannot combine --offline with --no-cache
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 4
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: provider unavailable
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"INVALIDINPUT","error":true,"message":"invalid input: no symbols read from stdin"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: invalid input: no symbols read from stdin
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
error: the following required arguments were not provided:
|
||||
<SYMBOLS>...
|
||||
|
||||
Usage: idx stocks quote <SYMBOLS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
exit: 2
|
||||
--- stdout
|
||||
--- stderr
|
||||
error: the following required arguments were not provided:
|
||||
<SYMBOLS>...
|
||||
|
||||
Usage: idx stocks quote <SYMBOLS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: command requires --provider msn"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: unsupported: BBCA.JK: command requires --provider msn
|
||||
20
crates/idx-cli/tests/golden/help-cache.golden
Normal file
20
crates/idx-cli/tests/golden/help-cache.golden
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Manage local cache
|
||||
|
||||
Usage: idx cache [OPTIONS] <COMMAND>
|
||||
|
||||
Commands:
|
||||
info Show cache usage information
|
||||
clear Clear all cached files
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||
--no-color Disable colored output
|
||||
-q, --quiet Suppress non-essential output
|
||||
-v, --verbose... Increase verbosity (-v, -vv)
|
||||
--offline Run without network requests
|
||||
--no-cache Bypass local cache
|
||||
-h, --help Print help
|
||||
--- stderr
|
||||
22
crates/idx-cli/tests/golden/help-config.golden
Normal file
22
crates/idx-cli/tests/golden/help-config.golden
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Manage configuration
|
||||
|
||||
Usage: idx config [OPTIONS] <COMMAND>
|
||||
|
||||
Commands:
|
||||
init Create default configuration file
|
||||
get Get a configuration value by key
|
||||
set Set a configuration value by key
|
||||
path Print configuration file path
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||
--no-color Disable colored output
|
||||
-q, --quiet Suppress non-essential output
|
||||
-v, --verbose... Increase verbosity (-v, -vv)
|
||||
--offline Run without network requests
|
||||
--no-cache Bypass local cache
|
||||
-h, --help Print help
|
||||
--- stderr
|
||||
58
crates/idx-cli/tests/golden/help-ownership.golden
Normal file
58
crates/idx-cli/tests/golden/help-ownership.golden
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Ownership intelligence (KSEI + Bing).
|
||||
|
||||
Preferred bootstrap path:
|
||||
1. Run `idx ownership sync` to install or refresh a maintained SQLite snapshot.
|
||||
2. If no snapshot manifest is available, run `idx ownership discover` and then `idx ownership import --url <pdf-url>`.
|
||||
3. Local `.xlsx`, `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows.
|
||||
|
||||
Usage: idx ownership [OPTIONS] <COMMAND>
|
||||
|
||||
Commands:
|
||||
discover Discover the latest IDX-hosted ownership report URLs
|
||||
import Import ownership data directly from source files
|
||||
sync Install or refresh a maintained ownership SQLite snapshot
|
||||
ticker Show all holders for a ticker (KSEI + Bing combined)
|
||||
entity Show all holdings for an entity across tickers
|
||||
search Search entities by name
|
||||
cross-holders Rank entities by cross-ownership breadth
|
||||
concentration Rank tickers by ownership concentration
|
||||
flow Show Bing institutional flow for a ticker
|
||||
changes Diff two KSEI releases
|
||||
graph Ownership network graph
|
||||
resolve Manual entity resolution workflow
|
||||
releases List imported KSEI releases
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-o, --output <OUTPUT>
|
||||
Output format [default: auto = table on a terminal, json when piped]
|
||||
|
||||
[possible values: auto, table, json, jsonl]
|
||||
|
||||
--no-color
|
||||
Disable colored output
|
||||
|
||||
-q, --quiet
|
||||
Suppress non-essential output
|
||||
|
||||
-v, --verbose...
|
||||
Increase verbosity (-v, -vv)
|
||||
|
||||
--offline
|
||||
Run without network requests
|
||||
|
||||
--no-cache
|
||||
Bypass local cache
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
Examples:
|
||||
idx ownership sync
|
||||
idx ownership sync --manifest /path/to/ownership-snapshot-manifest.json
|
||||
idx ownership discover --limit 1
|
||||
idx ownership import --url <pdf-url>
|
||||
idx ownership releases
|
||||
--- stderr
|
||||
40
crates/idx-cli/tests/golden/help-root.golden
Normal file
40
crates/idx-cli/tests/golden/help-root.golden
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
idx-cli is a Rust command-line tool for Indonesian stock market (IDX) analysis.
|
||||
It supports quote lookup, historical data, local caching, and output formats for both humans and AI agents.
|
||||
|
||||
Usage: idx [OPTIONS] <COMMAND>
|
||||
|
||||
Commands:
|
||||
stocks Stock data and analysis
|
||||
config Manage configuration
|
||||
cache Manage local cache
|
||||
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
||||
completions Generate shell completions
|
||||
version Show idx-cli version
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-o, --output <OUTPUT>
|
||||
Output format [default: auto = table on a terminal, json when piped]
|
||||
|
||||
[possible values: auto, table, json, jsonl]
|
||||
|
||||
--no-color
|
||||
Disable colored output
|
||||
|
||||
-q, --quiet
|
||||
Suppress non-essential output
|
||||
|
||||
-v, --verbose...
|
||||
Increase verbosity (-v, -vv)
|
||||
|
||||
--offline
|
||||
Run without network requests
|
||||
|
||||
--no-cache
|
||||
Bypass local cache
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
--- stderr
|
||||
33
crates/idx-cli/tests/golden/help-stocks.golden
Normal file
33
crates/idx-cli/tests/golden/help-stocks.golden
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Stock data and analysis
|
||||
|
||||
Usage: idx stocks [OPTIONS] <COMMAND>
|
||||
|
||||
Commands:
|
||||
quote Get real-time stock quotes
|
||||
history Get historical OHLC data
|
||||
technical Run technical analysis on a stock
|
||||
growth Run growth analysis on a stock
|
||||
valuation Run valuation analysis on a stock
|
||||
risk Run risk analysis on a stock
|
||||
fundamental Run full fundamental analysis on a stock
|
||||
profile Get company profile
|
||||
financials Get financial statements
|
||||
earnings Get earnings report
|
||||
sentiment Get crowd sentiment
|
||||
insights Get AI insights
|
||||
news Get stock news
|
||||
screen MSN screener
|
||||
compare Compare fundamentals across stocks
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||
--no-color Disable colored output
|
||||
-q, --quiet Suppress non-essential output
|
||||
-v, --verbose... Increase verbosity (-v, -vv)
|
||||
--offline Run without network requests
|
||||
--no-cache Bypass local cache
|
||||
-h, --help Print help
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL ID","change_type":"decreased","old_bps":2400,"new_bps":2334,"delta_bps":-66},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL IS","change_type":"increased","old_bps":75,"new_bps":129,"delta_bps":54},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL MF","change_type":"increased","old_bps":117,"new_bps":152,"delta_bps":35},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN MF","change_type":"increased","old_bps":307,"new_bps":337,"delta_bps":30},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN OT","change_type":"decreased","old_bps":91,"new_bps":64,"delta_bps":-27},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN PF","change_type":"decreased","old_bps":76,"new_bps":56,"delta_bps":-20},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN CP","change_type":"increased","old_bps":72,"new_bps":86,"delta_bps":14},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL CP","change_type":"decreased","old_bps":6481,"new_bps":6467,"delta_bps":-14},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL SC","change_type":"decreased","old_bps":36,"new_bps":29,"delta_bps":-7},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN IB","change_type":"decreased","old_bps":267,"new_bps":263,"delta_bps":-4},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN SC","change_type":"increased","old_bps":51,"new_bps":55,"delta_bps":4},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL PF","change_type":"increased","old_bps":17,"new_bps":20,"delta_bps":3},{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN ID","change_type":"decreased","old_bps":3,"new_bps":2,"delta_bps":-1}]
|
||||
--- stderr
|
||||
16
crates/idx-cli/tests/golden/ownership-changes.jsonl.golden
Normal file
16
crates/idx-cli/tests/golden/ownership-changes.jsonl.golden
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL ID","change_type":"decreased","old_bps":2400,"new_bps":2334,"delta_bps":-66}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL IS","change_type":"increased","old_bps":75,"new_bps":129,"delta_bps":54}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL MF","change_type":"increased","old_bps":117,"new_bps":152,"delta_bps":35}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN MF","change_type":"increased","old_bps":307,"new_bps":337,"delta_bps":30}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN OT","change_type":"decreased","old_bps":91,"new_bps":64,"delta_bps":-27}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN PF","change_type":"decreased","old_bps":76,"new_bps":56,"delta_bps":-20}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN CP","change_type":"increased","old_bps":72,"new_bps":86,"delta_bps":14}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL CP","change_type":"decreased","old_bps":6481,"new_bps":6467,"delta_bps":-14}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL SC","change_type":"decreased","old_bps":36,"new_bps":29,"delta_bps":-7}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN IB","change_type":"decreased","old_bps":267,"new_bps":263,"delta_bps":-4}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN SC","change_type":"increased","old_bps":51,"new_bps":55,"delta_bps":4}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE LOCAL PF","change_type":"increased","old_bps":17,"new_bps":20,"delta_bps":3}
|
||||
{"ticker_code":"AADI","entity_name":"KSEI AGGREGATE FOREIGN ID","change_type":"decreased","old_bps":3,"new_bps":2,"delta_bps":-1}
|
||||
--- stderr
|
||||
32
crates/idx-cli/tests/golden/ownership-changes.table.golden
Normal file
32
crates/idx-cli/tests/golden/ownership-changes.table.golden
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌────────┬───────────────────────────┬───────────┬────────┬────────┬────────┐
|
||||
│ TICKER ┆ ENTITY ┆ CHANGE ┆ OLD% ┆ NEW% ┆ DELTA% │
|
||||
╞════════╪═══════════════════════════╪═══════════╪════════╪════════╪════════╡
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL ID ┆ DECREASED ┆ 24.00% ┆ 23.34% ┆ -0.66% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL IS ┆ INCREASED ┆ 0.75% ┆ 1.29% ┆ +0.54% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL MF ┆ INCREASED ┆ 1.17% ┆ 1.52% ┆ +0.35% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN MF ┆ INCREASED ┆ 3.07% ┆ 3.37% ┆ +0.30% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN OT ┆ DECREASED ┆ 0.91% ┆ 0.64% ┆ -0.27% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN PF ┆ DECREASED ┆ 0.76% ┆ 0.56% ┆ -0.20% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN CP ┆ INCREASED ┆ 0.72% ┆ 0.86% ┆ +0.14% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL CP ┆ DECREASED ┆ 64.81% ┆ 64.67% ┆ -0.14% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL SC ┆ DECREASED ┆ 0.36% ┆ 0.29% ┆ -0.07% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN IB ┆ DECREASED ┆ 2.67% ┆ 2.63% ┆ -0.04% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN SC ┆ INCREASED ┆ 0.51% ┆ 0.55% ┆ +0.04% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE LOCAL PF ┆ INCREASED ┆ 0.17% ┆ 0.20% ┆ +0.03% │
|
||||
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||
│ AADI ┆ KSEI AGGREGATE FOREIGN ID ┆ DECREASED ┆ 0.03% ┆ 0.02% ┆ -0.01% │
|
||||
└────────┴───────────────────────────┴───────────┴────────┴────────┴────────┘
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[["AALI",{"top1_bps":7968,"top3_bps":7968,"hhi":6348,"free_float_bps":2032,"holder_count":1}],["BBCA",{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4}],["AADI",{"top1_bps":4110,"top3_bps":6230,"hhi":2009,"free_float_bps":2030,"holder_count":10}],["ABBA",{"top1_bps":283,"top3_bps":418,"hhi":9,"free_float_bps":9582,"holder_count":2}]]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌──────┬────────┬────────┬────────┬──────┬─────────────┬─────────┐
|
||||
│ RANK ┆ TICKER ┆ TOP1% ┆ TOP3% ┆ HHI ┆ FREE_FLOAT% ┆ HOLDERS │
|
||||
╞══════╪════════╪════════╪════════╪══════╪═════════════╪═════════╡
|
||||
│ 1 ┆ AALI ┆ 79.68% ┆ 79.68% ┆ 6348 ┆ 20.32% ┆ 1 │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ BBCA ┆ 54.94% ┆ 57.16% ┆ 3021 ┆ 41.82% ┆ 4 │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ AADI ┆ 41.10% ┆ 62.30% ┆ 2009 ┆ 20.30% ┆ 10 │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ ABBA ┆ 2.83% ┆ 4.18% ┆ 9 ┆ 95.82% ┆ 2 │
|
||||
└──────┴────────┴────────┴────────┴──────┴─────────────┴─────────┘
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"entity":{"id":11,"canonical_name":"ASTRA INTERNATIONAL","entity_type":null,"country":null},"ticker_count":1,"total_bps":7968},{"entity":{"id":14,"canonical_name":"DWIMURIA INVESTAMA ANDALAN","entity_type":null,"country":null},"ticker_count":1,"total_bps":5494},{"entity":{"id":4,"canonical_name":"ADARO STRATEGIC INVESTMENTS","entity_type":null,"country":null},"ticker_count":1,"total_bps":4110},{"entity":{"id":5,"canonical_name":"ALAMTRI RESOURCES INDONESIA TBK PT","entity_type":null,"country":null},"ticker_count":1,"total_bps":1537},{"entity":{"id":7,"canonical_name":"GARIBALDI THOHIR","entity_type":null,"country":null},"ticker_count":1,"total_bps":583},{"entity":{"id":2,"canonical_name":"SARATOGA INVESTAMA SEDAYA","entity_type":null,"country":null},"ticker_count":1,"total_bps":438},{"entity":{"id":9,"canonical_name":"EDWIN SOERYADJAYA","entity_type":null,"country":null},"ticker_count":1,"total_bps":352},{"entity":{"id":8,"canonical_name":"IR. T. PERMADI RACHMAT","entity_type":null,"country":null},"ticker_count":1,"total_bps":348},{"entity":{"id":13,"canonical_name":"YAYASAN ABDI BANGSA","entity_type":null,"country":null},"ticker_count":1,"total_bps":283},{"entity":{"id":10,"canonical_name":"SANDIAGA SALAHUDDIN UNO","entity_type":null,"country":null},"ticker_count":1,"total_bps":204},{"entity":{"id":1,"canonical_name":"PERSADA CAPITAL INVESTAMA","entity_type":null,"country":null},"ticker_count":1,"total_bps":154},{"entity":{"id":12,"canonical_name":"YAYASAN BINA SEJAHTERA WARGA BULOG","entity_type":null,"country":null},"ticker_count":1,"total_bps":135},{"entity":{"id":6,"canonical_name":"FTAG INVESTMENT BANK","entity_type":null,"country":null},"ticker_count":1,"total_bps":129},{"entity":{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null},"ticker_count":1,"total_bps":115},{"entity":{"id":3,"canonical_name":"TRINUGRAHA THOHIR","entity_type":null,"country":null},"ticker_count":1,"total_bps":115},{"entity":{"id":16,"canonical_name":"TRICIPTA MANDHALA GUMILANG","entity_type":null,"country":null},"ticker_count":1,"total_bps":107},{"entity":{"id":15,"canonical_name":"CATURGUWIRATNA SUMAPALA","entity_type":null,"country":null},"ticker_count":1,"total_bps":102}]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌──────┬────────────────────────────────────┬─────────┬───────────┐
|
||||
│ RANK ┆ ENTITY ┆ TICKERS ┆ TOTAL_BPS │
|
||||
╞══════╪════════════════════════════════════╪═════════╪═══════════╡
|
||||
│ 1 ┆ ASTRA INTERNATIONAL ┆ 1 ┆ 79.68% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ DWIMURIA INVESTAMA ANDALAN ┆ 1 ┆ 54.94% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ ADARO STRATEGIC INVESTMENTS ┆ 1 ┆ 41.10% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ ALAMTRI RESOURCES INDONESIA TBK PT ┆ 1 ┆ 15.37% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ GARIBALDI THOHIR ┆ 1 ┆ 5.83% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 6 ┆ SARATOGA INVESTAMA SEDAYA ┆ 1 ┆ 4.38% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 7 ┆ EDWIN SOERYADJAYA ┆ 1 ┆ 3.52% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 8 ┆ IR. T. PERMADI RACHMAT ┆ 1 ┆ 3.48% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 9 ┆ YAYASAN ABDI BANGSA ┆ 1 ┆ 2.83% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ SANDIAGA SALAHUDDIN UNO ┆ 1 ┆ 2.04% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 11 ┆ PERSADA CAPITAL INVESTAMA ┆ 1 ┆ 1.54% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 12 ┆ YAYASAN BINA SEJAHTERA WARGA BULOG ┆ 1 ┆ 1.35% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 13 ┆ FTAG INVESTMENT BANK ┆ 1 ┆ 1.29% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 14 ┆ ANTHONI SALIM ┆ 1 ┆ 1.15% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 15 ┆ TRINUGRAHA THOHIR ┆ 1 ┆ 1.15% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 16 ┆ TRICIPTA MANDHALA GUMILANG ┆ 1 ┆ 1.07% │
|
||||
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 17 ┆ CATURGUWIRATNA SUMAPALA ┆ 1 ┆ 1.02% │
|
||||
└──────┴────────────────────────────────────┴─────────┴───────────┘
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-entity.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-entity.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"entity":{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null},"ticker_count":1,"holdings":[{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"source":"ksei","shares":1416306835,"percentage_bps":115,"report_date":"2026-08-31"}]}
|
||||
--- stderr
|
||||
10
crates/idx-cli/tests/golden/ownership-entity.table.golden
Normal file
10
crates/idx-cli/tests/golden/ownership-entity.table.golden
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Entity: ANTHONI SALIM
|
||||
┌────────┬────────┬───────────────┬───────┬─────────────┐
|
||||
│ TICKER ┆ SOURCE ┆ SHARES ┆ % ┆ REPORT_DATE │
|
||||
╞════════╪════════╪═══════════════╪═══════╪═════════════╡
|
||||
│ BBCA ┆ KSEI ┆ 1,416,306,835 ┆ 1.15% ┆ 2026-08-31 │
|
||||
└────────┴────────┴───────────────┴───────┴─────────────┘
|
||||
Total tickers: 1
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-flow.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-flow.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
null
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-flow.table.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-flow.table.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No institutional flow data available for BBCA.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"nodes":[{"id":"entity:14","label":"DWIMURIA INVESTAMA ANDALAN","node_type":"entity"},{"id":"entity:15","label":"CATURGUWIRATNA SUMAPALA","node_type":"entity"},{"id":"entity:16","label":"TRICIPTA MANDHALA GUMILANG","node_type":"entity"},{"id":"entity:17","label":"ANTHONI SALIM","node_type":"entity"},{"id":"ticker:BBCA","label":"BBCA","node_type":"ticker"}],"edges":[{"from":"entity:17","to":"ticker:BBCA","percentage_bps":115,"source":"ksei"},{"from":"entity:15","to":"ticker:BBCA","percentage_bps":102,"source":"ksei"},{"from":"entity:14","to":"ticker:BBCA","percentage_bps":5494,"source":"ksei"},{"from":"entity:16","to":"ticker:BBCA","percentage_bps":107,"source":"ksei"}]}
|
||||
--- stderr
|
||||
20
crates/idx-cli/tests/golden/ownership-graph-dot.table.golden
Normal file
20
crates/idx-cli/tests/golden/ownership-graph-dot.table.golden
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
digraph ownership {
|
||||
rankdir=LR;
|
||||
graph [fontname="Helvetica"];
|
||||
node [fontname="Helvetica"];
|
||||
edge [fontname="Helvetica"];
|
||||
|
||||
"entity:14" [label="DWIMURIA INVESTAMA ANDALAN", shape=ellipse];
|
||||
"entity:15" [label="CATURGUWIRATNA SUMAPALA", shape=ellipse];
|
||||
"entity:16" [label="TRICIPTA MANDHALA GUMILANG", shape=ellipse];
|
||||
"entity:17" [label="ANTHONI SALIM", shape=ellipse];
|
||||
"ticker:BBCA" [label="BBCA", shape=box];
|
||||
|
||||
"entity:17" -> "ticker:BBCA" [label="1.15% (ksei)"];
|
||||
"entity:15" -> "ticker:BBCA" [label="1.02% (ksei)"];
|
||||
"entity:14" -> "ticker:BBCA" [label="54.94% (ksei)"];
|
||||
"entity:16" -> "ticker:BBCA" [label="1.07% (ksei)"];
|
||||
}
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-graph.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-graph.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"nodes":[{"id":"entity:14","label":"DWIMURIA INVESTAMA ANDALAN","node_type":"entity"},{"id":"entity:15","label":"CATURGUWIRATNA SUMAPALA","node_type":"entity"},{"id":"entity:16","label":"TRICIPTA MANDHALA GUMILANG","node_type":"entity"},{"id":"entity:17","label":"ANTHONI SALIM","node_type":"entity"},{"id":"ticker:BBCA","label":"BBCA","node_type":"ticker"}],"edges":[{"from":"entity:17","to":"ticker:BBCA","percentage_bps":115,"source":"ksei"},{"from":"entity:15","to":"ticker:BBCA","percentage_bps":102,"source":"ksei"},{"from":"entity:14","to":"ticker:BBCA","percentage_bps":5494,"source":"ksei"},{"from":"entity:16","to":"ticker:BBCA","percentage_bps":107,"source":"ksei"}]}
|
||||
--- stderr
|
||||
10
crates/idx-cli/tests/golden/ownership-graph.table.golden
Normal file
10
crates/idx-cli/tests/golden/ownership-graph.table.golden
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
nodes: 5 edges: 4
|
||||
|
||||
BBCA [TICKER]
|
||||
├─ DWIMURIA INVESTAMA ANDALAN (54.94%, ksei)
|
||||
├─ ANTHONI SALIM (1.15%, ksei)
|
||||
├─ TRICIPTA MANDHALA GUMILANG (1.07%, ksei)
|
||||
└─ CATURGUWIRATNA SUMAPALA (1.02%, ksei)
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"action":"imported","inserted_rows":18,"ticker_count":1,"as_of_date":"2026-02-27","sha256":"7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547","source_url":null}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Imported 18 rows for 1 tickers (as of 2026-02-27).
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"action":"imported","inserted_rows":17,"ticker_count":4,"as_of_date":"2026-08-31","sha256":"7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3","source_url":null}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Imported 17 rows for 4 tickers (as of 2026-08-31).
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No ownership releases imported yet.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"id":1,"source_url":null,"sha256":"7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3","as_of_date":"2026-08-31","row_count":17,"imported_at":0},{"id":3,"source_url":null,"sha256":"7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547","as_of_date":"2026-02-27","row_count":18,"imported_at":0},{"id":2,"source_url":null,"sha256":"efb3d8fb19c9e0d803dc1b1734c8389356605d0f78d73db6729fb76d3d75753d","as_of_date":"2026-01-30","row_count":18,"imported_at":0}]
|
||||
--- stderr
|
||||
12
crates/idx-cli/tests/golden/ownership-releases.table.golden
Normal file
12
crates/idx-cli/tests/golden/ownership-releases.table.golden
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌────────────┬──────┬──────────────────────────────────────────────────────────────────┬────────┬─────────────┐
|
||||
│ AS_OF ┆ ROWS ┆ SHA256 ┆ SOURCE ┆ IMPORTED_AT │
|
||||
╞════════════╪══════╪══════════════════════════════════════════════════════════════════╪════════╪═════════════╡
|
||||
│ 2026-08-31 ┆ 17 ┆ 7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3 ┆ - ┆ 0 │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-02-27 ┆ 18 ┆ 7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547 ┆ - ┆ 0 │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||
│ 2026-01-30 ┆ 18 ┆ efb3d8fb19c9e0d803dc1b1734c8389356605d0f78d73db6729fb76d3d75753d ┆ - ┆ 0 │
|
||||
└────────────┴──────┴──────────────────────────────────────────────────────────────────┴────────┴─────────────┘
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"action":"mapped","alias":"SALIM ANTHONI","entity":"ANTHONI SALIM"}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Mapped alias 'SALIM ANTHONI' -> 'ANTHONI SALIM'.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"action":"merged","alias_updates":1,"bing_holdings_updates":0,"keep":1,"ksei_holdings_updates":1,"merge":2}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Merged entity 2 into 1 (aliases: 1, ksei_holdings: 1, bing_holdings: 0).
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
No unresolved or low-confidence aliases found.
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/ownership-search.json.golden
Normal file
4
crates/idx-cli/tests/golden/ownership-search.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
[{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null}]
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
┌───────────────┬──────┬─────────┐
|
||||
│ ENTITY ┆ TYPE ┆ COUNTRY │
|
||||
╞═══════════════╪══════╪═════════╡
|
||||
│ ANTHONI SALIM ┆ - ┆ - │
|
||||
└───────────────┴──────┴─────────┘
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: BBCA"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: symbol not found: BBCA
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue