From 535ef64a0cb808018103490ca803b4f83dec34a5 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 08:18:17 +0000 Subject: [PATCH 01/14] docs: add idx-core / idx-mcp system design Accepted design for extracting idx-core as the shared data layer, the agent-native CLI output contract, idx-mcp tools, packaging, and the parity gates (golden output tests plus live A/B check) that the refactor must pass before merging to main. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 + docs/DESIGN.md | 320 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 docs/DESIGN.md diff --git a/AGENTS.md b/AGENTS.md index f4367cd..b137161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ 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 @@ -33,6 +34,9 @@ Docs: 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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..1125aae --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,320 @@ +# System Design: idx-core and its frontends + +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; nothing implemented yet. + +## Goal + +Turn idx-cli's data layer into `idx-core`, a library that is the single way this +project retrieves Indonesian market, financial, and investor data. The CLI, an +MCP server, and later an HTTP API become thin frontends over it. + +Non-goals for now: new data sources, a hosted service, async rewrites of the +provider clients. + +## Architecture + +```text + Upstreams idx-core (library) Frontends +┌──────────────┐ ┌──────────────────────────────────────┐ ┌──────────────┐ +│ MSN Finance │──▶│ sources/ HTTP clients + parsers │ │ idx-cli │ +│ Yahoo │──▶│ cache/ TTL, offline, stale │──▶│ clap, table │ +│ IDX / KSEI │──▶│ store/ ownership SQLite │ ├──────────────┤ +└──────────────┘ │ market/ quotes, history, screen │──▶│ idx-mcp │ + │ financial/ fundamentals, reports │ │ MCP tools │ + │ investor/ ownership queries + sync │ ├──────────────┤ + │ analysis/ technical, fundamental │──▶│ idx-api │ + └──────────────────────────────────────┘ │ (later) │ + └──────────────┘ +``` + +Rule: frontends parse input and render output. Every fetch, cache decision, +filter, sort, and calculation lives in `idx-core`. + +## Data Domains + +| Domain | Data | Source today | Status | +| --- | --- | --- | --- | +| Market | quotes, OHLCV history, screener | MSN (default), Yahoo (OHLCV) | shipped | +| Market | IHSG index, breadth, movers, sectors | not chosen yet | `ROADMAP.md` | +| Financial | key ratios, statements, earnings, profile | MSN | shipped (single-period statements) | +| Financial | sentiment, insights, news | MSN | shipped | +| Investor | KSEI holders above 1%, entity holdings, changes, concentration, cross-holders, graph | IDX XLSX (legacy PDF), synced snapshot | shipped | +| Investor | institutional holders (Bing) | schema only | ingest unsupported | +| Analysis | technical signals, growth/valuation/risk | derived locally | shipped | + +Two access patterns, and the core API should keep them visibly separate: + +- **Live data** (market, financial): fetched from upstreams, cached on disk by TTL. +- **Local data** (investor): read from a local SQLite database that is + populated by `sync` or `import`. Queries never touch the network. + +All upstreams are unofficial or scraped. They change without notice, rate limit +datacenter IPs (Yahoo `429`), and sit behind Cloudflare (IDX `403`). The core +must report which source and how fresh each result is, and must never present +stale or fallback data as current. + +## idx-core API + +A single entry point holds configuration, providers, cache, and the DB path. +Domain methods hang off it. + +```rust +let idx = Idx::builder() + .config(CoreConfig::load()?) // provider, TTLs, paths; no output settings + .build()?; + +// market +let quotes: Fetched> = idx.market().quotes(&["BBCA", "BBRI"], CachePolicy::Default)?; +let candles: Fetched> = idx.market().history("BBCA", Period::M3, Interval::D1, CachePolicy::Default)?; +let screen: Fetched> = idx.market().screen(ScreenQuery { filter, region, limit })?; + +// financial +let report: Fetched = idx.financial().statements("BBCA", StatementFilter::all())?; +let earnings: Fetched = idx.financial().earnings("BBCA", EarningsFilter::default())?; + +// analysis (fetches its own inputs through the same cache) +let tech: Fetched = idx.analysis().technical("BBCA")?; + +// investor (local DB) +let holders: TickerOwnership = idx.investor().ticker("BBCA")?; +let status: OwnershipStatus = idx.investor().status()?; // releases, latest as-of, empty? +let sync: OwnershipSyncResult = idx.investor().sync(SyncOptions::default())?; +``` + +Key decisions: + +- **Blocking API.** `ureq` and `rusqlite` are blocking, and so is the CLI. + Async frontends (MCP, HTTP) call core through `spawn_blocking`. Revisit only + if an HTTP API needs high concurrency. +- **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`. + This replaces today's stderr-only stale-cache warning, which an agent or API + client cannot see. +- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / + `--no-cache` flag handling now spread through `cli/stocks.rs`. +- **Explicit configuration.** Core reads no environment variables. `IDX_*` env + parsing, `config.toml` loading, and XDG resolution move to a `CoreConfig::load()` + helper that frontends call; tests construct `CoreConfig` directly. +- **Mock provider behind a `testing` feature.** Today `MockProvider` ships in the + release binary and reads fixtures relative to the working directory. +- **Typed inputs.** Screener filters, regions, statement and earnings filters + become enums with `FromStr`, so validation is identical across frontends. + +## What Moves Out of `src/cli/` + +| From | To | +| --- | --- | +| `fetch_with_cache`, `cache_bucket`, offline/stale handling (`cli/stocks.rs`) | `idx-core` cache layer, behind `CachePolicy` | +| `build_technical_report`, `fetch_fundamental_analysis_report` | `analysis` | +| `filter_financial_statements`, `filter_earnings_report` | `financial` | +| `screener_filter_key`, `screener_region_key`, `sort_screener_quotes`, full-list fetch | `market::screen` | +| ownership command orchestration (`cli/ownership.rs`) | `investor` | +| `IdxConfig.output`, `no_color` | stay in `idx-cli` | + +After the move, `cli/stocks.rs` and `cli/ownership.rs` should be argument +parsing plus a call into core plus rendering. + +## Error Model + +`IdxError` and its stable codes (`SYMBOLNOTFOUND`, `NOMARKETDATA`, `RATELIMITED`, +`UNSUPPORTED`, `INVALIDINPUT`, `OFFLINE`, `CACHEMISS`, `HTTP`, ...) live in core. +Each frontend maps them once: + +| Frontend | Mapping | +| --- | --- | +| CLI | unchanged: stderr, JSON `{error, code, message}` in JSON mode, non-zero exit | +| MCP | tool result with `isError: true` and the same `{code, message}` body | +| HTTP | status by class (`INVALIDINPUT` 400, `SYMBOLNOTFOUND` 404, `RATELIMITED` 429, upstream failures 502) plus the same body | + +## Agent-Native CLI Output + +Agents mostly drive the CLI through a shell and compose it with `jq`, `grep`, +`sed`, Python, and other commands. The CLI should be a well-behaved Unix filter +first and a pretty terminal tool second. + +### Output resolution + +`-o` flag > `IDX_OUTPUT` > `config.toml` > **auto**. Auto picks: + +- stdout is a terminal: `table` +- stdout is a pipe, file, or captured by a harness: `json` + +idx cannot tell whether an agent is calling it, only whether stdout is a +terminal (`std::io::IsTerminal`). Agent harnesses capture output, so they get +JSON with no flag. Two edge cases, both handled by the explicit overrides: + +- a human piping to `less` or `grep` gets JSON; use `-o table` +- a harness that runs commands inside a pseudo-terminal gets tables; set + `IDX_OUTPUT=json` in its environment + +### Formats + +| Format | Shape | Use | +| --- | --- | --- | +| `table` | human-readable | terminals | +| `json` | one document; pretty on a terminal, compact otherwise | default for pipes; `jq`, `json.load` | +| `jsonl` | list results: one record per line; single objects: one line | `head`, `grep`, streaming, `pandas.read_json(..., lines=True)` | + +Measured on fixtures, table output is 2-5x the bytes of JSON, and compact JSON +is another 20-30% smaller than pretty JSON. + +### Composability contract + +- stdout carries only data; diagnostics and warnings go to stderr. Already true. +- Errors in `json`/`jsonl` mode are one JSON object on stderr with stdout empty. + Already true. +- No ANSI color when stdout is not a terminal. +- Commands that take symbols accept `-` to read them from stdin, one per line or + whitespace-separated: + `idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare -` +- Exit codes by error class (today every error exits `1`): + + | Exit | Meaning | Codes | + | --- | --- | --- | + | 0 | success, including partial `compare` results | | + | 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... | + | 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` | + | 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` | + | 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE` | + | 5 | unsupported | `UNSUPPORTED` | + +- JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths + stay short and JSONL works per record. Freshness metadata (`Fetched`) is + exposed by MCP and the API; the CLI keeps its stderr stale-cache warning. +- Field names, units (basis points, whole IDR, WIB timestamps), and nullability + are part of the contract and documented in `docs/ARCHITECTURE.md`. + +Examples: + +```bash +idx stocks quote BBCA BBRI | jq -r '.[] | "\(.symbol) \(.price)"' +idx -o jsonl stocks history BBCA --period 1y | tail -5 +idx ownership ticker BBCA | jq '.holders | map(select(.percentage_bps > 500))' +idx -o jsonl stocks screen --filter top-performers \ + | python -c 'import sys, json; [print(json.loads(l)["symbol"]) for l in sys.stdin]' +``` + +This is a breaking change for anyone parsing table output from a pipe. Ship it +in a minor version with a release note; `general.output = "table"` restores the +old default. + +## Frontend: idx-mcp + +Stdio transport first, using the `rmcp` crate. Read-only by default. + +Tools are grouped by question rather than mirroring every CLI command, which +keeps the tool list short for agents: + +| Tool | Inputs | Backed by | +| --- | --- | --- | +| `get_quotes` | `symbols[]` | `market().quotes` | +| `get_price_history` | `symbol`, `period`, `interval` | `market().history` | +| `screen_stocks` | `filter`, `region`, `limit` | `market().screen` | +| `get_company` | `symbol`, `sections[]` (profile, financials, earnings, sentiment, insights, news) | `financial()` | +| `analyze_stock` | `symbol`, `kinds[]` (technical, growth, valuation, risk) | `analysis()` | +| `compare_stocks` | `symbols[]` | `analysis()` | +| `get_shareholders` | `ticker` | `investor().ticker` | +| `get_investor_holdings` | `entity` | `investor().entity` | +| `search_investors` | `query` | `investor().search` | +| `get_ownership_changes` | `from`, `to`, `ticker?` | `investor().changes` | +| `ownership_status` | none | `investor().status` | + +Ownership bootstrap: if the local DB is empty, investor tools return an error +telling the agent to call `ownership_sync`. That tool is opt-in through a +server flag, because it downloads several MB. + +## Frontend: idx-api (later) + +An HTTP server over the same core, e.g. `GET /v1/stocks/{symbol}/quote`. Before +building it, decide whether it is self-hosted only. Publicly re-serving MSN, +Yahoo, or IDX data raises terms-of-use and rate-limit questions that a local CLI +does not. + +## Packaging + +```text +Cargo workspace (this repo) +├── crates/idx-core library, published to crates.io +├── crates/idx-cli binary `idx` (package name unchanged) +├── crates/idx-mcp binary `idx-mcp` +npm/ +├── idx-cli existing wrapper +└── idx-mcp same postinstall download + SHA256SUMS +``` + +- One version for every crate and npm package, released from one `vX.Y.Z` tag. + Release assets add `idx-mcp-` next to the existing `idx-`. +- Nix: `packages.default` (idx) and `packages.idx-mcp`; both run through + `nix flake check`. +- The names `idx-core`, `idx-mcp`, and `idx-api` were free on crates.io and npm + on 2026-10-14. +- Bundling later is a packaging choice: an `idx mcp` subcommand behind a Cargo + feature, or an npm package that installs both binaries. + +## Parity and Verification + +The refactor must keep every shipped command's output identical. Two gates +enforce that, and neither may be skipped or weakened to make a change pass. + +### Golden output tests (offline, in `nix flake check`) + +Before any refactor code lands, capture the current CLI as golden files: + +- every `stocks`, `ownership`, `config`, `cache`, `version`, and `completions` + command, in both `table` and `json`, against the mock provider fixtures and a + fixture-built ownership DB +- the documented error cases (invalid input, unsupported provider, offline + without cache, empty ownership DB) +- stored as `tests/golden/.{stdout,stderr,exit}` and compared byte for + byte by one data-driven test in `tests/golden.rs` + +Regenerating goldens (`UPDATE_GOLDEN=1`) is only allowed for an intentional, +reviewed output change, and the diff must be in the same commit with the reason +in the message. Phases 1-3 must not change any golden file. Phase 0 changes only +what it documents: default format resolution, JSON whitespace when piped, and +exit codes; JSON content and table bytes stay identical. + +`tests/cli.rs` stays the behavioral suite and must keep passing unchanged. + +### Live A/B check (opt-in, before merging the refactor) + +Fixtures cannot show that the new code still talks to real upstreams correctly. +`scripts/live-smoke.sh --compare ` runs every command with the +pre-refactor binary and the new binary back to back against live MSN, Yahoo, and +IDX, then compares: + +- exit codes and JSON structure (keys and types) for every command +- values for slow-moving data (profile, financials, earnings, ownership) +- volatile fields (price, change, volume, news items) for type only + +Any coding agent that changes CLI behavior must run the live check and report +its summary before calling the work done. IDX ownership cases need a network +that Cloudflare does not block; when they return `403`, report that explicitly +instead of treating it as a pass. + +## Migration Plan + +Work happens on the `refactor/idx-core` branch. Each phase is its own commit +series with `nix flake check -L` green; `main` receives the branch only after +every phase passes both parity gates. + +0. **Golden baseline.** Add `tests/golden.rs` and the golden files from the + current `main`, plus `live-smoke.sh --compare`. No source changes. +1. **Agent-native output.** `auto` output, compact JSON, `jsonl`, stdin `-`, + and exit-code classes. Touches only `config.rs`, `output/`, and `main.rs`. +2. **Workspace split, no behavior change.** Move code into `crates/idx-core` + and `crates/idx-cli`. +3. **Pull logic into core.** Move the table above into core, add `CachePolicy`, + `Fetched`, `CoreConfig`, and typed filter enums. +4. **idx-mcp.** Implement the tool table over stdio, with fixture-backed tests. +5. **Release.** Workspace publishing, npm `idx-mcp`, release assets, flake package. + +## Open Questions + +1. Are the exit-code classes right, and should `compare` with partial results + still exit `0`? +2. MCP tool granularity: is the grouped table above right, or should it be one + tool per CLI command? +3. Should `ownership_sync` be available to agents by default? +4. Which source should back IHSG index, breadth, and sector data, and should it + land before or after idx-mcp? +5. Is idx-api self-hosted only? From 29796f0f9d690a0892c1c49d93e12eaf15312c38 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 08:32:27 +0000 Subject: [PATCH 02/14] fix(financials): emit statement line items in stable order StatementSection.values was a HashMap, so `stocks financials -o json` printed line items in a different order on every run. Table output already sorted them alphabetically; JSON now matches via BTreeMap. Values are unchanged, and older cache entries deserialize as before. Found while capturing golden output, which needs deterministic bytes. Co-Authored-By: Claude Opus 5.5 --- src/api/msn/map.rs | 2 +- src/api/types.rs | 2 +- src/cli/stocks.rs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 912e4ff..2889168 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -727,7 +727,7 @@ fn parse_statement_section(section: &RawStatementSection) -> StatementSection { "underlyingInstrument", "id", ]; - let mut values = std::collections::HashMap::new(); + let mut values = std::collections::BTreeMap::new(); for (k, v) in §ion.data { if skip_keys.contains(&k.as_str()) { diff --git a/src/api/types.rs b/src/api/types.rs index 5055235..d68d52b 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -144,7 +144,7 @@ pub struct InstrumentInfo { #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatementSection { - pub values: std::collections::HashMap, + pub values: std::collections::BTreeMap, pub currency: String, pub report_date: String, pub end_date: String, diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 676e327..a596706 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1006,7 +1006,7 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::BTreeMap; use chrono::{Days, NaiveDate}; @@ -1217,19 +1217,19 @@ mod tests { name: "BCA".into(), }, balance_sheet: Some(StatementSection { - values: HashMap::from([("assets".into(), 1.0)]), + values: BTreeMap::from([("assets".into(), 1.0)]), currency: "IDR".into(), report_date: "2026-01-01".into(), end_date: "2025-12-31".into(), }), cash_flow: Some(StatementSection { - values: HashMap::from([("cash".into(), 2.0)]), + values: BTreeMap::from([("cash".into(), 2.0)]), currency: "IDR".into(), report_date: "2026-01-01".into(), end_date: "2025-12-31".into(), }), income_statement: Some(StatementSection { - values: HashMap::from([("income".into(), 3.0)]), + values: BTreeMap::from([("income".into(), 3.0)]), currency: "IDR".into(), report_date: "2026-01-01".into(), end_date: "2025-12-31".into(), From fe627496ddeadc8a9bc736027e2991d61c416b82 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 08:33:25 +0000 Subject: [PATCH 03/14] test: add golden output parity gate and live A/B compare Phase 0 of docs/DESIGN.md, before any refactor code: - tests/golden.rs runs every case in tests/golden/cases.toml (126 outputs across help, completions, config, cache, all stocks commands on MSN and Yahoo mocks, error paths, and ownership queries on a seeded fixture DB) and compares exit code, stdout, and stderr byte for byte. Regenerate only with UPDATE_GOLDEN=1 for a reviewed output change. - scripts/live-smoke.sh --compare runs each live case with both binaries and fails on differing exit codes, added/missing/retyped JSON keys, or changed values for slow-moving data. - Exclude golden files from the whitespace/EOF pre-commit fixers, which would otherwise rewrite pinned help output. Both gates were fault-injected: a one-word table change fails exactly the three history goldens, and a binary dropping profile.long_name fails only the profile live comparison. Co-Authored-By: Claude Opus 5.5 --- .pre-commit-config.yaml | 3 + AGENTS.md | 4 +- docs/DESIGN.md | 15 +- docs/SMOKE.md | 15 +- scripts/live-smoke.sh | 97 +- tests/golden.rs | 208 ++ tests/golden/cache-clear.json.golden | 7 + tests/golden/cache-clear.table.golden | 4 + tests/golden/cache-info-empty.json.golden | 10 + tests/golden/cache-info-empty.table.golden | 8 + tests/golden/cases.toml | 327 ++ tests/golden/completions-bash.golden | 2851 +++++++++++++++++ tests/golden/config-get.golden | 4 + tests/golden/config-init.golden | 4 + tests/golden/config-path.golden | 4 + tests/golden/config-set.golden | 4 + .../error-compare-total-failure.json.golden | 8 + .../error-compare-total-failure.table.golden | 4 + .../error-fetch-bing-unsupported.json.golden | 8 + .../error-fetch-bing-unsupported.table.golden | 4 + ...ror-industry-only-fundamentals.json.golden | 8 + ...or-industry-only-fundamentals.table.golden | 4 + .../golden/error-invalid-provider.json.golden | 8 + .../error-invalid-provider.table.golden | 4 + .../error-offline-cache-miss.json.golden | 8 + .../error-offline-cache-miss.table.golden | 4 + .../error-offline-no-cache-flags.json.golden | 8 + .../error-offline-no-cache-flags.table.golden | 4 + .../golden/error-provider-failure.json.golden | 8 + .../error-provider-failure.table.golden | 4 + tests/golden/error-screen-region.json.golden | 8 + tests/golden/error-screen-region.table.golden | 4 + .../error-usage-missing-symbol.json.golden | 9 + .../error-usage-missing-symbol.table.golden | 9 + ...rror-yahoo-profile-unsupported.json.golden | 8 + ...ror-yahoo-profile-unsupported.table.golden | 4 + tests/golden/help-cache.golden | 20 + tests/golden/help-config.golden | 22 + tests/golden/help-ownership.golden | 58 + tests/golden/help-root.golden | 40 + tests/golden/help-stocks.golden | 33 + tests/golden/ownership-changes.json.golden | 109 + tests/golden/ownership-changes.table.golden | 32 + .../ownership-concentration.json.golden | 45 + .../ownership-concentration.table.golden | 14 + .../ownership-cross-holders.json.golden | 175 + .../ownership-cross-holders.table.golden | 40 + tests/golden/ownership-entity.json.golden | 26 + tests/golden/ownership-entity.table.golden | 10 + tests/golden/ownership-flow.json.golden | 4 + tests/golden/ownership-flow.table.golden | 4 + tests/golden/ownership-graph-dot.json.golden | 58 + tests/golden/ownership-graph-dot.table.golden | 20 + tests/golden/ownership-graph.json.golden | 58 + tests/golden/ownership-graph.table.golden | 10 + .../ownership-import-archive-txt.json.golden | 11 + .../ownership-import-archive-txt.table.golden | 4 + .../golden/ownership-import-xlsx.json.golden | 11 + .../golden/ownership-import-xlsx.table.golden | 4 + .../ownership-releases-empty.json.golden | 4 + .../ownership-releases-empty.table.golden | 4 + tests/golden/ownership-releases.json.golden | 29 + tests/golden/ownership-releases.table.golden | 12 + .../golden/ownership-resolve-map.json.golden | 8 + .../golden/ownership-resolve-map.table.golden | 4 + .../ownership-resolve-merge.json.golden | 11 + .../ownership-resolve-merge.table.golden | 4 + .../ownership-resolve-unresolved.json.golden | 4 + .../ownership-resolve-unresolved.table.golden | 4 + tests/golden/ownership-search.json.golden | 11 + tests/golden/ownership-search.table.golden | 8 + .../golden/ownership-ticker-empty.json.golden | 8 + .../ownership-ticker-empty.table.golden | 4 + .../golden/ownership-ticker-ksei.json.golden | 67 + .../golden/ownership-ticker-ksei.table.golden | 16 + tests/golden/ownership-ticker.json.golden | 67 + tests/golden/ownership-ticker.table.golden | 16 + tests/golden/stocks-compare.json.golden | 81 + tests/golden/stocks-compare.table.golden | 23 + ...tocks-earnings-forecast-annual.json.golden | 22 + ...ocks-earnings-forecast-annual.table.golden | 10 + ...cks-earnings-history-quarterly.json.golden | 22 + ...ks-earnings-history-quarterly.table.golden | 9 + tests/golden/stocks-earnings.json.golden | 56 + tests/golden/stocks-earnings.table.golden | 20 + .../stocks-financials-income.json.golden | 21 + .../stocks-financials-income.table.golden | 12 + tests/golden/stocks-financials.json.golden | 36 + tests/golden/stocks-financials.table.golden | 28 + tests/golden/stocks-fundamental.json.golden | 41 + tests/golden/stocks-fundamental.table.golden | 49 + tests/golden/stocks-growth.json.golden | 12 + tests/golden/stocks-growth.table.golden | 13 + tests/golden/stocks-history-msn.json.golden | 29 + tests/golden/stocks-history-msn.table.golden | 13 + tests/golden/stocks-history.json.golden | 29 + tests/golden/stocks-history.table.golden | 14 + tests/golden/stocks-insights.json.golden | 16 + tests/golden/stocks-insights.table.golden | 10 + tests/golden/stocks-news.json.golden | 15 + tests/golden/stocks-news.table.golden | 8 + tests/golden/stocks-profile.json.golden | 27 + tests/golden/stocks-profile.table.golden | 28 + tests/golden/stocks-quote-multi.json.golden | 35 + tests/golden/stocks-quote-multi.table.golden | 10 + .../stocks-quote-offline-warm.json.golden | 20 + .../stocks-quote-offline-warm.table.golden | 8 + tests/golden/stocks-quote.json.golden | 20 + tests/golden/stocks-quote.table.golden | 8 + tests/golden/stocks-risk.json.golden | 12 + tests/golden/stocks-risk.table.golden | 15 + .../stocks-screen-high-volume.json.golden | 35 + .../stocks-screen-high-volume.table.golden | 10 + .../stocks-screen-large-cap.json.golden | 35 + .../stocks-screen-large-cap.table.golden | 10 + tests/golden/stocks-screen-worst.json.golden | 35 + tests/golden/stocks-screen-worst.table.golden | 10 + tests/golden/stocks-screen.json.golden | 35 + tests/golden/stocks-screen.table.golden | 10 + tests/golden/stocks-sentiment.json.golden | 20 + tests/golden/stocks-sentiment.table.golden | 10 + tests/golden/stocks-technical.json.golden | 28 + tests/golden/stocks-technical.table.golden | 26 + .../stocks-valuation-nonfinite.json.golden | 19 + .../stocks-valuation-nonfinite.table.golden | 21 + tests/golden/stocks-valuation.json.golden | 19 + tests/golden/stocks-valuation.table.golden | 21 + tests/golden/yahoo-fundamental.json.golden | 41 + tests/golden/yahoo-fundamental.table.golden | 49 + tests/golden/yahoo-history.json.golden | 29 + tests/golden/yahoo-history.table.golden | 13 + tests/golden/yahoo-quote.json.golden | 20 + tests/golden/yahoo-quote.table.golden | 8 + 133 files changed, 6033 insertions(+), 23 deletions(-) create mode 100644 tests/golden.rs create mode 100644 tests/golden/cache-clear.json.golden create mode 100644 tests/golden/cache-clear.table.golden create mode 100644 tests/golden/cache-info-empty.json.golden create mode 100644 tests/golden/cache-info-empty.table.golden create mode 100644 tests/golden/cases.toml create mode 100644 tests/golden/completions-bash.golden create mode 100644 tests/golden/config-get.golden create mode 100644 tests/golden/config-init.golden create mode 100644 tests/golden/config-path.golden create mode 100644 tests/golden/config-set.golden create mode 100644 tests/golden/error-compare-total-failure.json.golden create mode 100644 tests/golden/error-compare-total-failure.table.golden create mode 100644 tests/golden/error-fetch-bing-unsupported.json.golden create mode 100644 tests/golden/error-fetch-bing-unsupported.table.golden create mode 100644 tests/golden/error-industry-only-fundamentals.json.golden create mode 100644 tests/golden/error-industry-only-fundamentals.table.golden create mode 100644 tests/golden/error-invalid-provider.json.golden create mode 100644 tests/golden/error-invalid-provider.table.golden create mode 100644 tests/golden/error-offline-cache-miss.json.golden create mode 100644 tests/golden/error-offline-cache-miss.table.golden create mode 100644 tests/golden/error-offline-no-cache-flags.json.golden create mode 100644 tests/golden/error-offline-no-cache-flags.table.golden create mode 100644 tests/golden/error-provider-failure.json.golden create mode 100644 tests/golden/error-provider-failure.table.golden create mode 100644 tests/golden/error-screen-region.json.golden create mode 100644 tests/golden/error-screen-region.table.golden create mode 100644 tests/golden/error-usage-missing-symbol.json.golden create mode 100644 tests/golden/error-usage-missing-symbol.table.golden create mode 100644 tests/golden/error-yahoo-profile-unsupported.json.golden create mode 100644 tests/golden/error-yahoo-profile-unsupported.table.golden create mode 100644 tests/golden/help-cache.golden create mode 100644 tests/golden/help-config.golden create mode 100644 tests/golden/help-ownership.golden create mode 100644 tests/golden/help-root.golden create mode 100644 tests/golden/help-stocks.golden create mode 100644 tests/golden/ownership-changes.json.golden create mode 100644 tests/golden/ownership-changes.table.golden create mode 100644 tests/golden/ownership-concentration.json.golden create mode 100644 tests/golden/ownership-concentration.table.golden create mode 100644 tests/golden/ownership-cross-holders.json.golden create mode 100644 tests/golden/ownership-cross-holders.table.golden create mode 100644 tests/golden/ownership-entity.json.golden create mode 100644 tests/golden/ownership-entity.table.golden create mode 100644 tests/golden/ownership-flow.json.golden create mode 100644 tests/golden/ownership-flow.table.golden create mode 100644 tests/golden/ownership-graph-dot.json.golden create mode 100644 tests/golden/ownership-graph-dot.table.golden create mode 100644 tests/golden/ownership-graph.json.golden create mode 100644 tests/golden/ownership-graph.table.golden create mode 100644 tests/golden/ownership-import-archive-txt.json.golden create mode 100644 tests/golden/ownership-import-archive-txt.table.golden create mode 100644 tests/golden/ownership-import-xlsx.json.golden create mode 100644 tests/golden/ownership-import-xlsx.table.golden create mode 100644 tests/golden/ownership-releases-empty.json.golden create mode 100644 tests/golden/ownership-releases-empty.table.golden create mode 100644 tests/golden/ownership-releases.json.golden create mode 100644 tests/golden/ownership-releases.table.golden create mode 100644 tests/golden/ownership-resolve-map.json.golden create mode 100644 tests/golden/ownership-resolve-map.table.golden create mode 100644 tests/golden/ownership-resolve-merge.json.golden create mode 100644 tests/golden/ownership-resolve-merge.table.golden create mode 100644 tests/golden/ownership-resolve-unresolved.json.golden create mode 100644 tests/golden/ownership-resolve-unresolved.table.golden create mode 100644 tests/golden/ownership-search.json.golden create mode 100644 tests/golden/ownership-search.table.golden create mode 100644 tests/golden/ownership-ticker-empty.json.golden create mode 100644 tests/golden/ownership-ticker-empty.table.golden create mode 100644 tests/golden/ownership-ticker-ksei.json.golden create mode 100644 tests/golden/ownership-ticker-ksei.table.golden create mode 100644 tests/golden/ownership-ticker.json.golden create mode 100644 tests/golden/ownership-ticker.table.golden create mode 100644 tests/golden/stocks-compare.json.golden create mode 100644 tests/golden/stocks-compare.table.golden create mode 100644 tests/golden/stocks-earnings-forecast-annual.json.golden create mode 100644 tests/golden/stocks-earnings-forecast-annual.table.golden create mode 100644 tests/golden/stocks-earnings-history-quarterly.json.golden create mode 100644 tests/golden/stocks-earnings-history-quarterly.table.golden create mode 100644 tests/golden/stocks-earnings.json.golden create mode 100644 tests/golden/stocks-earnings.table.golden create mode 100644 tests/golden/stocks-financials-income.json.golden create mode 100644 tests/golden/stocks-financials-income.table.golden create mode 100644 tests/golden/stocks-financials.json.golden create mode 100644 tests/golden/stocks-financials.table.golden create mode 100644 tests/golden/stocks-fundamental.json.golden create mode 100644 tests/golden/stocks-fundamental.table.golden create mode 100644 tests/golden/stocks-growth.json.golden create mode 100644 tests/golden/stocks-growth.table.golden create mode 100644 tests/golden/stocks-history-msn.json.golden create mode 100644 tests/golden/stocks-history-msn.table.golden create mode 100644 tests/golden/stocks-history.json.golden create mode 100644 tests/golden/stocks-history.table.golden create mode 100644 tests/golden/stocks-insights.json.golden create mode 100644 tests/golden/stocks-insights.table.golden create mode 100644 tests/golden/stocks-news.json.golden create mode 100644 tests/golden/stocks-news.table.golden create mode 100644 tests/golden/stocks-profile.json.golden create mode 100644 tests/golden/stocks-profile.table.golden create mode 100644 tests/golden/stocks-quote-multi.json.golden create mode 100644 tests/golden/stocks-quote-multi.table.golden create mode 100644 tests/golden/stocks-quote-offline-warm.json.golden create mode 100644 tests/golden/stocks-quote-offline-warm.table.golden create mode 100644 tests/golden/stocks-quote.json.golden create mode 100644 tests/golden/stocks-quote.table.golden create mode 100644 tests/golden/stocks-risk.json.golden create mode 100644 tests/golden/stocks-risk.table.golden create mode 100644 tests/golden/stocks-screen-high-volume.json.golden create mode 100644 tests/golden/stocks-screen-high-volume.table.golden create mode 100644 tests/golden/stocks-screen-large-cap.json.golden create mode 100644 tests/golden/stocks-screen-large-cap.table.golden create mode 100644 tests/golden/stocks-screen-worst.json.golden create mode 100644 tests/golden/stocks-screen-worst.table.golden create mode 100644 tests/golden/stocks-screen.json.golden create mode 100644 tests/golden/stocks-screen.table.golden create mode 100644 tests/golden/stocks-sentiment.json.golden create mode 100644 tests/golden/stocks-sentiment.table.golden create mode 100644 tests/golden/stocks-technical.json.golden create mode 100644 tests/golden/stocks-technical.table.golden create mode 100644 tests/golden/stocks-valuation-nonfinite.json.golden create mode 100644 tests/golden/stocks-valuation-nonfinite.table.golden create mode 100644 tests/golden/stocks-valuation.json.golden create mode 100644 tests/golden/stocks-valuation.table.golden create mode 100644 tests/golden/yahoo-fundamental.json.golden create mode 100644 tests/golden/yahoo-fundamental.table.golden create mode 100644 tests/golden/yahoo-history.json.golden create mode 100644 tests/golden/yahoo-history.table.golden create mode 100644 tests/golden/yahoo-quote.json.golden create mode 100644 tests/golden/yahoo-quote.table.golden diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9b1d3c..dfb9713 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,11 @@ repos: - repo: builtin hooks: + # Golden files pin exact CLI output, including trailing spaces. - id: trailing-whitespace + exclude: ^tests/golden/.*\.golden$ - id: end-of-file-fixer + exclude: ^tests/golden/.*\.golden$ - id: check-merge-conflict - repo: local diff --git a/AGENTS.md b/AGENTS.md index b137161..70b7779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ 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/ +tests/ cli.rs (behavior), golden.rs + golden/ (output parity), fixtures/ scripts/ smoke, installer, and ownership snapshot publisher ``` @@ -42,5 +42,5 @@ Docs: ```bash nix flake check -L # canonical gate (fmt, clippy, tests, package) nix develop --command cargo test # focused loop -scripts/live-smoke.sh # opt-in live provider drift check +scripts/live-smoke.sh --compare # live drift check, A/B against an older build ``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1125aae..b126234 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -162,7 +162,8 @@ is another 20-30% smaller than pretty JSON. - stdout carries only data; diagnostics and warnings go to stderr. Already true. - Errors in `json`/`jsonl` mode are one JSON object on stderr with stdout empty. Already true. -- No ANSI color when stdout is not a terminal. +- No ANSI color when stdout is not a terminal. Today some table headers emit + bold codes even when piped. - Commands that take symbols accept `-` to read them from stdin, one per line or whitespace-separated: `idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare -` @@ -264,14 +265,16 @@ Before any refactor code lands, capture the current CLI as golden files: fixture-built ownership DB - the documented error cases (invalid input, unsupported provider, offline without cache, empty ownership DB) -- stored as `tests/golden/.{stdout,stderr,exit}` and compared byte for - byte by one data-driven test in `tests/golden.rs` +- cases listed in `tests/golden/cases.toml`; each result stored as + `tests/golden/..golden` (exit code, stdout, stderr) and + compared byte for byte by one data-driven test in `tests/golden.rs` Regenerating goldens (`UPDATE_GOLDEN=1`) is only allowed for an intentional, reviewed output change, and the diff must be in the same commit with the reason -in the message. Phases 1-3 must not change any golden file. Phase 0 changes only -what it documents: default format resolution, JSON whitespace when piped, and -exit codes; JSON content and table bytes stay identical. +in the message. Phases 2-4 must not change any golden file. Phase 1 changes only +what it documents: default format resolution, JSON whitespace when piped, ANSI +color when piped (table output emits bold headers into pipes today), and exit +codes; JSON content and table text stay identical. `tests/cli.rs` stays the behavioral suite and must keep passing unchanged. diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 562f8ee..b8f97d3 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -8,7 +8,7 @@ offline with pinned tools: | Check | Covers | | --- | --- | | `fmt`, `clippy` | formatting and lints (`--all-targets`) | -| `test` | unit/module tests and `tests/cli.rs` CLI integration tests | +| `test` | unit/module tests, `tests/cli.rs` CLI integration tests, and `tests/golden.rs` output parity | | `default`, `package` | release build and `cargo package` verification | | `packaged-cli` | the wrapped Nix binary starts, emits JSON, and generates completions | | `ownership-publisher`, `publisher-test` | packaged publisher entry point and its regression scenarios | @@ -19,6 +19,12 @@ fixture-backed mock providers (`IDX_USE_MOCK_PROVIDER=1`) and covers output contracts, cache/offline transitions, provider routing, error codes, ownership imports and queries, and snapshot sync. +`tests/golden.rs` pins the exact output (exit code, stdout, stderr) of every +command listed in `tests/golden/cases.toml`, in table and JSON. A mismatch means +user-visible output changed. Regenerate with +`UPDATE_GOLDEN=1 cargo test --test golden` only when the change is intended, +and commit the golden diff with the reason. + For a focused loop, run one check or filter Cargo tests: ```bash @@ -39,6 +45,7 @@ validates each JSON output shape. It is opt-in and never part of the gate. nix develop --command scripts/live-smoke.sh # stocks + ownership nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx +nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B against an older build scripts/live-smoke.sh --dry-run ``` @@ -48,6 +55,12 @@ scripts/live-smoke.sh --dry-run DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts. +`--compare ` runs every case with both binaries back to back, each +with its own config and cache, and fails on a different exit code, any added, +missing, or retyped JSON key, or changed values for `profile`, `financials`, +`earnings`, and ownership results. Build the old side with, for example, +`nix build "git+file://$PWD?ref=main" -o old`. + Each run isolates config, cache, and data under `tmp/live-smoke//` and keeps per-case `.stdout`, `.stderr`, and `.validation` logs there. diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 1bd4d3f..2aefa78 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -1,8 +1,12 @@ #!/usr/bin/env bash # Live provider drift check: runs shipped commands against real MSN, Yahoo, # and IDX endpoints and validates the JSON output shape. Deterministic CLI -# behavior is covered by tests/cli.rs; this only answers "do the upstreams -# still look the way our parsers expect?" +# behavior is covered by tests/cli.rs and tests/golden.rs; this answers "do the +# upstreams still look the way our parsers expect?" +# +# With --compare , every case also runs the old binary back to back +# and fails on any difference in exit code or JSON structure, and on any value +# difference for slow-moving data (see "Live A/B check" in docs/DESIGN.md). set -u set -o pipefail @@ -10,6 +14,7 @@ set -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SYMBOL="BBCA" BIN_PATH="" +COMPARE_BIN="" DRY_RUN=0 declare -a GROUPS_SELECTED=() declare -a FAILURES=() @@ -24,6 +29,7 @@ Options: --group Restrict to a group. Repeatable. Default: both --symbol Primary ticker. Default: BBCA --bin Use an existing idx binary (skips cargo build) + --compare Also run an older idx binary and diff the results --dry-run Print cases without running them --help Show this help EOF @@ -34,6 +40,7 @@ while [[ $# -gt 0 ]]; do --group) GROUPS_SELECTED+=("${2:-}"); shift 2 ;; --symbol) SYMBOL="${2:-}"; shift 2 ;; --bin) BIN_PATH="${2:-}"; shift 2 ;; + --compare) COMPARE_BIN="${2:-}"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --help|-h) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; @@ -58,14 +65,36 @@ selected() { WORKDIR="$ROOT_DIR/tmp/live-smoke/$(date +%Y%m%d-%H%M%S)" LOG_DIR="$WORKDIR/logs" -BASE_ENV=( - "XDG_CONFIG_HOME=$WORKDIR/config" - "XDG_CACHE_HOME=$WORKDIR/cache" - "XDG_DATA_HOME=$WORKDIR/data" - "IDX_PROVIDER=msn" - "IDX_HISTORY_PROVIDER=auto" - "IDX_NO_COLOR=1" -) +COMMON_ENV=("IDX_PROVIDER=msn" "IDX_HISTORY_PROVIDER=auto" "IDX_NO_COLOR=1") + +# Each binary gets its own config, cache, and data so both really fetch. +side_env() { + local home="$WORKDIR/$1" + printf '%s\n' "XDG_CONFIG_HOME=$home/config" "XDG_CACHE_HOME=$home/cache" \ + "XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}" +} + +# Values of these commands should not move between two back-to-back runs. +stable_values() { + case "$1" in + "stocks profile "*|"stocks financials "*|"stocks earnings "*|"ownership ticker "*|"ownership releases") return 0 ;; + *) return 1 ;; + esac +} + +# Key paths (array indices collapsed) that disappeared or appeared, and keys +# whose non-null type changed. Empty lists mean the structure matches. +# shellcheck disable=SC2016 +SHAPE_DIFF=' +def norm: map(if type == "number" then "*" else . end) | join("."); +def keyset: [paths | norm] | unique; +def types: [paths(scalars) as $p | select(getpath($p) != null) + | {k: ($p | norm), t: (getpath($p) | type)}] + | group_by(.k) | map({key: .[0].k, value: (map(.t) | unique)}) | from_entries; +($old[0] | keyset) as $ko | ($new[0] | keyset) as $kn +| ($old[0] | types) as $to | ($new[0] | types) as $tn +| {missing: ($ko - $kn), added: ($kn - $ko), + type_changed: [$to | keys[] as $k | select($tn[$k] != null and $tn[$k] != $to[$k]) | $k]}' # jq filter describing the JSON shape each command must keep. contract() { @@ -103,8 +132,16 @@ contract() { esac } +run_bin() { + local side="$1" bin="$2" out="$3" + shift 3 + local -a envs + mapfile -t envs < <(side_env "$side") + env "${envs[@]}" "$bin" -o json "$@" >"$out.stdout" 2>"$out.stderr" +} + run_case() { - local name="$1" cmd="$2" filter log + local name="$1" cmd="$2" filter log status old_status diff local -a args TOTAL=$((TOTAL + 1)) printf '[%02d] %s\n' "$TOTAL" "$name" @@ -115,8 +152,19 @@ run_case() { log="$LOG_DIR/$(printf '%02d' "$TOTAL")_$name" read -r -a args <<< "$cmd" - if ! env "${BASE_ENV[@]}" "$BIN_PATH" -o json "${args[@]}" >"$log.stdout" 2>"$log.stderr"; then - FAILURES+=("$name: non-zero exit (see $log.stderr)") + run_bin new "$BIN_PATH" "$log" "${args[@]}" + status=$? + if [[ -n "$COMPARE_BIN" ]]; then + run_bin old "$COMPARE_BIN" "$log.old" "${args[@]}" + old_status=$? + if (( status != old_status )); then + FAILURES+=("$name: exit $status, old binary exited $old_status (see $log.stderr, $log.old.stderr)") + echo " FAIL exit differs from old binary" + return + fi + fi + if (( status != 0 )); then + FAILURES+=("$name: exit $status (see $log.stderr)") echo " FAIL exit" return fi @@ -126,6 +174,22 @@ run_case() { echo " FAIL shape" return fi + if [[ -n "$COMPARE_BIN" ]]; then + diff="$(jq -n --slurpfile old "$log.old.stdout" --slurpfile new "$log.stdout" "$SHAPE_DIFF")" + if [[ "$(jq -c '[.[] | length] | add' <<< "$diff")" != 0 ]]; then + printf '%s\n' "$diff" >"$log.diff" + FAILURES+=("$name: JSON structure differs from old binary (see $log.diff)") + echo " FAIL structure differs" + return + fi + if stable_values "$cmd" && ! cmp -s \ + <(jq -S 'del(.. | .imported_at?)' "$log.old.stdout") \ + <(jq -S 'del(.. | .imported_at?)' "$log.stdout"); then + FAILURES+=("$name: values differ from old binary (diff $log.old.stdout $log.stdout)") + echo " FAIL values differ" + return + fi + fi PASSED=$((PASSED + 1)) echo " PASS" } @@ -137,7 +201,10 @@ if (( ! DRY_RUN )); then BIN_PATH="$ROOT_DIR/target/debug/idx" (cd "$ROOT_DIR" && cargo build --quiet --bin idx) || { echo "cargo build failed" >&2; exit 1; } fi - env "${BASE_ENV[@]}" "$BIN_PATH" config set ownership.db_path "$WORKDIR/ownership.db" >/dev/null + if [[ -n "$COMPARE_BIN" && ! -x "$COMPARE_BIN" ]]; then + echo "--compare binary not found or not executable: $COMPARE_BIN" >&2 + exit 2 + fi fi if selected stocks; then @@ -158,10 +225,12 @@ if selected ownership; then url="$(jq -r '.[0].pdf_url' "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout")" run_case import "ownership import --url $url" run_case releases "ownership releases" + run_case ticker "ownership ticker $SYMBOL" fi fi (( DRY_RUN )) && { echo "dry-run: $TOTAL case(s)"; exit 0; } +[[ -n "$COMPARE_BIN" ]] && printf '\ncompared against: %s\n' "$COMPARE_BIN" printf '\nsummary: %d passed, %d failed\nlogs: %s\n' "$PASSED" "$((TOTAL - PASSED))" "$LOG_DIR" if (( ${#FAILURES[@]} > 0 )); then printf ' - %s\n' "${FAILURES[@]}" diff --git a/tests/golden.rs b/tests/golden.rs new file mode 100644 index 0000000..884abf2 --- /dev/null +++ b/tests/golden.rs @@ -0,0 +1,208 @@ +//! 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/.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::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +use serde::Deserialize; + +const IDX: &str = env!("CARGO_BIN_EXE_idx"); +const REPO: &str = env!("CARGO_MANIFEST_DIR"); + +#[derive(Deserialize)] +struct CaseFile { + case: Vec, +} + +#[derive(Deserialize)] +struct Case { + name: String, + args: Vec, + /// Output formats to expand the case into (`-o ` is prepended and + /// `.` appended to the name). Empty means run the args as given. + #[serde(default = "default_formats")] + formats: Vec, + #[serde(default)] + env: BTreeMap, + /// 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>, +} + +fn default_formats() -> Vec { + vec!["table".into(), "json".into()] +} + +fn golden_dir() -> PathBuf { + Path::new(REPO).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) -> std::process::Output { + let mut cmd = Command::new(IDX); + cmd.current_dir(REPO).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); + cmd.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 = 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( + Path::new(REPO).join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"), + &xlsx, + ) + .expect("copy xlsx fixture"); + let inputs = [ + xlsx, + Path::new(REPO).join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"), + Path::new(REPO).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(), "") + .replace(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)> = 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 actual = render(&root, &idx(&root, &args, &case.env)); + 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") + ); +} diff --git a/tests/golden/cache-clear.json.golden b/tests/golden/cache-clear.json.golden new file mode 100644 index 0000000..ac869ff --- /dev/null +++ b/tests/golden/cache-clear.json.golden @@ -0,0 +1,7 @@ +exit: 0 +--- stdout +{ + "failed": [], + "removed": 0 +} +--- stderr diff --git a/tests/golden/cache-clear.table.golden b/tests/golden/cache-clear.table.golden new file mode 100644 index 0000000..57c0377 --- /dev/null +++ b/tests/golden/cache-clear.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +cleared 0 files +--- stderr diff --git a/tests/golden/cache-info-empty.json.golden b/tests/golden/cache-info-empty.json.golden new file mode 100644 index 0000000..eb5a389 --- /dev/null +++ b/tests/golden/cache-info-empty.json.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout +{ + "files": 0, + "newest": null, + "oldest": null, + "path": "/cache/idx", + "size_bytes": 0 +} +--- stderr diff --git a/tests/golden/cache-info-empty.table.golden b/tests/golden/cache-info-empty.table.golden new file mode 100644 index 0000000..4f864ba --- /dev/null +++ b/tests/golden/cache-info-empty.table.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +path: /cache/idx +files: 0 +size_bytes: 0 +oldest: - +newest: - +--- stderr diff --git a/tests/golden/cases.toml b/tests/golden/cases.toml new file mode 100644 index 0000000..f81a4f0 --- /dev/null +++ b/tests/golden/cases.toml @@ -0,0 +1,327 @@ +# Golden output cases for tests/golden.rs. +# +# Each case runs once per entry in `formats` (default: table and json) with +# `-o ` prepended; `formats = []` runs the args as given. 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. +# +# `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 = "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" +args = ["stocks", "quote", "BBCA,BBRI"] + +[[case]] +name = "stocks-history" +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" +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 = "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" +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" +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 diff --git a/tests/golden/completions-bash.golden b/tests/golden/completions-bash.golden new file mode 100644 index 0000000..5cee459 --- /dev/null +++ b/tests/golden/completions-bash.golden @@ -0,0 +1,2851 @@ +exit: 0 +--- stdout +_idx() { + local i cur prev opts cmd + COMPREPLY=() + if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then + cur="$2" + else + cur="${COMP_WORDS[COMP_CWORD]}" + fi + prev="$3" + cmd="" + opts="" + + for i in "${COMP_WORDS[@]:0:COMP_CWORD}" + do + case "${cmd},${i}" in + ",$1") + cmd="idx" + ;; + idx,cache) + cmd="idx__cache" + ;; + idx,completions) + cmd="idx__completions" + ;; + idx,config) + cmd="idx__config" + ;; + idx,help) + cmd="idx__help" + ;; + idx,ownership) + cmd="idx__ownership" + ;; + idx,stocks) + cmd="idx__stocks" + ;; + idx,version) + cmd="idx__version" + ;; + idx__cache,clear) + cmd="idx__cache__clear" + ;; + idx__cache,help) + cmd="idx__cache__help" + ;; + idx__cache,info) + cmd="idx__cache__info" + ;; + idx__cache__help,clear) + cmd="idx__cache__help__clear" + ;; + idx__cache__help,help) + cmd="idx__cache__help__help" + ;; + idx__cache__help,info) + cmd="idx__cache__help__info" + ;; + idx__config,get) + cmd="idx__config__get" + ;; + idx__config,help) + cmd="idx__config__help" + ;; + idx__config,init) + cmd="idx__config__init" + ;; + idx__config,path) + cmd="idx__config__path" + ;; + idx__config,set) + cmd="idx__config__set" + ;; + idx__config__help,get) + cmd="idx__config__help__get" + ;; + idx__config__help,help) + cmd="idx__config__help__help" + ;; + idx__config__help,init) + cmd="idx__config__help__init" + ;; + idx__config__help,path) + cmd="idx__config__help__path" + ;; + idx__config__help,set) + cmd="idx__config__help__set" + ;; + idx__help,cache) + cmd="idx__help__cache" + ;; + idx__help,completions) + cmd="idx__help__completions" + ;; + idx__help,config) + cmd="idx__help__config" + ;; + idx__help,help) + cmd="idx__help__help" + ;; + idx__help,ownership) + cmd="idx__help__ownership" + ;; + idx__help,stocks) + cmd="idx__help__stocks" + ;; + idx__help,version) + cmd="idx__help__version" + ;; + idx__help__cache,clear) + cmd="idx__help__cache__clear" + ;; + idx__help__cache,info) + cmd="idx__help__cache__info" + ;; + idx__help__config,get) + cmd="idx__help__config__get" + ;; + idx__help__config,init) + cmd="idx__help__config__init" + ;; + idx__help__config,path) + cmd="idx__help__config__path" + ;; + idx__help__config,set) + cmd="idx__help__config__set" + ;; + idx__help__ownership,changes) + cmd="idx__help__ownership__changes" + ;; + idx__help__ownership,concentration) + cmd="idx__help__ownership__concentration" + ;; + idx__help__ownership,cross-holders) + cmd="idx__help__ownership__cross__holders" + ;; + idx__help__ownership,discover) + cmd="idx__help__ownership__discover" + ;; + idx__help__ownership,entity) + cmd="idx__help__ownership__entity" + ;; + idx__help__ownership,flow) + cmd="idx__help__ownership__flow" + ;; + idx__help__ownership,graph) + cmd="idx__help__ownership__graph" + ;; + idx__help__ownership,import) + cmd="idx__help__ownership__import" + ;; + idx__help__ownership,releases) + cmd="idx__help__ownership__releases" + ;; + idx__help__ownership,resolve) + cmd="idx__help__ownership__resolve" + ;; + idx__help__ownership,search) + cmd="idx__help__ownership__search" + ;; + idx__help__ownership,sync) + cmd="idx__help__ownership__sync" + ;; + idx__help__ownership,ticker) + cmd="idx__help__ownership__ticker" + ;; + idx__help__ownership__resolve,map) + cmd="idx__help__ownership__resolve__map" + ;; + idx__help__ownership__resolve,merge) + cmd="idx__help__ownership__resolve__merge" + ;; + idx__help__ownership__resolve,unresolved) + cmd="idx__help__ownership__resolve__unresolved" + ;; + idx__help__stocks,compare) + cmd="idx__help__stocks__compare" + ;; + idx__help__stocks,earnings) + cmd="idx__help__stocks__earnings" + ;; + idx__help__stocks,financials) + cmd="idx__help__stocks__financials" + ;; + idx__help__stocks,fundamental) + cmd="idx__help__stocks__fundamental" + ;; + idx__help__stocks,growth) + cmd="idx__help__stocks__growth" + ;; + idx__help__stocks,history) + cmd="idx__help__stocks__history" + ;; + idx__help__stocks,insights) + cmd="idx__help__stocks__insights" + ;; + idx__help__stocks,news) + cmd="idx__help__stocks__news" + ;; + idx__help__stocks,profile) + cmd="idx__help__stocks__profile" + ;; + idx__help__stocks,quote) + cmd="idx__help__stocks__quote" + ;; + idx__help__stocks,risk) + cmd="idx__help__stocks__risk" + ;; + idx__help__stocks,screen) + cmd="idx__help__stocks__screen" + ;; + idx__help__stocks,sentiment) + cmd="idx__help__stocks__sentiment" + ;; + idx__help__stocks,technical) + cmd="idx__help__stocks__technical" + ;; + idx__help__stocks,valuation) + cmd="idx__help__stocks__valuation" + ;; + idx__ownership,changes) + cmd="idx__ownership__changes" + ;; + idx__ownership,concentration) + cmd="idx__ownership__concentration" + ;; + idx__ownership,cross-holders) + cmd="idx__ownership__cross__holders" + ;; + idx__ownership,discover) + cmd="idx__ownership__discover" + ;; + idx__ownership,entity) + cmd="idx__ownership__entity" + ;; + idx__ownership,flow) + cmd="idx__ownership__flow" + ;; + idx__ownership,graph) + cmd="idx__ownership__graph" + ;; + idx__ownership,help) + cmd="idx__ownership__help" + ;; + idx__ownership,import) + cmd="idx__ownership__import" + ;; + idx__ownership,releases) + cmd="idx__ownership__releases" + ;; + idx__ownership,resolve) + cmd="idx__ownership__resolve" + ;; + idx__ownership,search) + cmd="idx__ownership__search" + ;; + idx__ownership,sync) + cmd="idx__ownership__sync" + ;; + idx__ownership,ticker) + cmd="idx__ownership__ticker" + ;; + idx__ownership__help,changes) + cmd="idx__ownership__help__changes" + ;; + idx__ownership__help,concentration) + cmd="idx__ownership__help__concentration" + ;; + idx__ownership__help,cross-holders) + cmd="idx__ownership__help__cross__holders" + ;; + idx__ownership__help,discover) + cmd="idx__ownership__help__discover" + ;; + idx__ownership__help,entity) + cmd="idx__ownership__help__entity" + ;; + idx__ownership__help,flow) + cmd="idx__ownership__help__flow" + ;; + idx__ownership__help,graph) + cmd="idx__ownership__help__graph" + ;; + idx__ownership__help,help) + cmd="idx__ownership__help__help" + ;; + idx__ownership__help,import) + cmd="idx__ownership__help__import" + ;; + idx__ownership__help,releases) + cmd="idx__ownership__help__releases" + ;; + idx__ownership__help,resolve) + cmd="idx__ownership__help__resolve" + ;; + idx__ownership__help,search) + cmd="idx__ownership__help__search" + ;; + idx__ownership__help,sync) + cmd="idx__ownership__help__sync" + ;; + idx__ownership__help,ticker) + cmd="idx__ownership__help__ticker" + ;; + idx__ownership__help__resolve,map) + cmd="idx__ownership__help__resolve__map" + ;; + idx__ownership__help__resolve,merge) + cmd="idx__ownership__help__resolve__merge" + ;; + idx__ownership__help__resolve,unresolved) + cmd="idx__ownership__help__resolve__unresolved" + ;; + idx__ownership__resolve,help) + cmd="idx__ownership__resolve__help" + ;; + idx__ownership__resolve,map) + cmd="idx__ownership__resolve__map" + ;; + idx__ownership__resolve,merge) + cmd="idx__ownership__resolve__merge" + ;; + idx__ownership__resolve,unresolved) + cmd="idx__ownership__resolve__unresolved" + ;; + idx__ownership__resolve__help,help) + cmd="idx__ownership__resolve__help__help" + ;; + idx__ownership__resolve__help,map) + cmd="idx__ownership__resolve__help__map" + ;; + idx__ownership__resolve__help,merge) + cmd="idx__ownership__resolve__help__merge" + ;; + idx__ownership__resolve__help,unresolved) + cmd="idx__ownership__resolve__help__unresolved" + ;; + idx__stocks,compare) + cmd="idx__stocks__compare" + ;; + idx__stocks,earnings) + cmd="idx__stocks__earnings" + ;; + idx__stocks,financials) + cmd="idx__stocks__financials" + ;; + idx__stocks,fundamental) + cmd="idx__stocks__fundamental" + ;; + idx__stocks,growth) + cmd="idx__stocks__growth" + ;; + idx__stocks,help) + cmd="idx__stocks__help" + ;; + idx__stocks,history) + cmd="idx__stocks__history" + ;; + idx__stocks,insights) + cmd="idx__stocks__insights" + ;; + idx__stocks,news) + cmd="idx__stocks__news" + ;; + idx__stocks,profile) + cmd="idx__stocks__profile" + ;; + idx__stocks,quote) + cmd="idx__stocks__quote" + ;; + idx__stocks,risk) + cmd="idx__stocks__risk" + ;; + idx__stocks,screen) + cmd="idx__stocks__screen" + ;; + idx__stocks,sentiment) + cmd="idx__stocks__sentiment" + ;; + idx__stocks,technical) + cmd="idx__stocks__technical" + ;; + idx__stocks,valuation) + cmd="idx__stocks__valuation" + ;; + idx__stocks__help,compare) + cmd="idx__stocks__help__compare" + ;; + idx__stocks__help,earnings) + cmd="idx__stocks__help__earnings" + ;; + idx__stocks__help,financials) + cmd="idx__stocks__help__financials" + ;; + idx__stocks__help,fundamental) + cmd="idx__stocks__help__fundamental" + ;; + idx__stocks__help,growth) + cmd="idx__stocks__help__growth" + ;; + idx__stocks__help,help) + cmd="idx__stocks__help__help" + ;; + idx__stocks__help,history) + cmd="idx__stocks__help__history" + ;; + idx__stocks__help,insights) + cmd="idx__stocks__help__insights" + ;; + idx__stocks__help,news) + cmd="idx__stocks__help__news" + ;; + idx__stocks__help,profile) + cmd="idx__stocks__help__profile" + ;; + idx__stocks__help,quote) + cmd="idx__stocks__help__quote" + ;; + idx__stocks__help,risk) + cmd="idx__stocks__help__risk" + ;; + idx__stocks__help,screen) + cmd="idx__stocks__help__screen" + ;; + idx__stocks__help,sentiment) + cmd="idx__stocks__help__sentiment" + ;; + idx__stocks__help,technical) + cmd="idx__stocks__help__technical" + ;; + idx__stocks__help,valuation) + cmd="idx__stocks__help__valuation" + ;; + *) + ;; + esac + done + + case "${cmd}" in + idx) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help stocks config cache ownership completions version help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help info clear help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__clear) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__help) + opts="info clear help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__help__clear) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__help__info) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__cache__info) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__completions) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help bash zsh fish" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help init get set path help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__get) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help) + opts="init get set path help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help__get) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help__init) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help__path) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__help__set) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__init) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__path) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__config__set) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help) + opts="stocks config cache ownership completions version help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__cache) + opts="info clear" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__cache__clear) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__cache__info) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__completions) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__config) + opts="init get set path" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__config__get) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__config__init) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__config__path) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__config__set) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership) + opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__changes) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__concentration) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__cross__holders) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__discover) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__entity) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__graph) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__import) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__releases) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__resolve) + opts="unresolved map merge" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__resolve__map) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__resolve__merge) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__resolve__unresolved) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__search) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__sync) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__ownership__ticker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks) + opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__compare) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__earnings) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__financials) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__fundamental) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__growth) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__history) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__insights) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__news) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__profile) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__quote) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__risk) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__screen) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__sentiment) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__technical) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__valuation) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__version) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__changes) + opts="-o -q -v -h --from --to --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__concentration) + opts="-o -q -v -h --by --top --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --by) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --top) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__cross__holders) + opts="-o -q -v -h --top --min-tickers --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --top) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --min-tickers) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__discover) + opts="-o -q -v -h --family --limit --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --family) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__entity) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__flow) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__graph) + opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --depth) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --format) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help) + opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__changes) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__concentration) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__cross__holders) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__discover) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__entity) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__graph) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__import) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__releases) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__resolve) + opts="unresolved map merge" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__resolve__map) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__resolve__merge) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__resolve__unresolved) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__search) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__sync) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__help__ticker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__import) + opts="-o -q -v -h --url --file --fetch-bing --force --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --url) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --file) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --fetch-bing) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__releases) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help unresolved map merge help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__help) + opts="unresolved map merge help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__help__map) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__help__merge) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__help__unresolved) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 5 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__map) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__merge) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__resolve__unresolved) + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__search) + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__sync) + opts="-o -q -v -h --manifest --force --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --manifest) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__ownership__ticker) + opts="-o -q -v -h --source --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --source) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__compare) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__earnings) + opts="-o -q -v -h --forecast --history --annual --quarterly --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__financials) + opts="-o -q -v -h --statement --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --statement) + COMPREPLY=($(compgen -W "income balance cashflow" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__fundamental) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__growth) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help) + opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__compare) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__earnings) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__financials) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__fundamental) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__growth) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__history) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__insights) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__news) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__profile) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__quote) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__risk) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__screen) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__sentiment) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__technical) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__help__valuation) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__history) + opts="-o -q -v -h --period --interval --history-provider --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --period) + COMPREPLY=($(compgen -W "1d 5d 1mo 3mo 6mo 1y 2y 5y" -- "${cur}")) + return 0 + ;; + --interval) + COMPREPLY=($(compgen -W "1d 1wk 1mo" -- "${cur}")) + return 0 + ;; + --history-provider) + COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__insights) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__news) + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__profile) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__quote) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__risk) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__screen) + opts="-o -q -v -h --filter --region --limit --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --filter) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --region) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__sentiment) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__technical) + opts="-o -q -v -h --history-provider --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --history-provider) + COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__valuation) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__version) + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "table json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + esac +} + +if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then + complete -F _idx -o nosort -o bashdefault -o default idx +else + complete -F _idx -o bashdefault -o default idx +fi +--- stderr diff --git a/tests/golden/config-get.golden b/tests/golden/config-get.golden new file mode 100644 index 0000000..c9ca9de --- /dev/null +++ b/tests/golden/config-get.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +msn +--- stderr diff --git a/tests/golden/config-init.golden b/tests/golden/config-init.golden new file mode 100644 index 0000000..ab029c5 --- /dev/null +++ b/tests/golden/config-init.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +/config/idx/config.toml +--- stderr diff --git a/tests/golden/config-path.golden b/tests/golden/config-path.golden new file mode 100644 index 0000000..ab029c5 --- /dev/null +++ b/tests/golden/config-path.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +/config/idx/config.toml +--- stderr diff --git a/tests/golden/config-set.golden b/tests/golden/config-set.golden new file mode 100644 index 0000000..aecf1eb --- /dev/null +++ b/tests/golden/config-set.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +ok +--- stderr diff --git a/tests/golden/error-compare-total-failure.json.golden b/tests/golden/error-compare-total-failure.json.golden new file mode 100644 index 0000000..9c72e99 --- /dev/null +++ b/tests/golden/error-compare-total-failure.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "PROVIDERUNAVAILABLE", + "error": true, + "message": "provider unavailable" +} diff --git a/tests/golden/error-compare-total-failure.table.golden b/tests/golden/error-compare-total-failure.table.golden new file mode 100644 index 0000000..fc7d924 --- /dev/null +++ b/tests/golden/error-compare-total-failure.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: provider unavailable diff --git a/tests/golden/error-fetch-bing-unsupported.json.golden b/tests/golden/error-fetch-bing-unsupported.json.golden new file mode 100644 index 0000000..1c73d67 --- /dev/null +++ b/tests/golden/error-fetch-bing-unsupported.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "UNSUPPORTED", + "error": true, + "message": "unsupported: --fetch-bing import is not implemented yet" +} diff --git a/tests/golden/error-fetch-bing-unsupported.table.golden b/tests/golden/error-fetch-bing-unsupported.table.golden new file mode 100644 index 0000000..bf6ba34 --- /dev/null +++ b/tests/golden/error-fetch-bing-unsupported.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: unsupported: --fetch-bing import is not implemented yet diff --git a/tests/golden/error-industry-only-fundamentals.json.golden b/tests/golden/error-industry-only-fundamentals.json.golden new file mode 100644 index 0000000..d8fb2a5 --- /dev/null +++ b/tests/golden/error-industry-only-fundamentals.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "PARSEERROR", + "error": true, + "message": "parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled" +} diff --git a/tests/golden/error-industry-only-fundamentals.table.golden b/tests/golden/error-industry-only-fundamentals.table.golden new file mode 100644 index 0000000..b2bf359 --- /dev/null +++ b/tests/golden/error-industry-only-fundamentals.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled diff --git a/tests/golden/error-invalid-provider.json.golden b/tests/golden/error-invalid-provider.json.golden new file mode 100644 index 0000000..cb67653 --- /dev/null +++ b/tests/golden/error-invalid-provider.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "CONFIGERROR", + "error": true, + "message": "config error: invalid provider 'bogus' (expected yahoo or msn)" +} diff --git a/tests/golden/error-invalid-provider.table.golden b/tests/golden/error-invalid-provider.table.golden new file mode 100644 index 0000000..8b5ee37 --- /dev/null +++ b/tests/golden/error-invalid-provider.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: config error: invalid provider 'bogus' (expected yahoo or msn) diff --git a/tests/golden/error-offline-cache-miss.json.golden b/tests/golden/error-offline-cache-miss.json.golden new file mode 100644 index 0000000..6006ad7 --- /dev/null +++ b/tests/golden/error-offline-cache-miss.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "CACHEMISS", + "error": true, + "message": "cache miss: msn-quote/BBCA.JK" +} diff --git a/tests/golden/error-offline-cache-miss.table.golden b/tests/golden/error-offline-cache-miss.table.golden new file mode 100644 index 0000000..27e5af2 --- /dev/null +++ b/tests/golden/error-offline-cache-miss.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: cache miss: msn-quote/BBCA.JK diff --git a/tests/golden/error-offline-no-cache-flags.json.golden b/tests/golden/error-offline-no-cache-flags.json.golden new file mode 100644 index 0000000..8d9b175 --- /dev/null +++ b/tests/golden/error-offline-no-cache-flags.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "INVALIDINPUT", + "error": true, + "message": "invalid input: cannot combine --offline with --no-cache" +} diff --git a/tests/golden/error-offline-no-cache-flags.table.golden b/tests/golden/error-offline-no-cache-flags.table.golden new file mode 100644 index 0000000..0dcead6 --- /dev/null +++ b/tests/golden/error-offline-no-cache-flags.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: invalid input: cannot combine --offline with --no-cache diff --git a/tests/golden/error-provider-failure.json.golden b/tests/golden/error-provider-failure.json.golden new file mode 100644 index 0000000..9c72e99 --- /dev/null +++ b/tests/golden/error-provider-failure.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "PROVIDERUNAVAILABLE", + "error": true, + "message": "provider unavailable" +} diff --git a/tests/golden/error-provider-failure.table.golden b/tests/golden/error-provider-failure.table.golden new file mode 100644 index 0000000..fc7d924 --- /dev/null +++ b/tests/golden/error-provider-failure.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: provider unavailable diff --git a/tests/golden/error-screen-region.json.golden b/tests/golden/error-screen-region.json.golden new file mode 100644 index 0000000..e3ad825 --- /dev/null +++ b/tests/golden/error-screen-region.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "INVALIDINPUT", + "error": true, + "message": "invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)" +} diff --git a/tests/golden/error-screen-region.table.golden b/tests/golden/error-screen-region.table.golden new file mode 100644 index 0000000..39ac5be --- /dev/null +++ b/tests/golden/error-screen-region.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp) diff --git a/tests/golden/error-usage-missing-symbol.json.golden b/tests/golden/error-usage-missing-symbol.json.golden new file mode 100644 index 0000000..e653302 --- /dev/null +++ b/tests/golden/error-usage-missing-symbol.json.golden @@ -0,0 +1,9 @@ +exit: 2 +--- stdout +--- stderr +error: the following required arguments were not provided: + ... + +Usage: idx stocks quote ... + +For more information, try '--help'. diff --git a/tests/golden/error-usage-missing-symbol.table.golden b/tests/golden/error-usage-missing-symbol.table.golden new file mode 100644 index 0000000..e653302 --- /dev/null +++ b/tests/golden/error-usage-missing-symbol.table.golden @@ -0,0 +1,9 @@ +exit: 2 +--- stdout +--- stderr +error: the following required arguments were not provided: + ... + +Usage: idx stocks quote ... + +For more information, try '--help'. diff --git a/tests/golden/error-yahoo-profile-unsupported.json.golden b/tests/golden/error-yahoo-profile-unsupported.json.golden new file mode 100644 index 0000000..237c99d --- /dev/null +++ b/tests/golden/error-yahoo-profile-unsupported.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "UNSUPPORTED", + "error": true, + "message": "unsupported: BBCA.JK: command requires --provider msn" +} diff --git a/tests/golden/error-yahoo-profile-unsupported.table.golden b/tests/golden/error-yahoo-profile-unsupported.table.golden new file mode 100644 index 0000000..762324c --- /dev/null +++ b/tests/golden/error-yahoo-profile-unsupported.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: unsupported: BBCA.JK: command requires --provider msn diff --git a/tests/golden/help-cache.golden b/tests/golden/help-cache.golden new file mode 100644 index 0000000..40f2b4f --- /dev/null +++ b/tests/golden/help-cache.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +Manage local cache + +Usage: idx cache [OPTIONS] + +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 format [possible values: table, json] + --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 diff --git a/tests/golden/help-config.golden b/tests/golden/help-config.golden new file mode 100644 index 0000000..485641f --- /dev/null +++ b/tests/golden/help-config.golden @@ -0,0 +1,22 @@ +exit: 0 +--- stdout +Manage configuration + +Usage: idx config [OPTIONS] + +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 format [possible values: table, json] + --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 diff --git a/tests/golden/help-ownership.golden b/tests/golden/help-ownership.golden new file mode 100644 index 0000000..44bb1e8 --- /dev/null +++ b/tests/golden/help-ownership.golden @@ -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 `. + 3. Local `.xlsx`, `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows. + +Usage: idx ownership [OPTIONS] + +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 format + + [possible values: table, json] + + --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 + idx ownership releases +--- stderr diff --git a/tests/golden/help-root.golden b/tests/golden/help-root.golden new file mode 100644 index 0000000..6095643 --- /dev/null +++ b/tests/golden/help-root.golden @@ -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] + +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 format + + [possible values: table, json] + + --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 diff --git a/tests/golden/help-stocks.golden b/tests/golden/help-stocks.golden new file mode 100644 index 0000000..1c51546 --- /dev/null +++ b/tests/golden/help-stocks.golden @@ -0,0 +1,33 @@ +exit: 0 +--- stdout +Stock data and analysis + +Usage: idx stocks [OPTIONS] + +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 format [possible values: table, json] + --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 diff --git a/tests/golden/ownership-changes.json.golden b/tests/golden/ownership-changes.json.golden new file mode 100644 index 0000000..138b1cb --- /dev/null +++ b/tests/golden/ownership-changes.json.golden @@ -0,0 +1,109 @@ +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 diff --git a/tests/golden/ownership-changes.table.golden b/tests/golden/ownership-changes.table.golden new file mode 100644 index 0000000..7f08993 --- /dev/null +++ b/tests/golden/ownership-changes.table.golden @@ -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 diff --git a/tests/golden/ownership-concentration.json.golden b/tests/golden/ownership-concentration.json.golden new file mode 100644 index 0000000..2c5afc3 --- /dev/null +++ b/tests/golden/ownership-concentration.json.golden @@ -0,0 +1,45 @@ +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 diff --git a/tests/golden/ownership-concentration.table.golden b/tests/golden/ownership-concentration.table.golden new file mode 100644 index 0000000..b7472ac --- /dev/null +++ b/tests/golden/ownership-concentration.table.golden @@ -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 diff --git a/tests/golden/ownership-cross-holders.json.golden b/tests/golden/ownership-cross-holders.json.golden new file mode 100644 index 0000000..9c4685e --- /dev/null +++ b/tests/golden/ownership-cross-holders.json.golden @@ -0,0 +1,175 @@ +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 diff --git a/tests/golden/ownership-cross-holders.table.golden b/tests/golden/ownership-cross-holders.table.golden new file mode 100644 index 0000000..ddce4ba --- /dev/null +++ b/tests/golden/ownership-cross-holders.table.golden @@ -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 diff --git a/tests/golden/ownership-entity.json.golden b/tests/golden/ownership-entity.json.golden new file mode 100644 index 0000000..ece9e47 --- /dev/null +++ b/tests/golden/ownership-entity.json.golden @@ -0,0 +1,26 @@ +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 diff --git a/tests/golden/ownership-entity.table.golden b/tests/golden/ownership-entity.table.golden new file mode 100644 index 0000000..3fb0cae --- /dev/null +++ b/tests/golden/ownership-entity.table.golden @@ -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 diff --git a/tests/golden/ownership-flow.json.golden b/tests/golden/ownership-flow.json.golden new file mode 100644 index 0000000..3882ba7 --- /dev/null +++ b/tests/golden/ownership-flow.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +null +--- stderr diff --git a/tests/golden/ownership-flow.table.golden b/tests/golden/ownership-flow.table.golden new file mode 100644 index 0000000..cfa0580 --- /dev/null +++ b/tests/golden/ownership-flow.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +No institutional flow data available for BBCA. +--- stderr diff --git a/tests/golden/ownership-graph-dot.json.golden b/tests/golden/ownership-graph-dot.json.golden new file mode 100644 index 0000000..ffebccb --- /dev/null +++ b/tests/golden/ownership-graph-dot.json.golden @@ -0,0 +1,58 @@ +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 diff --git a/tests/golden/ownership-graph-dot.table.golden b/tests/golden/ownership-graph-dot.table.golden new file mode 100644 index 0000000..0e6fa36 --- /dev/null +++ b/tests/golden/ownership-graph-dot.table.golden @@ -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 diff --git a/tests/golden/ownership-graph.json.golden b/tests/golden/ownership-graph.json.golden new file mode 100644 index 0000000..ffebccb --- /dev/null +++ b/tests/golden/ownership-graph.json.golden @@ -0,0 +1,58 @@ +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 diff --git a/tests/golden/ownership-graph.table.golden b/tests/golden/ownership-graph.table.golden new file mode 100644 index 0000000..46031aa --- /dev/null +++ b/tests/golden/ownership-graph.table.golden @@ -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 diff --git a/tests/golden/ownership-import-archive-txt.json.golden b/tests/golden/ownership-import-archive-txt.json.golden new file mode 100644 index 0000000..0717683 --- /dev/null +++ b/tests/golden/ownership-import-archive-txt.json.golden @@ -0,0 +1,11 @@ +exit: 0 +--- stdout +{ + "action": "imported", + "inserted_rows": 18, + "ticker_count": 1, + "as_of_date": "2026-02-27", + "sha256": "7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547", + "source_url": null +} +--- stderr diff --git a/tests/golden/ownership-import-archive-txt.table.golden b/tests/golden/ownership-import-archive-txt.table.golden new file mode 100644 index 0000000..8b0dfc1 --- /dev/null +++ b/tests/golden/ownership-import-archive-txt.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +Imported 18 rows for 1 tickers (as of 2026-02-27). +--- stderr diff --git a/tests/golden/ownership-import-xlsx.json.golden b/tests/golden/ownership-import-xlsx.json.golden new file mode 100644 index 0000000..30fb0d3 --- /dev/null +++ b/tests/golden/ownership-import-xlsx.json.golden @@ -0,0 +1,11 @@ +exit: 0 +--- stdout +{ + "action": "imported", + "inserted_rows": 17, + "ticker_count": 4, + "as_of_date": "2026-08-31", + "sha256": "7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3", + "source_url": null +} +--- stderr diff --git a/tests/golden/ownership-import-xlsx.table.golden b/tests/golden/ownership-import-xlsx.table.golden new file mode 100644 index 0000000..6eda918 --- /dev/null +++ b/tests/golden/ownership-import-xlsx.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +Imported 17 rows for 4 tickers (as of 2026-08-31). +--- stderr diff --git a/tests/golden/ownership-releases-empty.json.golden b/tests/golden/ownership-releases-empty.json.golden new file mode 100644 index 0000000..d845e3c --- /dev/null +++ b/tests/golden/ownership-releases-empty.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[] +--- stderr diff --git a/tests/golden/ownership-releases-empty.table.golden b/tests/golden/ownership-releases-empty.table.golden new file mode 100644 index 0000000..a069376 --- /dev/null +++ b/tests/golden/ownership-releases-empty.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +No ownership releases imported yet. +--- stderr diff --git a/tests/golden/ownership-releases.json.golden b/tests/golden/ownership-releases.json.golden new file mode 100644 index 0000000..a555050 --- /dev/null +++ b/tests/golden/ownership-releases.json.golden @@ -0,0 +1,29 @@ +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 diff --git a/tests/golden/ownership-releases.table.golden b/tests/golden/ownership-releases.table.golden new file mode 100644 index 0000000..ffebd3c --- /dev/null +++ b/tests/golden/ownership-releases.table.golden @@ -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 diff --git a/tests/golden/ownership-resolve-map.json.golden b/tests/golden/ownership-resolve-map.json.golden new file mode 100644 index 0000000..baf20c6 --- /dev/null +++ b/tests/golden/ownership-resolve-map.json.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +{ + "action": "mapped", + "alias": "SALIM ANTHONI", + "entity": "ANTHONI SALIM" +} +--- stderr diff --git a/tests/golden/ownership-resolve-map.table.golden b/tests/golden/ownership-resolve-map.table.golden new file mode 100644 index 0000000..baa777c --- /dev/null +++ b/tests/golden/ownership-resolve-map.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +Mapped alias 'SALIM ANTHONI' -> 'ANTHONI SALIM'. +--- stderr diff --git a/tests/golden/ownership-resolve-merge.json.golden b/tests/golden/ownership-resolve-merge.json.golden new file mode 100644 index 0000000..6ea677b --- /dev/null +++ b/tests/golden/ownership-resolve-merge.json.golden @@ -0,0 +1,11 @@ +exit: 0 +--- stdout +{ + "action": "merged", + "alias_updates": 1, + "bing_holdings_updates": 0, + "keep": 1, + "ksei_holdings_updates": 1, + "merge": 2 +} +--- stderr diff --git a/tests/golden/ownership-resolve-merge.table.golden b/tests/golden/ownership-resolve-merge.table.golden new file mode 100644 index 0000000..8bf7715 --- /dev/null +++ b/tests/golden/ownership-resolve-merge.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +Merged entity 2 into 1 (aliases: 1, ksei_holdings: 1, bing_holdings: 0). +--- stderr diff --git a/tests/golden/ownership-resolve-unresolved.json.golden b/tests/golden/ownership-resolve-unresolved.json.golden new file mode 100644 index 0000000..d845e3c --- /dev/null +++ b/tests/golden/ownership-resolve-unresolved.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[] +--- stderr diff --git a/tests/golden/ownership-resolve-unresolved.table.golden b/tests/golden/ownership-resolve-unresolved.table.golden new file mode 100644 index 0000000..186a221 --- /dev/null +++ b/tests/golden/ownership-resolve-unresolved.table.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +No unresolved or low-confidence aliases found. +--- stderr diff --git a/tests/golden/ownership-search.json.golden b/tests/golden/ownership-search.json.golden new file mode 100644 index 0000000..81ef014 --- /dev/null +++ b/tests/golden/ownership-search.json.golden @@ -0,0 +1,11 @@ +exit: 0 +--- stdout +[ + { + "id": 17, + "canonical_name": "ANTHONI SALIM", + "entity_type": null, + "country": null + } +] +--- stderr diff --git a/tests/golden/ownership-search.table.golden b/tests/golden/ownership-search.table.golden new file mode 100644 index 0000000..31b0f0e --- /dev/null +++ b/tests/golden/ownership-search.table.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +┌───────────────┬──────┬─────────┐ +│ ENTITY ┆ TYPE ┆ COUNTRY │ +╞═══════════════╪══════╪═════════╡ +│ ANTHONI SALIM ┆ - ┆ - │ +└───────────────┴──────┴─────────┘ +--- stderr diff --git a/tests/golden/ownership-ticker-empty.json.golden b/tests/golden/ownership-ticker-empty.json.golden new file mode 100644 index 0000000..f165894 --- /dev/null +++ b/tests/golden/ownership-ticker-empty.json.golden @@ -0,0 +1,8 @@ +exit: 1 +--- stdout +--- stderr +{ + "code": "SYMBOLNOTFOUND", + "error": true, + "message": "symbol not found: BBCA" +} diff --git a/tests/golden/ownership-ticker-empty.table.golden b/tests/golden/ownership-ticker-empty.table.golden new file mode 100644 index 0000000..b92ace9 --- /dev/null +++ b/tests/golden/ownership-ticker-empty.table.golden @@ -0,0 +1,4 @@ +exit: 1 +--- stdout +--- stderr +Error: symbol not found: BBCA diff --git a/tests/golden/ownership-ticker-ksei.json.golden b/tests/golden/ownership-ticker-ksei.json.golden new file mode 100644 index 0000000..de19ce7 --- /dev/null +++ b/tests/golden/ownership-ticker-ksei.json.golden @@ -0,0 +1,67 @@ +exit: 0 +--- stdout +{ + "ticker": { + "id": 4, + "code": "BBCA", + "name": "BANK CENTRAL ASIA Tbk", + "sector": null + }, + "ksei_as_of": "2026-08-31", + "bing_as_of": null, + "holders": [ + { + "rank": 1, + "source": "ksei", + "name": "DWIMURIA INVESTAMA ANDALAN", + "entity_id": 14, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 67729950000, + "percentage_bps": 5494, + "signal": null + }, + { + "rank": 2, + "source": "ksei", + "name": "ANTHONI SALIM", + "entity_id": 17, + "investor_type": "INDIVIDUAL", + "locality": "Local", + "shares": 1416306835, + "percentage_bps": 115, + "signal": null + }, + { + "rank": 3, + "source": "ksei", + "name": "TRICIPTA MANDHALA GUMILANG", + "entity_id": 16, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 1313250000, + "percentage_bps": 107, + "signal": null + }, + { + "rank": 4, + "source": "ksei", + "name": "CATURGUWIRATNA SUMAPALA", + "entity_id": 15, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 1261750000, + "percentage_bps": 102, + "signal": null + } + ], + "concentration": { + "top1_bps": 5494, + "top3_bps": 5716, + "hhi": 3021, + "free_float_bps": 4182, + "holder_count": 4 + }, + "flow": null +} +--- stderr diff --git a/tests/golden/ownership-ticker-ksei.table.golden b/tests/golden/ownership-ticker-ksei.table.golden new file mode 100644 index 0000000..e59444a --- /dev/null +++ b/tests/golden/ownership-ticker-ksei.table.golden @@ -0,0 +1,16 @@ +exit: 0 +--- stdout +Ownership: BBCA KSEI=2026-08-31 Bing=- +┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐ +│ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │ +╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡ +│ 1 ┆ KSEI ┆ DWIMURIA INVESTAMA ANDALAN ┆ CORPORATE ┆ L ┆ 67,729,950,000 ┆ 54.94% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 2 ┆ KSEI ┆ ANTHONI SALIM ┆ INDIVIDUAL ┆ L ┆ 1,416,306,835 ┆ 1.15% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 3 ┆ KSEI ┆ TRICIPTA MANDHALA GUMILANG ┆ CORPORATE ┆ L ┆ 1,313,250,000 ┆ 1.07% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │ +└──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘ +Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 +--- stderr diff --git a/tests/golden/ownership-ticker.json.golden b/tests/golden/ownership-ticker.json.golden new file mode 100644 index 0000000..de19ce7 --- /dev/null +++ b/tests/golden/ownership-ticker.json.golden @@ -0,0 +1,67 @@ +exit: 0 +--- stdout +{ + "ticker": { + "id": 4, + "code": "BBCA", + "name": "BANK CENTRAL ASIA Tbk", + "sector": null + }, + "ksei_as_of": "2026-08-31", + "bing_as_of": null, + "holders": [ + { + "rank": 1, + "source": "ksei", + "name": "DWIMURIA INVESTAMA ANDALAN", + "entity_id": 14, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 67729950000, + "percentage_bps": 5494, + "signal": null + }, + { + "rank": 2, + "source": "ksei", + "name": "ANTHONI SALIM", + "entity_id": 17, + "investor_type": "INDIVIDUAL", + "locality": "Local", + "shares": 1416306835, + "percentage_bps": 115, + "signal": null + }, + { + "rank": 3, + "source": "ksei", + "name": "TRICIPTA MANDHALA GUMILANG", + "entity_id": 16, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 1313250000, + "percentage_bps": 107, + "signal": null + }, + { + "rank": 4, + "source": "ksei", + "name": "CATURGUWIRATNA SUMAPALA", + "entity_id": 15, + "investor_type": "CORPORATE", + "locality": "Local", + "shares": 1261750000, + "percentage_bps": 102, + "signal": null + } + ], + "concentration": { + "top1_bps": 5494, + "top3_bps": 5716, + "hhi": 3021, + "free_float_bps": 4182, + "holder_count": 4 + }, + "flow": null +} +--- stderr diff --git a/tests/golden/ownership-ticker.table.golden b/tests/golden/ownership-ticker.table.golden new file mode 100644 index 0000000..e59444a --- /dev/null +++ b/tests/golden/ownership-ticker.table.golden @@ -0,0 +1,16 @@ +exit: 0 +--- stdout +Ownership: BBCA KSEI=2026-08-31 Bing=- +┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐ +│ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │ +╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡ +│ 1 ┆ KSEI ┆ DWIMURIA INVESTAMA ANDALAN ┆ CORPORATE ┆ L ┆ 67,729,950,000 ┆ 54.94% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 2 ┆ KSEI ┆ ANTHONI SALIM ┆ INDIVIDUAL ┆ L ┆ 1,416,306,835 ┆ 1.15% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 3 ┆ KSEI ┆ TRICIPTA MANDHALA GUMILANG ┆ CORPORATE ┆ L ┆ 1,313,250,000 ┆ 1.07% ┆ - │ +├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │ +└──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘ +Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 +--- stderr diff --git a/tests/golden/stocks-compare.json.golden b/tests/golden/stocks-compare.json.golden new file mode 100644 index 0000000..c5cf145 --- /dev/null +++ b/tests/golden/stocks-compare.json.golden @@ -0,0 +1,81 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBCA.JK", + "growth": { + "revenue_growth": 0.081, + "earnings_growth": 0.121, + "revenue_growth_pct": 8.1, + "earnings_growth_pct": 12.1, + "revenue_signal": "slow", + "earnings_signal": "moderate", + "overall_signal": "mixed" + }, + "valuation": { + "pe_trailing": 25.4, + "pe_forward": null, + "pb": 4.6, + "roe": 0.198, + "roe_pct": 19.8, + "net_margin": 0.324, + "net_margin_pct": 32.4, + "ev_ebitda": null, + "pe_signal": "premium", + "pb_signal": "expensive", + "roe_signal": "strong", + "margin_signal": "excellent", + "ev_ebitda_signal": "no data", + "overall_signal": "expensive" + }, + "risk": { + "debt_to_equity": 0.42, + "current_ratio": 1.18, + "roa": 0.031, + "roa_pct": 3.1, + "de_signal": "conservative", + "current_ratio_signal": "tight", + "overall_signal": "moderate risk" + }, + "overall_signal": "mixed" + }, + { + "symbol": "BBRI.JK", + "growth": { + "revenue_growth": 0.081, + "earnings_growth": 0.121, + "revenue_growth_pct": 8.1, + "earnings_growth_pct": 12.1, + "revenue_signal": "slow", + "earnings_signal": "moderate", + "overall_signal": "mixed" + }, + "valuation": { + "pe_trailing": 25.4, + "pe_forward": null, + "pb": 4.6, + "roe": 0.198, + "roe_pct": 19.8, + "net_margin": 0.324, + "net_margin_pct": 32.4, + "ev_ebitda": null, + "pe_signal": "premium", + "pb_signal": "expensive", + "roe_signal": "strong", + "margin_signal": "excellent", + "ev_ebitda_signal": "no data", + "overall_signal": "expensive" + }, + "risk": { + "debt_to_equity": 0.42, + "current_ratio": 1.18, + "roa": 0.031, + "roa_pct": 3.1, + "de_signal": "conservative", + "current_ratio_signal": "tight", + "overall_signal": "moderate risk" + }, + "overall_signal": "mixed" + } +] +--- stderr diff --git a/tests/golden/stocks-compare.table.golden b/tests/golden/stocks-compare.table.golden new file mode 100644 index 0000000..c913fbc --- /dev/null +++ b/tests/golden/stocks-compare.table.golden @@ -0,0 +1,23 @@ +exit: 0 +--- stdout +Fundamental Comparison +┌────────────────┬─────────────────────────┬─────────────────────────┐ +│ METRIC ┆ BBCA.JK ┆ BBRI.JK │ +╞════════════════╪═════════════════════════╪═════════════════════════╡ +│ Symbol ┆ BBCA.JK ┆ BBRI.JK │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Growth ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Valuation ┆ expensive ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Risk ┆ moderate risk ┆ moderate risk │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E ┆ 25.40 ┆ 25.40 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ +19.80% │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Revenue Growth ┆ +8.10% ┆ +8.10% │ +└────────────────┴─────────────────────────┴─────────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-earnings-forecast-annual.json.golden b/tests/golden/stocks-earnings-forecast-annual.json.golden new file mode 100644 index 0000000..ca4b219 --- /dev/null +++ b/tests/golden/stocks-earnings-forecast-annual.json.golden @@ -0,0 +1,22 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "eps_last_year": 1200.0, + "revenue_last_year": 100000000000.0, + "forecast": [ + { + "eps_actual": null, + "eps_forecast": 1300.0, + "eps_surprise": null, + "eps_surprise_pct": null, + "revenue_actual": null, + "revenue_forecast": 110000000000.0, + "revenue_surprise": null, + "earning_release_date": "2026-03-15", + "period_type": "2026" + } + ], + "history": [] +} +--- stderr diff --git a/tests/golden/stocks-earnings-forecast-annual.table.golden b/tests/golden/stocks-earnings-forecast-annual.table.golden new file mode 100644 index 0000000..7462fbf --- /dev/null +++ b/tests/golden/stocks-earnings-forecast-annual.table.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout + +── Earnings Forecast ── +┌────────┬─────────┬─────────────────┬────────────┐ +│ PERIOD ┆ EPS FC ┆ REV FC ┆ DATE │ +╞════════╪═════════╪═════════════════╪════════════╡ +│ FY2026 ┆ 1300.00 ┆ 110,000,000,000 ┆ 2026-03-15 │ +└────────┴─────────┴─────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-earnings-history-quarterly.json.golden b/tests/golden/stocks-earnings-history-quarterly.json.golden new file mode 100644 index 0000000..2c8eef8 --- /dev/null +++ b/tests/golden/stocks-earnings-history-quarterly.json.golden @@ -0,0 +1,22 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "eps_last_year": 1200.0, + "revenue_last_year": 100000000000.0, + "forecast": [], + "history": [ + { + "eps_actual": 320.0, + "eps_forecast": null, + "eps_surprise": 5.0, + "eps_surprise_pct": 1.5, + "revenue_actual": 27000000000.0, + "revenue_forecast": null, + "revenue_surprise": 200000000.0, + "earning_release_date": "2025-11-20", + "period_type": "Q42025" + } + ] +} +--- stderr diff --git a/tests/golden/stocks-earnings-history-quarterly.table.golden b/tests/golden/stocks-earnings-history-quarterly.table.golden new file mode 100644 index 0000000..733bfb3 --- /dev/null +++ b/tests/golden/stocks-earnings-history-quarterly.table.golden @@ -0,0 +1,9 @@ +exit: 0 +--- stdout +── Earnings History ── +┌─────────┬─────────┬────────┬──────────┬───────────┬────────────────┬────────────┐ +│ PERIOD ┆ EPS ACT ┆ EPS FC ┆ SURPRISE ┆ SURPRISE% ┆ REV ACT ┆ DATE │ +╞═════════╪═════════╪════════╪══════════╪═══════════╪════════════════╪════════════╡ +│ Q4 2025 ┆ 320.00 ┆ - ┆ 5.00 ┆ 1.50 ┆ 27,000,000,000 ┆ 2025-11-20 │ +└─────────┴─────────┴────────┴──────────┴───────────┴────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-earnings.json.golden b/tests/golden/stocks-earnings.json.golden new file mode 100644 index 0000000..f9fcc31 --- /dev/null +++ b/tests/golden/stocks-earnings.json.golden @@ -0,0 +1,56 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "eps_last_year": 1200.0, + "revenue_last_year": 100000000000.0, + "forecast": [ + { + "eps_actual": null, + "eps_forecast": 1300.0, + "eps_surprise": null, + "eps_surprise_pct": null, + "revenue_actual": null, + "revenue_forecast": 110000000000.0, + "revenue_surprise": null, + "earning_release_date": "2026-03-15", + "period_type": "2026" + }, + { + "eps_actual": null, + "eps_forecast": 330.0, + "eps_surprise": null, + "eps_surprise_pct": null, + "revenue_actual": null, + "revenue_forecast": 28000000000.0, + "revenue_surprise": null, + "earning_release_date": "2026-04-20", + "period_type": "Q12026" + } + ], + "history": [ + { + "eps_actual": 1250.0, + "eps_forecast": null, + "eps_surprise": 20.0, + "eps_surprise_pct": 1.6, + "revenue_actual": 105000000000.0, + "revenue_forecast": null, + "revenue_surprise": 500000000.0, + "earning_release_date": "2025-03-15", + "period_type": "2025" + }, + { + "eps_actual": 320.0, + "eps_forecast": null, + "eps_surprise": 5.0, + "eps_surprise_pct": 1.5, + "revenue_actual": 27000000000.0, + "revenue_forecast": null, + "revenue_surprise": 200000000.0, + "earning_release_date": "2025-11-20", + "period_type": "Q42025" + } + ] +} +--- stderr diff --git a/tests/golden/stocks-earnings.table.golden b/tests/golden/stocks-earnings.table.golden new file mode 100644 index 0000000..ef37a38 --- /dev/null +++ b/tests/golden/stocks-earnings.table.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +── Earnings History ── +┌─────────┬─────────┬────────┬──────────┬───────────┬─────────────────┬────────────┐ +│ PERIOD ┆ EPS ACT ┆ EPS FC ┆ SURPRISE ┆ SURPRISE% ┆ REV ACT ┆ DATE │ +╞═════════╪═════════╪════════╪══════════╪═══════════╪═════════════════╪════════════╡ +│ FY2025 ┆ 1250.00 ┆ - ┆ 20.00 ┆ 1.60 ┆ 105,000,000,000 ┆ 2025-03-15 │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Q4 2025 ┆ 320.00 ┆ - ┆ 5.00 ┆ 1.50 ┆ 27,000,000,000 ┆ 2025-11-20 │ +└─────────┴─────────┴────────┴──────────┴───────────┴─────────────────┴────────────┘ + +── Earnings Forecast ── +┌─────────┬─────────┬─────────────────┬────────────┐ +│ PERIOD ┆ EPS FC ┆ REV FC ┆ DATE │ +╞═════════╪═════════╪═════════════════╪════════════╡ +│ FY2026 ┆ 1300.00 ┆ 110,000,000,000 ┆ 2026-03-15 │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Q1 2026 ┆ 330.00 ┆ 28,000,000,000 ┆ 2026-04-20 │ +└─────────┴─────────┴─────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-financials-income.json.golden b/tests/golden/stocks-financials-income.json.golden new file mode 100644 index 0000000..83cd079 --- /dev/null +++ b/tests/golden/stocks-financials-income.json.golden @@ -0,0 +1,21 @@ +exit: 0 +--- stdout +{ + "instrument": { + "id": "bn91jc", + "symbol": "BBCA.JK", + "name": "Bank Central Asia Tbk PT" + }, + "balance_sheet": null, + "cash_flow": null, + "income_statement": { + "values": { + "netIncome": 400000000.0, + "totalRevenue": 1000000000.0 + }, + "currency": "IDR", + "report_date": "2025-12-31", + "end_date": "2025-12-31" + } +} +--- stderr diff --git a/tests/golden/stocks-financials-income.table.golden b/tests/golden/stocks-financials-income.table.golden new file mode 100644 index 0000000..ca58877 --- /dev/null +++ b/tests/golden/stocks-financials-income.table.golden @@ -0,0 +1,12 @@ +exit: 0 +--- stdout + +── Income Statement (2025-12-31) ── +┌───────────────┬───────────────┐ +│ LINE ITEM ┆ VALUE (IDR) │ +╞═══════════════╪═══════════════╡ +│ Net Income ┆ 400,000,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Total Revenue ┆ 1,000,000,000 │ +└───────────────┴───────────────┘ +--- stderr diff --git a/tests/golden/stocks-financials.json.golden b/tests/golden/stocks-financials.json.golden new file mode 100644 index 0000000..34bab31 --- /dev/null +++ b/tests/golden/stocks-financials.json.golden @@ -0,0 +1,36 @@ +exit: 0 +--- stdout +{ + "instrument": { + "id": "bn91jc", + "symbol": "BBCA.JK", + "name": "Bank Central Asia Tbk PT" + }, + "balance_sheet": { + "values": { + "cashAndShortTermInvestments": 500000000.0, + "totalEquity": 900000000.0 + }, + "currency": "IDR", + "report_date": "2025-12-31", + "end_date": "2025-12-31" + }, + "cash_flow": { + "values": { + "operatingCashFlow": 600000000.0 + }, + "currency": "IDR", + "report_date": "2025-12-31", + "end_date": "2025-12-31" + }, + "income_statement": { + "values": { + "netIncome": 400000000.0, + "totalRevenue": 1000000000.0 + }, + "currency": "IDR", + "report_date": "2025-12-31", + "end_date": "2025-12-31" + } +} +--- stderr diff --git a/tests/golden/stocks-financials.table.golden b/tests/golden/stocks-financials.table.golden new file mode 100644 index 0000000..b938cf2 --- /dev/null +++ b/tests/golden/stocks-financials.table.golden @@ -0,0 +1,28 @@ +exit: 0 +--- stdout + +── Income Statement (2025-12-31) ── +┌───────────────┬───────────────┐ +│ LINE ITEM ┆ VALUE (IDR) │ +╞═══════════════╪═══════════════╡ +│ Net Income ┆ 400,000,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Total Revenue ┆ 1,000,000,000 │ +└───────────────┴───────────────┘ + +── Balance Sheet (2025-12-31) ── +┌─────────────────────────────────┬─────────────┐ +│ LINE ITEM ┆ VALUE (IDR) │ +╞═════════════════════════════════╪═════════════╡ +│ Cash And Short Term Investments ┆ 500,000,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Total Equity ┆ 900,000,000 │ +└─────────────────────────────────┴─────────────┘ + +── Cash Flow (2025-12-31) ── +┌─────────────────────┬─────────────┐ +│ LINE ITEM ┆ VALUE (IDR) │ +╞═════════════════════╪═════════════╡ +│ Operating Cash Flow ┆ 600,000,000 │ +└─────────────────────┴─────────────┘ +--- stderr diff --git a/tests/golden/stocks-fundamental.json.golden b/tests/golden/stocks-fundamental.json.golden new file mode 100644 index 0000000..a24505b --- /dev/null +++ b/tests/golden/stocks-fundamental.json.golden @@ -0,0 +1,41 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "growth": { + "revenue_growth": 0.081, + "earnings_growth": 0.121, + "revenue_growth_pct": 8.1, + "earnings_growth_pct": 12.1, + "revenue_signal": "slow", + "earnings_signal": "moderate", + "overall_signal": "mixed" + }, + "valuation": { + "pe_trailing": 25.4, + "pe_forward": null, + "pb": 4.6, + "roe": 0.198, + "roe_pct": 19.8, + "net_margin": 0.324, + "net_margin_pct": 32.4, + "ev_ebitda": null, + "pe_signal": "premium", + "pb_signal": "expensive", + "roe_signal": "strong", + "margin_signal": "excellent", + "ev_ebitda_signal": "no data", + "overall_signal": "expensive" + }, + "risk": { + "debt_to_equity": 0.42, + "current_ratio": 1.18, + "roa": 0.031, + "roa_pct": 3.1, + "de_signal": "conservative", + "current_ratio_signal": "tight", + "overall_signal": "moderate risk" + }, + "overall_signal": "mixed" +} +--- stderr diff --git a/tests/golden/stocks-fundamental.table.golden b/tests/golden/stocks-fundamental.table.golden new file mode 100644 index 0000000..d697200 --- /dev/null +++ b/tests/golden/stocks-fundamental.table.golden @@ -0,0 +1,49 @@ +exit: 0 +--- stdout +Fundamental Analysis for BBCA.JK + +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪════════════════════╡ +│ Revenue Growth ┆ +8.10% ┆ slow │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ mixed │ +└─────────────────┴─────────┴────────────────────┘ + +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬─────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═════════════════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +32.40% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴─────────────────────┘ + +Risk Analysis for BBCA.JK +┌───────────────┬────────┬─────────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═════════════════════════╡ +│ Debt/Equity ┆ 0.42 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.18 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.10% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴─────────────────────────┘ + +Overall Signal: mixed +--- stderr diff --git a/tests/golden/stocks-growth.json.golden b/tests/golden/stocks-growth.json.golden new file mode 100644 index 0000000..d2327bf --- /dev/null +++ b/tests/golden/stocks-growth.json.golden @@ -0,0 +1,12 @@ +exit: 0 +--- stdout +{ + "revenue_growth": 0.081, + "earnings_growth": 0.121, + "revenue_growth_pct": 8.1, + "earnings_growth_pct": 12.1, + "revenue_signal": "slow", + "earnings_signal": "moderate", + "overall_signal": "mixed" +} +--- stderr diff --git a/tests/golden/stocks-growth.table.golden b/tests/golden/stocks-growth.table.golden new file mode 100644 index 0000000..a26e3a1 --- /dev/null +++ b/tests/golden/stocks-growth.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪════════════════════╡ +│ Revenue Growth ┆ +8.10% ┆ slow │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ mixed │ +└─────────────────┴─────────┴────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-history-msn.json.golden b/tests/golden/stocks-history-msn.json.golden new file mode 100644 index 0000000..e6eb3e6 --- /dev/null +++ b/tests/golden/stocks-history-msn.json.golden @@ -0,0 +1,29 @@ +exit: 0 +--- stdout +[ + { + "date": "2026-01-14", + "open": 8000, + "high": 8000, + "low": 8000, + "close": 8000, + "volume": 0 + }, + { + "date": "2026-01-15", + "open": 8075, + "high": 8075, + "low": 8075, + "close": 8075, + "volume": 0 + }, + { + "date": "2026-01-16", + "open": 8025, + "high": 8025, + "low": 8025, + "close": 8025, + "volume": 0 + } +] +--- stderr diff --git a/tests/golden/stocks-history-msn.table.golden b/tests/golden/stocks-history-msn.table.golden new file mode 100644 index 0000000..b47c365 --- /dev/null +++ b/tests/golden/stocks-history-msn.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +History for BBCA.JK +┌────────────┬───────┬───────┬───────┬───────┬────────┐ +│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ +╞════════════╪═══════╪═══════╪═══════╪═══════╪════════╡ +│ 2026-01-14 ┆ 8,000 ┆ 8,000 ┆ 8,000 ┆ 8,000 ┆ 0 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 2026-01-15 ┆ 8,075 ┆ 8,075 ┆ 8,075 ┆ 8,075 ┆ 0 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ 2026-01-16 ┆ 8,025 ┆ 8,025 ┆ 8,025 ┆ 8,025 ┆ 0 │ +└────────────┴───────┴───────┴───────┴───────┴────────┘ +--- stderr diff --git a/tests/golden/stocks-history.json.golden b/tests/golden/stocks-history.json.golden new file mode 100644 index 0000000..423e9c6 --- /dev/null +++ b/tests/golden/stocks-history.json.golden @@ -0,0 +1,29 @@ +exit: 0 +--- stdout +[ + { + "date": "2024-03-01", + "open": 9800, + "high": 9900, + "low": 9750, + "close": 9875, + "volume": 12300000 + }, + { + "date": "2024-03-02", + "open": 9850, + "high": 9920, + "low": 9800, + "close": 9880, + "volume": 11000000 + }, + { + "date": "2024-03-03", + "open": 9860, + "high": 9950, + "low": 9820, + "close": 9925, + "volume": 14000000 + } +] +--- stderr diff --git a/tests/golden/stocks-history.table.golden b/tests/golden/stocks-history.table.golden new file mode 100644 index 0000000..50e34a6 --- /dev/null +++ b/tests/golden/stocks-history.table.golden @@ -0,0 +1,14 @@ +exit: 0 +--- stdout +History for BBCA.JK +┌────────────┬───────┬───────┬───────┬───────┬────────────┐ +│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ +╞════════════╪═══════╪═══════╪═══════╪═══════╪════════════╡ +│ 2024-03-01 ┆ 9,800 ┆ 9,900 ┆ 9,750 ┆ 9,875 ┆ 12,300,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2024-03-02 ┆ 9,850 ┆ 9,920 ┆ 9,800 ┆ 9,880 ┆ 11,000,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2024-03-03 ┆ 9,860 ┆ 9,950 ┆ 9,820 ┆ 9,925 ┆ 14,000,000 │ +└────────────┴───────┴───────┴───────┴───────┴────────────┘ +--- stderr +info: history provider fallback active (msn -> yahoo) diff --git a/tests/golden/stocks-insights.json.golden b/tests/golden/stocks-insights.json.golden new file mode 100644 index 0000000..0050c83 --- /dev/null +++ b/tests/golden/stocks-insights.json.golden @@ -0,0 +1,16 @@ +exit: 0 +--- stdout +{ + "id": "bn91jc", + "symbol": "BBCA.JK", + "summary": "Mixed analyst signals for Bank Central Asia Tbk PT: 1 positive, 1 negative, 1 neutral.", + "highlights": [ + "P/E ratio: Earnings data unavailable, can't compare P/E ratio with peers", + "Analyst price target: Analysts forecast more than 20% upside for 12-month price target" + ], + "risks": [ + "Quarterly Revenue YoY Growth: Revenue grew worse than peers" + ], + "last_updated": "2026-03-26T04:14:57.9197955Z" +} +--- stderr diff --git a/tests/golden/stocks-insights.table.golden b/tests/golden/stocks-insights.table.golden new file mode 100644 index 0000000..f2ab652 --- /dev/null +++ b/tests/golden/stocks-insights.table.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout +Mixed analyst signals for Bank Central Asia Tbk PT: 1 positive, 1 negative, 1 neutral. +Last updated: 2026-03-26T04:14:57.9197955Z +Highlights: +- P/E ratio: Earnings data unavailable, can't compare P/E ratio with peers +- Analyst price target: Analysts forecast more than 20% upside for 12-month price target +Risks: +- Quarterly Revenue YoY Growth: Revenue grew worse than peers +--- stderr diff --git a/tests/golden/stocks-news.json.golden b/tests/golden/stocks-news.json.golden new file mode 100644 index 0000000..71cf540 --- /dev/null +++ b/tests/golden/stocks-news.json.golden @@ -0,0 +1,15 @@ +exit: 0 +--- stdout +[ + { + "id": "news-1", + "symbol": "BBCA.JK", + "title": "BCA reports steady growth", + "url": "https://example.com/news/bca-growth", + "description": "BCA delivered steady quarterly growth across lending and deposits.", + "provider": "Contoso News", + "published_at": "2026-03-20T10:00:00Z", + "read_time_min": 3 + } +] +--- stderr diff --git a/tests/golden/stocks-news.table.golden b/tests/golden/stocks-news.table.golden new file mode 100644 index 0000000..f0bdf1e --- /dev/null +++ b/tests/golden/stocks-news.table.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +┌───────────────────────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐ +│ TITLE ┆ PROVIDER ┆ DATE ┆ URL │ +╞═══════════════════════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡ +│ BCA reports steady growth ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │ +└───────────────────────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-profile.json.golden b/tests/golden/stocks-profile.json.golden new file mode 100644 index 0000000..afafd3f --- /dev/null +++ b/tests/golden/stocks-profile.json.golden @@ -0,0 +1,27 @@ +exit: 0 +--- stdout +{ + "id": "feee16af-c94c-465d-a59d-a9727c22bd0e", + "symbol": "BBCA", + "short_name": "Bank Cntrl Asia", + "long_name": "PT Bank Central Asia Tbk", + "description": "PT Bank Central Asia Tbk (the Bank) is an Indonesia-based commercial bank.", + "sector": "Financials", + "industry": "Banking Services", + "website": "https://www.bca.co.id/", + "employees": 27937, + "address": "Menara BCA, Grand, Jl. MH Thamrin No. 1", + "city": "JAKARTA PUSAT", + "country": "Indonesia", + "phone": "+62 21 23588000", + "officers": [ + { + "name": "Jahja Setiaatmadja", + "title": "President Director", + "age": 69, + "year_born": 1955, + "total_pay": 12000000000 + } + ] +} +--- stderr diff --git a/tests/golden/stocks-profile.table.golden b/tests/golden/stocks-profile.table.golden new file mode 100644 index 0000000..3c5c955 --- /dev/null +++ b/tests/golden/stocks-profile.table.golden @@ -0,0 +1,28 @@ +exit: 0 +--- stdout +┌─────────────┬────────────────────────────────────────────────────────────────────────────┐ +│ FIELD ┆ VALUE │ +╞═════════════╪════════════════════════════════════════════════════════════════════════════╡ +│ Symbol ┆ BBCA │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Name ┆ PT Bank Central Asia Tbk │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Sector ┆ Financials │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Industry ┆ Banking Services │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Website ┆ https://www.bca.co.id/ │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Country ┆ Indonesia │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ City ┆ JAKARTA PUSAT │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Phone ┆ +62 21 23588000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Employees ┆ 27937 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Description ┆ PT Bank Central Asia Tbk (the Bank) is an Indonesia-based commercial bank. │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Executives ┆ Jahja Setiaatmadja (President Director) │ +└─────────────┴────────────────────────────────────────────────────────────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-quote-multi.json.golden b/tests/golden/stocks-quote-multi.json.golden new file mode 100644 index 0000000..6095d47 --- /dev/null +++ b/tests/golden/stocks-quote-multi.json.golden @@ -0,0 +1,35 @@ +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 diff --git a/tests/golden/stocks-quote-multi.table.golden b/tests/golden/stocks-quote-multi.table.golden new file mode 100644 index 0000000..99eb4e0 --- /dev/null +++ b/tests/golden/stocks-quote-multi.table.golden @@ -0,0 +1,10 @@ +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 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBRI.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-quote-offline-warm.json.golden b/tests/golden/stocks-quote-offline-warm.json.golden new file mode 100644 index 0000000..bfcbbaf --- /dev/null +++ b/tests/golden/stocks-quote-offline-warm.json.golden @@ -0,0 +1,20 @@ +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 + } +] +--- stderr diff --git a/tests/golden/stocks-quote-offline-warm.table.golden b/tests/golden/stocks-quote-offline-warm.table.golden new file mode 100644 index 0000000..6102d71 --- /dev/null +++ b/tests/golden/stocks-quote-offline-warm.table.golden @@ -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 diff --git a/tests/golden/stocks-quote.json.golden b/tests/golden/stocks-quote.json.golden new file mode 100644 index 0000000..bfcbbaf --- /dev/null +++ b/tests/golden/stocks-quote.json.golden @@ -0,0 +1,20 @@ +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 + } +] +--- stderr diff --git a/tests/golden/stocks-quote.table.golden b/tests/golden/stocks-quote.table.golden new file mode 100644 index 0000000..6102d71 --- /dev/null +++ b/tests/golden/stocks-quote.table.golden @@ -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 diff --git a/tests/golden/stocks-risk.json.golden b/tests/golden/stocks-risk.json.golden new file mode 100644 index 0000000..0cce202 --- /dev/null +++ b/tests/golden/stocks-risk.json.golden @@ -0,0 +1,12 @@ +exit: 0 +--- stdout +{ + "debt_to_equity": 0.42, + "current_ratio": 1.18, + "roa": 0.031, + "roa_pct": 3.1, + "de_signal": "conservative", + "current_ratio_signal": "tight", + "overall_signal": "moderate risk" +} +--- stderr diff --git a/tests/golden/stocks-risk.table.golden b/tests/golden/stocks-risk.table.golden new file mode 100644 index 0000000..9888a27 --- /dev/null +++ b/tests/golden/stocks-risk.table.golden @@ -0,0 +1,15 @@ +exit: 0 +--- stdout +Risk Analysis for BBCA.JK +┌───────────────┬────────┬─────────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═════════════════════════╡ +│ Debt/Equity ┆ 0.42 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.18 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.10% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴─────────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-screen-high-volume.json.golden b/tests/golden/stocks-screen-high-volume.json.golden new file mode 100644 index 0000000..a822087 --- /dev/null +++ b/tests/golden/stocks-screen-high-volume.json.golden @@ -0,0 +1,35 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBRI.JK", + "price": 5100, + "change": 210, + "change_pct": 4.3, + "volume": 22200000, + "market_cap": 780000000000000, + "week52_high": 5400, + "week52_low": 3900, + "week52_position": 0.8, + "range_signal": "upper", + "prev_close": 4890, + "avg_volume": 18000000, + "as_of": null + }, + { + "symbol": "BBCA.JK", + "price": 9875, + "change": 117, + "change_pct": 1.2, + "volume": 12300000, + "market_cap": 1215200000000000, + "week52_high": 10500, + "week52_low": 8000, + "week52_position": 0.75, + "range_signal": "upper", + "prev_close": 9758, + "avg_volume": 10000000, + "as_of": null + } +] +--- stderr diff --git a/tests/golden/stocks-screen-high-volume.table.golden b/tests/golden/stocks-screen-high-volume.table.golden new file mode 100644 index 0000000..f9e4ee5 --- /dev/null +++ b/tests/golden/stocks-screen-high-volume.table.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout +┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐ +│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │ +╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡ +│ BBRI.JK ┆ 5,100 ┆ +210 ┆ +4.30% ┆ 22,200,000 ┆ 780,000,000,000,000 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-screen-large-cap.json.golden b/tests/golden/stocks-screen-large-cap.json.golden new file mode 100644 index 0000000..94ac385 --- /dev/null +++ b/tests/golden/stocks-screen-large-cap.json.golden @@ -0,0 +1,35 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBCA.JK", + "price": 9875, + "change": 117, + "change_pct": 1.2, + "volume": 12300000, + "market_cap": 1215200000000000, + "week52_high": 10500, + "week52_low": 8000, + "week52_position": 0.75, + "range_signal": "upper", + "prev_close": 9758, + "avg_volume": 10000000, + "as_of": null + }, + { + "symbol": "BBRI.JK", + "price": 5100, + "change": 210, + "change_pct": 4.3, + "volume": 22200000, + "market_cap": 780000000000000, + "week52_high": 5400, + "week52_low": 3900, + "week52_position": 0.8, + "range_signal": "upper", + "prev_close": 4890, + "avg_volume": 18000000, + "as_of": null + } +] +--- stderr diff --git a/tests/golden/stocks-screen-large-cap.table.golden b/tests/golden/stocks-screen-large-cap.table.golden new file mode 100644 index 0000000..5fe90da --- /dev/null +++ b/tests/golden/stocks-screen-large-cap.table.golden @@ -0,0 +1,10 @@ +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 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBRI.JK ┆ 5,100 ┆ +210 ┆ +4.30% ┆ 22,200,000 ┆ 780,000,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-screen-worst.json.golden b/tests/golden/stocks-screen-worst.json.golden new file mode 100644 index 0000000..94ac385 --- /dev/null +++ b/tests/golden/stocks-screen-worst.json.golden @@ -0,0 +1,35 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBCA.JK", + "price": 9875, + "change": 117, + "change_pct": 1.2, + "volume": 12300000, + "market_cap": 1215200000000000, + "week52_high": 10500, + "week52_low": 8000, + "week52_position": 0.75, + "range_signal": "upper", + "prev_close": 9758, + "avg_volume": 10000000, + "as_of": null + }, + { + "symbol": "BBRI.JK", + "price": 5100, + "change": 210, + "change_pct": 4.3, + "volume": 22200000, + "market_cap": 780000000000000, + "week52_high": 5400, + "week52_low": 3900, + "week52_position": 0.8, + "range_signal": "upper", + "prev_close": 4890, + "avg_volume": 18000000, + "as_of": null + } +] +--- stderr diff --git a/tests/golden/stocks-screen-worst.table.golden b/tests/golden/stocks-screen-worst.table.golden new file mode 100644 index 0000000..5fe90da --- /dev/null +++ b/tests/golden/stocks-screen-worst.table.golden @@ -0,0 +1,10 @@ +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 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBRI.JK ┆ 5,100 ┆ +210 ┆ +4.30% ┆ 22,200,000 ┆ 780,000,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-screen.json.golden b/tests/golden/stocks-screen.json.golden new file mode 100644 index 0000000..a822087 --- /dev/null +++ b/tests/golden/stocks-screen.json.golden @@ -0,0 +1,35 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBRI.JK", + "price": 5100, + "change": 210, + "change_pct": 4.3, + "volume": 22200000, + "market_cap": 780000000000000, + "week52_high": 5400, + "week52_low": 3900, + "week52_position": 0.8, + "range_signal": "upper", + "prev_close": 4890, + "avg_volume": 18000000, + "as_of": null + }, + { + "symbol": "BBCA.JK", + "price": 9875, + "change": 117, + "change_pct": 1.2, + "volume": 12300000, + "market_cap": 1215200000000000, + "week52_high": 10500, + "week52_low": 8000, + "week52_position": 0.75, + "range_signal": "upper", + "prev_close": 9758, + "avg_volume": 10000000, + "as_of": null + } +] +--- stderr diff --git a/tests/golden/stocks-screen.table.golden b/tests/golden/stocks-screen.table.golden new file mode 100644 index 0000000..f9e4ee5 --- /dev/null +++ b/tests/golden/stocks-screen.table.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout +┌─────────┬───────┬──────┬────────┬────────────┬───────────────────────┬────────────┐ +│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ MKT CAP ┆ 52W RANGE │ +╞═════════╪═══════╪══════╪════════╪════════════╪═══════════════════════╪════════════╡ +│ BBRI.JK ┆ 5,100 ┆ +210 ┆ +4.30% ┆ 22,200,000 ┆ 780,000,000,000,000 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBCA.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-sentiment.json.golden b/tests/golden/stocks-sentiment.json.golden new file mode 100644 index 0000000..e4d6d4a --- /dev/null +++ b/tests/golden/stocks-sentiment.json.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "statistics": [ + { + "time_range": "1D", + "bullish": 10, + "bearish": 2, + "neutral": 3 + }, + { + "time_range": "1W", + "bullish": 25, + "bearish": 5, + "neutral": 7 + } + ] +} +--- stderr diff --git a/tests/golden/stocks-sentiment.table.golden b/tests/golden/stocks-sentiment.table.golden new file mode 100644 index 0000000..93c28e9 --- /dev/null +++ b/tests/golden/stocks-sentiment.table.golden @@ -0,0 +1,10 @@ +exit: 0 +--- stdout +┌───────┬─────────┬─────────┬─────────┐ +│ RANGE ┆ BULLISH ┆ BEARISH ┆ NEUTRAL │ +╞═══════╪═════════╪═════════╪═════════╡ +│ 1D ┆ 10 ┆ 2 ┆ 3 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 1W ┆ 25 ┆ 5 ┆ 7 │ +└───────┴─────────┴─────────┴─────────┘ +--- stderr diff --git a/tests/golden/stocks-technical.json.golden b/tests/golden/stocks-technical.json.golden new file mode 100644 index 0000000..3e587b3 --- /dev/null +++ b/tests/golden/stocks-technical.json.golden @@ -0,0 +1,28 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "as_of": "2024-03-03", + "current_price": 9925, + "sma20": null, + "sma50": null, + "sma200": null, + "rsi14": null, + "macd": { + "line": null, + "signal": null, + "histogram": null + }, + "volume": { + "current": 14000000, + "average20": null, + "ratio20": null + }, + "signals": { + "rsi": "Neutral", + "macd": "Neutral", + "trend": "Neutral", + "overall": "Neutral" + } +} +--- stderr diff --git a/tests/golden/stocks-technical.table.golden b/tests/golden/stocks-technical.table.golden new file mode 100644 index 0000000..d1099d0 --- /dev/null +++ b/tests/golden/stocks-technical.table.golden @@ -0,0 +1,26 @@ +exit: 0 +--- stdout +Technical Analysis for BBCA.JK (2024-03-03) +┌───────────────────┬─────────────────────────────────────────────────────┬───────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════════╪═════════════════════════════════════════════════════╪═══════════════════╡ +│ Current Price ┆ 9,925 ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ SMA 20 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ SMA 50 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ SMA 200 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ RSI (14) ┆ - ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ MACD (12,26,9) ┆ -/-/- ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Trend ┆ Trend unavailable (need at least 200 daily candles) ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Volume Ratio (20) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall Signal ┆ - ┆ NEUTRAL │ +└───────────────────┴─────────────────────────────────────────────────────┴───────────────────┘ +--- stderr +info: history provider fallback active (msn -> yahoo) diff --git a/tests/golden/stocks-valuation-nonfinite.json.golden b/tests/golden/stocks-valuation-nonfinite.json.golden new file mode 100644 index 0000000..ef8159f --- /dev/null +++ b/tests/golden/stocks-valuation-nonfinite.json.golden @@ -0,0 +1,19 @@ +exit: 0 +--- stdout +{ + "pe_trailing": null, + "pe_forward": null, + "pb": 4.6, + "roe": 0.198, + "roe_pct": 19.8, + "net_margin": 0.442, + "net_margin_pct": 44.2, + "ev_ebitda": null, + "pe_signal": "no data", + "pb_signal": "expensive", + "roe_signal": "strong", + "margin_signal": "excellent", + "ev_ebitda_signal": "no data", + "overall_signal": "expensive" +} +--- stderr diff --git a/tests/golden/stocks-valuation-nonfinite.table.golden b/tests/golden/stocks-valuation-nonfinite.table.golden new file mode 100644 index 0000000..6ccd5d2 --- /dev/null +++ b/tests/golden/stocks-valuation-nonfinite.table.golden @@ -0,0 +1,21 @@ +exit: 0 +--- stdout +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬─────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═════════════════════╡ +│ P/E (Trailing) ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +44.20% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴─────────────────────┘ +--- stderr diff --git a/tests/golden/stocks-valuation.json.golden b/tests/golden/stocks-valuation.json.golden new file mode 100644 index 0000000..f795bfb --- /dev/null +++ b/tests/golden/stocks-valuation.json.golden @@ -0,0 +1,19 @@ +exit: 0 +--- stdout +{ + "pe_trailing": 25.4, + "pe_forward": null, + "pb": 4.6, + "roe": 0.198, + "roe_pct": 19.8, + "net_margin": 0.324, + "net_margin_pct": 32.4, + "ev_ebitda": null, + "pe_signal": "premium", + "pb_signal": "expensive", + "roe_signal": "strong", + "margin_signal": "excellent", + "ev_ebitda_signal": "no data", + "overall_signal": "expensive" +} +--- stderr diff --git a/tests/golden/stocks-valuation.table.golden b/tests/golden/stocks-valuation.table.golden new file mode 100644 index 0000000..2a0666c --- /dev/null +++ b/tests/golden/stocks-valuation.table.golden @@ -0,0 +1,21 @@ +exit: 0 +--- stdout +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬─────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═════════════════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +32.40% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴─────────────────────┘ +--- stderr diff --git a/tests/golden/yahoo-fundamental.json.golden b/tests/golden/yahoo-fundamental.json.golden new file mode 100644 index 0000000..8857e28 --- /dev/null +++ b/tests/golden/yahoo-fundamental.json.golden @@ -0,0 +1,41 @@ +exit: 0 +--- stdout +{ + "symbol": "BBCA.JK", + "growth": { + "revenue_growth": 0.118, + "earnings_growth": 0.121, + "revenue_growth_pct": 11.8, + "earnings_growth_pct": 12.1, + "revenue_signal": "moderate", + "earnings_signal": "moderate", + "overall_signal": "growing" + }, + "valuation": { + "pe_trailing": 25.4, + "pe_forward": 23.1, + "pb": 4.6, + "roe": 0.202, + "roe_pct": 20.2, + "net_margin": 0.385, + "net_margin_pct": 38.5, + "ev_ebitda": 21.28, + "pe_signal": "premium", + "pb_signal": "expensive", + "roe_signal": "excellent", + "margin_signal": "excellent", + "ev_ebitda_signal": "expensive", + "overall_signal": "expensive" + }, + "risk": { + "debt_to_equity": 18.5, + "current_ratio": 1.21, + "roa": 0.038, + "roa_pct": 3.8, + "de_signal": "conservative", + "current_ratio_signal": "tight", + "overall_signal": "moderate risk" + }, + "overall_signal": "mixed" +} +--- stderr diff --git a/tests/golden/yahoo-fundamental.table.golden b/tests/golden/yahoo-fundamental.table.golden new file mode 100644 index 0000000..651349a --- /dev/null +++ b/tests/golden/yahoo-fundamental.table.golden @@ -0,0 +1,49 @@ +exit: 0 +--- stdout +Fundamental Analysis for BBCA.JK + +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪════════════════════╡ +│ Revenue Growth ┆ +11.80% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ growing │ +└─────────────────┴─────────┴────────────────────┘ + +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬─────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═════════════════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ 23.10 ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +20.20% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +38.50% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ 21.28 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴─────────────────────┘ + +Risk Analysis for BBCA.JK +┌───────────────┬────────┬─────────────────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═════════════════════════╡ +│ Debt/Equity ┆ 18.50 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.21 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.80% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴─────────────────────────┘ + +Overall Signal: mixed +--- stderr diff --git a/tests/golden/yahoo-history.json.golden b/tests/golden/yahoo-history.json.golden new file mode 100644 index 0000000..423e9c6 --- /dev/null +++ b/tests/golden/yahoo-history.json.golden @@ -0,0 +1,29 @@ +exit: 0 +--- stdout +[ + { + "date": "2024-03-01", + "open": 9800, + "high": 9900, + "low": 9750, + "close": 9875, + "volume": 12300000 + }, + { + "date": "2024-03-02", + "open": 9850, + "high": 9920, + "low": 9800, + "close": 9880, + "volume": 11000000 + }, + { + "date": "2024-03-03", + "open": 9860, + "high": 9950, + "low": 9820, + "close": 9925, + "volume": 14000000 + } +] +--- stderr diff --git a/tests/golden/yahoo-history.table.golden b/tests/golden/yahoo-history.table.golden new file mode 100644 index 0000000..59954ee --- /dev/null +++ b/tests/golden/yahoo-history.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +History for BBCA.JK +┌────────────┬───────┬───────┬───────┬───────┬────────────┐ +│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ +╞════════════╪═══════╪═══════╪═══════╪═══════╪════════════╡ +│ 2024-03-01 ┆ 9,800 ┆ 9,900 ┆ 9,750 ┆ 9,875 ┆ 12,300,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2024-03-02 ┆ 9,850 ┆ 9,920 ┆ 9,800 ┆ 9,880 ┆ 11,000,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2024-03-03 ┆ 9,860 ┆ 9,950 ┆ 9,820 ┆ 9,925 ┆ 14,000,000 │ +└────────────┴───────┴───────┴───────┴───────┴────────────┘ +--- stderr diff --git a/tests/golden/yahoo-quote.json.golden b/tests/golden/yahoo-quote.json.golden new file mode 100644 index 0000000..ead2991 --- /dev/null +++ b/tests/golden/yahoo-quote.json.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +[ + { + "symbol": "BBCA.JK", + "price": 9875, + "change": 117, + "change_pct": 1.1990161918425908, + "volume": 12300000, + "market_cap": 1215200000000000, + "week52_high": 10250, + "week52_low": 7800, + "week52_position": 0.8469387755102041, + "range_signal": "upper", + "prev_close": 9758, + "avg_volume": 10000000, + "as_of": null + } +] +--- stderr diff --git a/tests/golden/yahoo-quote.table.golden b/tests/golden/yahoo-quote.table.golden new file mode 100644 index 0000000..6102d71 --- /dev/null +++ b/tests/golden/yahoo-quote.table.golden @@ -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 From 22b6dabaeb975f21fabda52847b4dbbcf7f89159 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 08:52:13 +0000 Subject: [PATCH 04/14] feat(cli): agent-native output, stdin symbols, and exit-code classes Phase 1 of docs/DESIGN.md: - Output format resolves -o > IDX_OUTPUT > general.output > auto; auto is a table on a terminal and JSON when stdout is piped or captured. `config init` now writes output = "auto". - JSON is pretty only on a terminal and compact otherwise. New `-o jsonl` prints one array item per line with the same key order. - No ANSI color when stdout is not a terminal (direct owo-colors calls now go through output::style). - `stocks quote -` and `stocks compare -` read symbols from stdin. - Exit codes by class: 2 invalid input/config, 3 not found or cache miss, 4 upstream unavailable, 5 unsupported, 1 other. Golden changes are limited to those documented kinds and were verified mechanically: JSON goldens are semantically identical, tables differ only by removed ANSI codes and the column padding comfy-table had reserved for them (cell contents identical), exit codes match each error's class, and help/completions only gain the new --output values. 17 new golden cases cover auto, jsonl, stdin, and the new errors. tests/cli.rs now pins IDX_OUTPUT=table in its harness (its tests assert table text and run piped) and parses discover JSON instead of matching pretty-printed whitespace. Live A/B against main: 16/16 stocks cases identical; ownership discover got IDX 403 on both binaries, now exiting 4 instead of 1 as intended. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- README.md | 26 ++- docs/ARCHITECTURE.md | 14 +- docs/DESIGN.md | 8 +- src/cli/cache.rs | 4 +- src/cli/mod.rs | 12 +- src/cli/ownership.rs | 37 ++-- src/cli/stocks.rs | 49 ++++- src/config.rs | 38 ++-- src/error.rs | 17 +- src/main.rs | 31 +-- src/output/json.rs | 40 +++- src/output/mod.rs | 97 +++++++--- src/output/style.rs | 41 ++++ src/output/table.rs | 2 +- tests/cli.rs | 33 ++-- tests/golden.rs | 31 ++- tests/golden/auto-format-env-table.golden | 8 + tests/golden/auto-format-piped.golden | 4 + tests/golden/cache-clear.json.golden | 5 +- tests/golden/cache-info-empty.json.golden | 8 +- tests/golden/cases.toml | 61 +++++- tests/golden/completions-bash.golden | 176 +++++++++--------- .../config-output-table-pins-format.golden | 8 + tests/golden/config-set-output-invalid.golden | 4 + tests/golden/config-set-output-jsonl.golden | 4 + .../error-compare-total-failure.json.golden | 8 +- .../error-compare-total-failure.table.golden | 2 +- .../error-fetch-bing-unsupported.json.golden | 8 +- .../error-fetch-bing-unsupported.table.golden | 2 +- ...ror-industry-only-fundamentals.json.golden | 6 +- tests/golden/error-invalid-output-env.golden | 4 + .../golden/error-invalid-provider.json.golden | 8 +- .../error-invalid-provider.table.golden | 2 +- .../error-offline-cache-miss.json.golden | 8 +- .../error-offline-cache-miss.table.golden | 2 +- .../error-offline-no-cache-flags.json.golden | 8 +- .../error-offline-no-cache-flags.table.golden | 2 +- .../golden/error-provider-failure.json.golden | 8 +- .../error-provider-failure.table.golden | 2 +- tests/golden/error-screen-region.json.golden | 8 +- tests/golden/error-screen-region.jsonl.golden | 4 + tests/golden/error-screen-region.table.golden | 2 +- tests/golden/error-stdin-empty.json.golden | 4 + tests/golden/error-stdin-empty.table.golden | 4 + ...rror-yahoo-profile-unsupported.json.golden | 8 +- ...ror-yahoo-profile-unsupported.table.golden | 2 +- tests/golden/help-cache.golden | 2 +- tests/golden/help-config.golden | 2 +- tests/golden/help-ownership.golden | 4 +- tests/golden/help-root.golden | 4 +- tests/golden/help-stocks.golden | 2 +- tests/golden/ownership-changes.json.golden | 107 +---------- tests/golden/ownership-changes.jsonl.golden | 16 ++ tests/golden/ownership-changes.table.golden | 58 +++--- .../ownership-concentration.json.golden | 43 +---- .../ownership-cross-holders.json.golden | 173 +---------------- tests/golden/ownership-entity.json.golden | 24 +-- tests/golden/ownership-entity.table.golden | 4 +- tests/golden/ownership-graph-dot.json.golden | 56 +----- tests/golden/ownership-graph.json.golden | 56 +----- .../ownership-import-archive-txt.json.golden | 9 +- .../golden/ownership-import-xlsx.json.golden | 9 +- tests/golden/ownership-releases.json.golden | 27 +-- .../golden/ownership-resolve-map.json.golden | 6 +- .../ownership-resolve-merge.json.golden | 9 +- tests/golden/ownership-search.json.golden | 9 +- .../golden/ownership-ticker-empty.json.golden | 8 +- .../ownership-ticker-empty.table.golden | 2 +- .../golden/ownership-ticker-ksei.json.golden | 65 +------ .../golden/ownership-ticker-ksei.table.golden | 4 +- tests/golden/ownership-ticker.json.golden | 65 +------ tests/golden/ownership-ticker.table.golden | 4 +- tests/golden/stocks-compare-stdin.json.golden | 4 + .../golden/stocks-compare-stdin.table.golden | 23 +++ tests/golden/stocks-compare.json.golden | 79 +------- tests/golden/stocks-compare.table.golden | 40 ++-- ...tocks-earnings-forecast-annual.json.golden | 20 +- ...cks-earnings-history-quarterly.json.golden | 20 +- tests/golden/stocks-earnings.json.golden | 54 +----- .../stocks-financials-income.json.golden | 19 +- tests/golden/stocks-financials.json.golden | 34 +--- tests/golden/stocks-fundamental.json.golden | 39 +--- tests/golden/stocks-fundamental.jsonl.golden | 4 + tests/golden/stocks-fundamental.table.golden | 84 ++++----- tests/golden/stocks-growth.json.golden | 10 +- tests/golden/stocks-growth.table.golden | 20 +- tests/golden/stocks-history-msn.json.golden | 27 +-- tests/golden/stocks-history-msn.table.golden | 2 +- tests/golden/stocks-history.json.golden | 27 +-- tests/golden/stocks-history.jsonl.golden | 6 + tests/golden/stocks-history.table.golden | 2 +- tests/golden/stocks-insights.json.golden | 14 +- tests/golden/stocks-news.json.golden | 13 +- tests/golden/stocks-profile.json.golden | 25 +-- tests/golden/stocks-quote-multi.json.golden | 33 +--- tests/golden/stocks-quote-multi.jsonl.golden | 5 + .../stocks-quote-offline-warm.json.golden | 18 +- tests/golden/stocks-quote-stdin.json.golden | 4 + tests/golden/stocks-quote-stdin.table.golden | 10 + tests/golden/stocks-quote.json.golden | 18 +- tests/golden/stocks-risk.json.golden | 10 +- tests/golden/stocks-risk.table.golden | 24 +-- .../stocks-screen-high-volume.json.golden | 33 +--- .../stocks-screen-large-cap.json.golden | 33 +--- tests/golden/stocks-screen-worst.json.golden | 33 +--- tests/golden/stocks-screen.json.golden | 33 +--- tests/golden/stocks-sentiment.json.golden | 18 +- tests/golden/stocks-technical.json.golden | 26 +-- tests/golden/stocks-technical.table.golden | 44 ++--- .../stocks-valuation-nonfinite.json.golden | 17 +- .../stocks-valuation-nonfinite.table.golden | 36 ++-- tests/golden/stocks-valuation.json.golden | 17 +- tests/golden/stocks-valuation.table.golden | 36 ++-- tests/golden/yahoo-fundamental.json.golden | 39 +--- tests/golden/yahoo-fundamental.table.golden | 84 ++++----- tests/golden/yahoo-history.json.golden | 27 +-- tests/golden/yahoo-history.table.golden | 2 +- tests/golden/yahoo-quote.json.golden | 18 +- 119 files changed, 919 insertions(+), 1895 deletions(-) create mode 100644 src/output/style.rs create mode 100644 tests/golden/auto-format-env-table.golden create mode 100644 tests/golden/auto-format-piped.golden create mode 100644 tests/golden/config-output-table-pins-format.golden create mode 100644 tests/golden/config-set-output-invalid.golden create mode 100644 tests/golden/config-set-output-jsonl.golden create mode 100644 tests/golden/error-invalid-output-env.golden create mode 100644 tests/golden/error-screen-region.jsonl.golden create mode 100644 tests/golden/error-stdin-empty.json.golden create mode 100644 tests/golden/error-stdin-empty.table.golden create mode 100644 tests/golden/ownership-changes.jsonl.golden create mode 100644 tests/golden/stocks-compare-stdin.json.golden create mode 100644 tests/golden/stocks-compare-stdin.table.golden create mode 100644 tests/golden/stocks-fundamental.jsonl.golden create mode 100644 tests/golden/stocks-history.jsonl.golden create mode 100644 tests/golden/stocks-quote-multi.jsonl.golden create mode 100644 tests/golden/stocks-quote-stdin.json.golden create mode 100644 tests/golden/stocks-quote-stdin.table.golden diff --git a/Cargo.toml b/Cargo.toml index c2a7f1a..0cac163 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ clap = { version = "4", features = ["derive"] } clap_complete = "4" ureq = { version = "3", features = ["json", "cookies"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } comfy-table = "7" owo-colors = "4" toml = "0.8" diff --git a/README.md b/README.md index 6f2cde1..ce7de8d 100644 --- a/README.md +++ b/README.md @@ -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,26 @@ 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 low-pe | 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 ` 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`. + ## Documentation - [docs/INSTALL.md](docs/INSTALL.md): install script options diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e16dec0..030e959 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -144,11 +144,21 @@ CLI flags > environment variables > config file > defaults ## Error Strategy - `IdxError` enum (thiserror) with structured error codes - Table mode: human-readable error on stderr -- JSON mode: `{"error": true, "code": "...", "message": "..."}` -- Exit code 0 on success, non-zero on failure +- JSON and JSONL modes: `{"error": true, "code": "...", "message": "..."}` on stderr, stdout empty +- Exit codes by class: `0` success, `2` `INVALIDINPUT`/`CONFIGERROR` and clap usage + errors, `3` `SYMBOLNOTFOUND`/`NOMARKETDATA`/`CACHEMISS`, `4` `HTTP`/`RATELIMITED`/ + `PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`, `5` `UNSUPPORTED`, `1` everything else - A totally failed `stocks compare` emits one structured error in JSON mode; per-symbol warnings are retained for partial success, not printed ahead of a total-failure error. ## CLI Output and Offline Cache +- Output format resolves as `-o` flag > `IDX_OUTPUT` > `general.output` > `auto`. + `auto` is a table when stdout is a terminal and JSON otherwise + (`std::io::IsTerminal`). +- JSON is pretty-printed only on a terminal; piped output is compact. `jsonl` + prints each array item on its own line with the same key order as `json`, + and a single object as one line. +- ANSI styling goes through `output::style` and is disabled when stdout is not a + terminal or color is turned off. - `version`, `cache info`, `cache clear`, and `ownership resolve map|merge` emit JSON objects when JSON output is selected. - `stocks quote` and `stocks compare` require at least one symbol. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b126234..fbe9c3f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; nothing implemented yet. +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-1 done. ## Goal @@ -164,8 +164,8 @@ is another 20-30% smaller than pretty JSON. Already true. - No ANSI color when stdout is not a terminal. Today some table headers emit bold codes even when piped. -- Commands that take symbols accept `-` to read them from stdin, one per line or - whitespace-separated: +- `stocks quote` and `stocks compare` accept `-` to read symbols from stdin, + separated by whitespace or commas: `idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare -` - Exit codes by error class (today every error exits `1`): @@ -175,7 +175,7 @@ is another 20-30% smaller than pretty JSON. | 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... | | 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` | | 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` | - | 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE` | + | 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR` | | 5 | unsupported | `UNSUPPORTED` | - JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths diff --git a/src/cli/cache.rs b/src/cli/cache.rs index 3a8ae31..8b190a7 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -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, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d645bd6..4a77ca7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, + #[arg( + short, + long, + value_enum, + global = true, + help = "Output format [default: auto = table on a terminal, json when piped]" + )] + pub output: Option, #[arg(long, global = true, help = "Disable colored output")] pub no_color: bool, #[arg(short, long, global = true, help = "Suppress non-essential output")] diff --git a/src/cli/ownership.rs b/src/cli/ownership.rs index cf4f369..eeadaea 100644 --- a/src/cli/ownership.rs +++ b/src/cli/ownership.rs @@ -2,16 +2,15 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use crate::output::style::Paint; 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::table::format_idr; use crate::ownership::types::{ @@ -219,7 +218,7 @@ fn handle_sync(args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> { let db_path = db::db_path(config)?; let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&result); } @@ -269,7 +268,7 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr let family = parse_discovery_family(&args.family)?; let reports = remote::discover_idx_ownership_reports(family, args.limit)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&reports); } @@ -337,7 +336,7 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> ))); } - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&data); } @@ -407,7 +406,7 @@ fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> 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); } @@ -440,7 +439,7 @@ 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)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&entities); } @@ -475,7 +474,7 @@ fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<() let conn = db::open_db(config)?; let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&rows); } @@ -509,7 +508,7 @@ fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result< 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); } @@ -551,14 +550,14 @@ fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { let flow = db::query_bing_flow(&conn, ticker_id)?; let Some(flow) = flow else { - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&Option::::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); } @@ -599,7 +598,7 @@ 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)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { #[derive(Serialize)] struct GraphOutput<'a> { nodes: &'a [crate::ownership::types::GraphNode], @@ -630,7 +629,7 @@ 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)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&changes); } @@ -679,7 +678,7 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError ResolveCommand::Unresolved { limit } => { let rows = db::list_unresolved(&conn, *limit)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&rows); } @@ -718,7 +717,7 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError } ResolveCommand::Map { alias, entity } => { db::manual_map(&conn, alias, entity)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&serde_json::json!({ "action": "mapped", "alias": alias, @@ -753,7 +752,7 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError 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, @@ -813,7 +812,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> source_url: import_input.source_url, }; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&result); } @@ -897,7 +896,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> source_url: release.source_url.clone(), }; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&result); } @@ -915,7 +914,7 @@ fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> { let conn = db::open_db(config)?; let releases = db::query_releases(&conn)?; - if matches!(config.output, OutputFormat::Json) { + if config.output.is_json() { return json::print_json(&releases); } diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index a596706..430c1ce 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1,4 +1,5 @@ use std::cmp::Ordering; +use std::io::Read; use clap::{Args, Subcommand, ValueEnum}; use serde::{Serialize, de::DeserializeOwned}; @@ -50,6 +51,34 @@ const SCREENER_FILTERS: &[&str] = &[ const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"]; +/// Splits comma-separated symbol arguments; `-` reads symbols from stdin, +/// separated by whitespace or commas. +fn expand_symbols(args: &[String]) -> Result, 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 = 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) +} + fn cache_bucket(provider: ProviderKind, key: &str) -> String { format!("{}-{key}", provider.as_str()) } @@ -124,10 +153,10 @@ pub struct StocksCmd { 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" + 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. + /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. #[arg(required = true)] symbols: Vec, }, @@ -228,10 +257,10 @@ pub enum StocksSubcommand { }, #[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" + 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. + /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. #[arg(required = true)] symbols: Vec, }, @@ -257,8 +286,8 @@ pub fn handle( StocksSubcommand::Quote { symbols } => { let quote_bucket = cache_bucket(provider.kind(), "quote"); let mut quotes = Vec::new(); - for sym in symbols.iter().flat_map(|s| s.split(',')) { - let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; + for sym in expand_symbols(symbols)? { + let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { quotes.push(q); continue; @@ -305,7 +334,7 @@ pub fn handle( history_provider(provider.kind(), history_mode, verbose)?; if matches!(history_mode, HistoryProviderKind::Auto) && history_source != provider.kind() - && !matches!(config.output, crate::output::OutputFormat::Json) + && !config.output.is_json() { runtime::info(format!( "history provider fallback active ({} -> {})", @@ -373,7 +402,7 @@ pub fn handle( history_provider(provider.kind(), history_mode, verbose)?; if matches!(history_mode, HistoryProviderKind::Auto) && history_source != provider.kind() - && !matches!(config.output, crate::output::OutputFormat::Json) + && !config.output.is_json() { runtime::info(format!( "history provider fallback active ({} -> {})", @@ -649,8 +678,8 @@ pub fn handle( let mut reports: Vec = Vec::new(); let mut failures = Vec::new(); - for sym in symbols.iter().flat_map(|s| s.split(',')) { - let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; + for sym in expand_symbols(symbols)? { + let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; match fetch_fundamental_analysis_report( &cache, provider.market(), diff --git a/src/config.rs b/src/config.rs index a0f55d3..b7b3ba9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::IsTerminal; use std::path::PathBuf; use clap::ValueEnum; @@ -7,7 +8,7 @@ use serde::Deserialize; use crate::cli::Cli; use crate::error::IdxError; -use crate::output::OutputFormat; +use crate::output::{OutputFormat, OutputSetting}; #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -92,7 +93,7 @@ struct FileGeneral { provider: Option, history_provider: Option, exchange: Option, - output: Option, + output: Option, color: Option, } @@ -108,7 +109,7 @@ impl Default for IdxConfig { provider: ProviderKind::Msn, history_provider: HistoryProviderKind::Auto, exchange: "JK".to_string(), - output: OutputFormat::Table, + output: OutputSetting::Auto.resolve(stdout_is_terminal()), no_color: false, quote_ttl: 300, fundamental_ttl: 3600, @@ -130,15 +131,12 @@ impl IdxConfig { cfg.exchange = exchange; } if let Ok(output) = std::env::var("IDX_OUTPUT") { - cfg.output = if output.eq_ignore_ascii_case("json") { - OutputFormat::Json - } else if output.eq_ignore_ascii_case("table") { - OutputFormat::Table - } else { - return Err(IdxError::ConfigError(format!( - "invalid IDX_OUTPUT value: '{output}', expected 'json' or 'table'" - ))); - }; + let setting = OutputSetting::parse(&output).ok_or_else(|| { + IdxError::ConfigError(format!( + "invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'" + )) + })?; + cfg.output = setting.resolve(stdout_is_terminal()); } if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true"); @@ -151,9 +149,9 @@ impl IdxConfig { } if let Some(output) = cli.output { - cfg.output = output; + cfg.output = output.resolve(stdout_is_terminal()); } - cfg.no_color = cfg.no_color || cli.no_color; + cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal(); Ok(cfg) } @@ -176,7 +174,7 @@ impl IdxConfig { cfg.exchange = exchange; } if let Some(output) = general.output { - cfg.output = output; + cfg.output = output.resolve(stdout_is_terminal()); } if let Some(color) = general.color { cfg.no_color = !color; @@ -216,8 +214,12 @@ fn parse_env_ttl(name: &str) -> Result, IdxError> { Ok(Some(parsed)) } +pub fn stdout_is_terminal() -> bool { + std::io::stdout().is_terminal() +} + pub fn default_config_toml() -> String { - "[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() + "[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"auto\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() } pub fn config_path() -> Result { @@ -292,9 +294,9 @@ fn normalize_config_value(key: &str, value: &str) -> Result { - if !value.eq_ignore_ascii_case("table") && !value.eq_ignore_ascii_case("json") { + if OutputSetting::parse(value).is_none() { return Err(IdxError::InvalidInput(format!( - "invalid output format '{value}': expected 'table' or 'json'" + "invalid output format '{value}': expected 'auto', 'table', 'json', or 'jsonl'" ))); } Ok(toml::Value::String(value.to_ascii_lowercase())) diff --git a/src/error.rs b/src/error.rs index 37ed148..7df9dc2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -76,8 +76,23 @@ impl IdxError { } } + /// Exit status by error class, so scripts can branch without parsing + /// stderr. Clap usage errors also exit 2. See docs/DESIGN.md. pub fn exit_code(&self) -> i32 { - 1 + match self.code() { + ErrorCode::InvalidInput | ErrorCode::ConfigError => 2, + ErrorCode::SymbolNotFound | ErrorCode::NoMarketData | ErrorCode::CacheMiss => 3, + ErrorCode::Http + | ErrorCode::RateLimited + | ErrorCode::ProviderUnavailable + | ErrorCode::Offline + | ErrorCode::AuthError => 4, + ErrorCode::Unsupported => 5, + ErrorCode::Io + | ErrorCode::ParseError + | ErrorCode::DatabaseError + | ErrorCode::PdfParseError => 1, + } } } diff --git a/src/main.rs b/src/main.rs index 499195f..0d38ea5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ 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}; fn main() { if let Err(err) = run() { @@ -62,16 +62,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(); @@ -119,13 +122,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()) } diff --git a/src/output/json.rs b/src/output/json.rs index 9c094f0..1b8d223 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -1,10 +1,44 @@ +use std::sync::atomic::{AtomicU8, Ordering}; + use serde::Serialize; use crate::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(value: &T) -> Result<(), IdxError> { - let out = - serde_json::to_string_pretty(value).map_err(|e| IdxError::ParseError(e.to_string()))?; - println!("{out}"); + 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::>(&compact) { + Ok(items) => { + for item in items { + println!("{}", item.get()); + } + } + Err(_) => println!("{compact}"), + } + } + } Ok(()) } diff --git a/src/output/mod.rs b/src/output/mod.rs index d82af17..4559a2a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,6 +1,9 @@ pub mod json; +pub mod style; pub mod table; +use std::io::IsTerminal; + use chrono::NaiveDate; use clap::ValueEnum; use serde::{Deserialize, Serialize}; @@ -13,12 +16,48 @@ use crate::api::types::{ }; use crate::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 { + ::from_str(value, true).ok() + } + + pub fn resolve(self, stdout_is_terminal: bool) -> OutputFormat { + match self { + Self::Auto if stdout_is_terminal => OutputFormat::Table, + Self::Auto | Self::Json => OutputFormat::Json, + Self::Table => OutputFormat::Table, + Self::Jsonl => OutputFormat::Jsonl, + } + } +} + +/// Resolved output format used by renderers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OutputFormat { #[default] Table, Json, + Jsonl, +} + +impl OutputFormat { + /// True for every machine-readable format (JSON and JSON Lines). + pub fn is_json(self) -> bool { + matches!(self, Self::Json | Self::Jsonl) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -56,7 +95,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 +106,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 +117,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 +129,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 +141,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 +153,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 +164,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 +175,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 +192,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 +231,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 => { - let payload = serde_json::json!({ - "error": true, - "code": format!("{:?}", err.code()).to_uppercase(), - "message": err.to_string() - }); - eprintln!( - "{}", - serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) - ); - } + if !format.is_json() { + eprintln!("Error: {err}"); + return; } + let payload = serde_json::json!({ + "error": true, + "code": format!("{:?}", err.code()).to_uppercase(), + "message": err.to_string() + }); + let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal(); + let rendered = if pretty { + serde_json::to_string_pretty(&payload) + } else { + serde_json::to_string(&payload) + }; + eprintln!("{}", rendered.unwrap_or_else(|_| "{}".to_string())); } diff --git a/src/output/style.rs b/src/output/style.rs new file mode 100644 index 0000000..ee72e50 --- /dev/null +++ b/src/output/style.rs @@ -0,0 +1,41 @@ +//! Terminal styling that turns itself off when color is disabled or stdout is +//! not a terminal, so piped output never carries ANSI escape codes. + +use std::fmt::Display; +use std::sync::atomic::{AtomicBool, Ordering}; + +use owo_colors::OwoColorize; + +static COLOR: AtomicBool = AtomicBool::new(true); + +pub fn set_color(enabled: bool) { + COLOR.store(enabled, Ordering::Relaxed); +} + +fn paint(value: T, styled: impl FnOnce(&T) -> String) -> String { + if COLOR.load(Ordering::Relaxed) { + styled(&value) + } else { + value.to_string() + } +} + +pub trait Paint: Display + Sized { + fn bold(self) -> String { + paint(self, |v| OwoColorize::bold(v).to_string()) + } + fn green(self) -> String { + paint(self, |v| OwoColorize::green(v).to_string()) + } + fn red(self) -> String { + paint(self, |v| OwoColorize::red(v).to_string()) + } + fn yellow(self) -> String { + paint(self, |v| OwoColorize::yellow(v).to_string()) + } + fn blue(self) -> String { + paint(self, |v| OwoColorize::blue(v).to_string()) + } +} + +impl Paint for T {} diff --git a/src/output/table.rs b/src/output/table.rs index 4257345..2f26f94 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -1,5 +1,5 @@ +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; diff --git a/tests/cli.rs b/tests/cli.rs index 13f95f5..5e3f34d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -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; }; @@ -111,6 +114,10 @@ fn bin_with_root(root: &Path) -> Command { let mut cmd = bin(); cmd.current_dir(env!("CARGO_MANIFEST_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 @@ -1271,18 +1278,18 @@ fn ownership_discover_json_includes_status() { }"#; let json_url = spawn_single_response_server("application/json", body.to_string()); - test_bin("ownership-discover-json-status") - .env("IDX_CURL_IMPERSONATE_BIN", "curl") - .env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url) - .env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL) - .env( - "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\"")); + 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) + .env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL) + .env( + "IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL", + "http://127.0.0.1/pengumuman", + ) + .args(["-o", "json", "ownership", "discover", "--limit", "1"]), + ); + assert_eq!(discovered[0]["status"], "supported"); } #[test] diff --git a/tests/golden.rs b/tests/golden.rs index 884abf2..d6c5aba 100644 --- a/tests/golden.rs +++ b/tests/golden.rs @@ -10,8 +10,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::OnceLock; use serde::Deserialize; @@ -40,6 +41,9 @@ struct Case { /// Commands run first in the same environment; their output is ignored. #[serde(default)] before: Vec>, + /// Text piped to the command's stdin. + #[serde(default)] + stdin: Option, } fn default_formats() -> Vec { @@ -56,6 +60,15 @@ fn work_dir() -> PathBuf { /// Runs idx with an isolated home under `root` and only the given IDX_* env. fn idx(root: &Path, args: &[String], env: &BTreeMap) -> std::process::Output { + idx_with_stdin(root, args, env, None) +} + +fn idx_with_stdin( + root: &Path, + args: &[String], + env: &BTreeMap, + stdin: Option<&str>, +) -> std::process::Output { let mut cmd = Command::new(IDX); cmd.current_dir(REPO).args(args); for (key, _) in std::env::vars_os() { @@ -68,8 +81,17 @@ fn idx(root: &Path, args: &[String], env: &BTreeMap) -> std::pro .env("XDG_CACHE_HOME", root.join("cache")) .env("XDG_DATA_HOME", root.join("data")) .env("IDX_USE_MOCK_PROVIDER", "1") - .envs(env); - cmd.output().expect("run idx") + .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 @@ -161,7 +183,8 @@ fn cli_output_matches_golden_files() { idx(&root, before, &case.env); } - let actual = render(&root, &idx(&root, &args, &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"); diff --git a/tests/golden/auto-format-env-table.golden b/tests/golden/auto-format-env-table.golden new file mode 100644 index 0000000..6102d71 --- /dev/null +++ b/tests/golden/auto-format-env-table.golden @@ -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 diff --git a/tests/golden/auto-format-piped.golden b/tests/golden/auto-format-piped.golden new file mode 100644 index 0000000..9df5da7 --- /dev/null +++ b/tests/golden/auto-format-piped.golden @@ -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 diff --git a/tests/golden/cache-clear.json.golden b/tests/golden/cache-clear.json.golden index ac869ff..d8fca10 100644 --- a/tests/golden/cache-clear.json.golden +++ b/tests/golden/cache-clear.json.golden @@ -1,7 +1,4 @@ exit: 0 --- stdout -{ - "failed": [], - "removed": 0 -} +{"failed":[],"removed":0} --- stderr diff --git a/tests/golden/cache-info-empty.json.golden b/tests/golden/cache-info-empty.json.golden index eb5a389..e68ed73 100644 --- a/tests/golden/cache-info-empty.json.golden +++ b/tests/golden/cache-info-empty.json.golden @@ -1,10 +1,4 @@ exit: 0 --- stdout -{ - "files": 0, - "newest": null, - "oldest": null, - "path": "/cache/idx", - "size_bytes": 0 -} +{"files":0,"newest":null,"oldest":null,"path":"/cache/idx","size_bytes":0} --- stderr diff --git a/tests/golden/cases.toml b/tests/golden/cases.toml index f81a4f0..2a44511 100644 --- a/tests/golden/cases.toml +++ b/tests/golden/cases.toml @@ -1,9 +1,11 @@ # Golden output cases for tests/golden.rs. # # Each case runs once per entry in `formats` (default: table and json) with -# `-o ` prepended; `formats = []` runs the args as given. 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. +# `-o ` 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. @@ -62,6 +64,22 @@ 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"] @@ -78,10 +96,12 @@ 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]] @@ -111,6 +131,7 @@ args = ["stocks", "risk", "BBCA"] [[case]] name = "stocks-fundamental" +formats = ["table", "json", "jsonl"] args = ["stocks", "fundamental", "BBCA"] [[case]] @@ -169,6 +190,38 @@ args = ["stocks", "screen", "--filter", "high-volume"] 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"] @@ -214,6 +267,7 @@ env = { IDX_MOCK_MSN_KEYRATIOS_FIXTURE = "tests/fixtures/msn_keyratios_industry_ [[case]] name = "error-screen-region" +formats = ["table", "json", "jsonl"] args = ["stocks", "screen", "--region", "eu"] [[case]] @@ -298,6 +352,7 @@ ownership = true [[case]] name = "ownership-changes" +formats = ["table", "json", "jsonl"] args = ["ownership", "changes", "--from", "2026-01-30", "--to", "2026-02-27"] ownership = true diff --git a/tests/golden/completions-bash.golden b/tests/golden/completions-bash.golden index 5cee459..6439682 100644 --- a/tests/golden/completions-bash.golden +++ b/tests/golden/completions-bash.golden @@ -446,11 +446,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -468,11 +468,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -490,11 +490,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -568,11 +568,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -590,11 +590,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -612,11 +612,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -634,11 +634,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -740,11 +740,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -762,11 +762,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -784,11 +784,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1436,11 +1436,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1466,11 +1466,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1496,11 +1496,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1526,11 +1526,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1556,11 +1556,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1578,11 +1578,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1600,11 +1600,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1630,11 +1630,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1916,11 +1916,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1938,11 +1938,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -1960,11 +1960,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2052,11 +2052,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2074,11 +2074,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2100,11 +2100,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2126,11 +2126,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2152,11 +2152,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2178,11 +2178,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2200,11 +2200,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2222,11 +2222,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2244,11 +2244,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2270,11 +2270,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2292,11 +2292,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2314,11 +2314,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2586,11 +2586,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2608,11 +2608,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2634,11 +2634,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2656,11 +2656,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2678,11 +2678,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2700,11 +2700,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2734,11 +2734,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2756,11 +2756,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2782,11 +2782,11 @@ _idx() { return 0 ;; --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2804,11 +2804,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) @@ -2826,11 +2826,11 @@ _idx() { fi case "${prev}" in --output) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; -o) - COMPREPLY=($(compgen -W "table json" -- "${cur}")) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; *) diff --git a/tests/golden/config-output-table-pins-format.golden b/tests/golden/config-output-table-pins-format.golden new file mode 100644 index 0000000..6102d71 --- /dev/null +++ b/tests/golden/config-output-table-pins-format.golden @@ -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 diff --git a/tests/golden/config-set-output-invalid.golden b/tests/golden/config-set-output-invalid.golden new file mode 100644 index 0000000..4260d21 --- /dev/null +++ b/tests/golden/config-set-output-invalid.golden @@ -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'"} diff --git a/tests/golden/config-set-output-jsonl.golden b/tests/golden/config-set-output-jsonl.golden new file mode 100644 index 0000000..aecf1eb --- /dev/null +++ b/tests/golden/config-set-output-jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +ok +--- stderr diff --git a/tests/golden/error-compare-total-failure.json.golden b/tests/golden/error-compare-total-failure.json.golden index 9c72e99..485ff72 100644 --- a/tests/golden/error-compare-total-failure.json.golden +++ b/tests/golden/error-compare-total-failure.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 4 --- stdout --- stderr -{ - "code": "PROVIDERUNAVAILABLE", - "error": true, - "message": "provider unavailable" -} +{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"} diff --git a/tests/golden/error-compare-total-failure.table.golden b/tests/golden/error-compare-total-failure.table.golden index fc7d924..711b361 100644 --- a/tests/golden/error-compare-total-failure.table.golden +++ b/tests/golden/error-compare-total-failure.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 4 --- stdout --- stderr Error: provider unavailable diff --git a/tests/golden/error-fetch-bing-unsupported.json.golden b/tests/golden/error-fetch-bing-unsupported.json.golden index 1c73d67..5160a6b 100644 --- a/tests/golden/error-fetch-bing-unsupported.json.golden +++ b/tests/golden/error-fetch-bing-unsupported.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 5 --- stdout --- stderr -{ - "code": "UNSUPPORTED", - "error": true, - "message": "unsupported: --fetch-bing import is not implemented yet" -} +{"code":"UNSUPPORTED","error":true,"message":"unsupported: --fetch-bing import is not implemented yet"} diff --git a/tests/golden/error-fetch-bing-unsupported.table.golden b/tests/golden/error-fetch-bing-unsupported.table.golden index bf6ba34..0beac40 100644 --- a/tests/golden/error-fetch-bing-unsupported.table.golden +++ b/tests/golden/error-fetch-bing-unsupported.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 5 --- stdout --- stderr Error: unsupported: --fetch-bing import is not implemented yet diff --git a/tests/golden/error-industry-only-fundamentals.json.golden b/tests/golden/error-industry-only-fundamentals.json.golden index d8fb2a5..174e400 100644 --- a/tests/golden/error-industry-only-fundamentals.json.golden +++ b/tests/golden/error-industry-only-fundamentals.json.golden @@ -1,8 +1,4 @@ exit: 1 --- stdout --- stderr -{ - "code": "PARSEERROR", - "error": true, - "message": "parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled" -} +{"code":"PARSEERROR","error":true,"message":"parse error: unsupported: company fundamentals unavailable from MSN; industry fallback is disabled"} diff --git a/tests/golden/error-invalid-output-env.golden b/tests/golden/error-invalid-output-env.golden new file mode 100644 index 0000000..ff8cf52 --- /dev/null +++ b/tests/golden/error-invalid-output-env.golden @@ -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'"} diff --git a/tests/golden/error-invalid-provider.json.golden b/tests/golden/error-invalid-provider.json.golden index cb67653..7e9cef0 100644 --- a/tests/golden/error-invalid-provider.json.golden +++ b/tests/golden/error-invalid-provider.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr -{ - "code": "CONFIGERROR", - "error": true, - "message": "config error: invalid provider 'bogus' (expected yahoo or msn)" -} +{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"} diff --git a/tests/golden/error-invalid-provider.table.golden b/tests/golden/error-invalid-provider.table.golden index 8b5ee37..37464ed 100644 --- a/tests/golden/error-invalid-provider.table.golden +++ b/tests/golden/error-invalid-provider.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr Error: config error: invalid provider 'bogus' (expected yahoo or msn) diff --git a/tests/golden/error-offline-cache-miss.json.golden b/tests/golden/error-offline-cache-miss.json.golden index 6006ad7..44b093f 100644 --- a/tests/golden/error-offline-cache-miss.json.golden +++ b/tests/golden/error-offline-cache-miss.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 3 --- stdout --- stderr -{ - "code": "CACHEMISS", - "error": true, - "message": "cache miss: msn-quote/BBCA.JK" -} +{"code":"CACHEMISS","error":true,"message":"cache miss: msn-quote/BBCA.JK"} diff --git a/tests/golden/error-offline-cache-miss.table.golden b/tests/golden/error-offline-cache-miss.table.golden index 27e5af2..2b09a39 100644 --- a/tests/golden/error-offline-cache-miss.table.golden +++ b/tests/golden/error-offline-cache-miss.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 3 --- stdout --- stderr Error: cache miss: msn-quote/BBCA.JK diff --git a/tests/golden/error-offline-no-cache-flags.json.golden b/tests/golden/error-offline-no-cache-flags.json.golden index 8d9b175..6c243ed 100644 --- a/tests/golden/error-offline-no-cache-flags.json.golden +++ b/tests/golden/error-offline-no-cache-flags.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr -{ - "code": "INVALIDINPUT", - "error": true, - "message": "invalid input: cannot combine --offline with --no-cache" -} +{"code":"INVALIDINPUT","error":true,"message":"invalid input: cannot combine --offline with --no-cache"} diff --git a/tests/golden/error-offline-no-cache-flags.table.golden b/tests/golden/error-offline-no-cache-flags.table.golden index 0dcead6..10a92aa 100644 --- a/tests/golden/error-offline-no-cache-flags.table.golden +++ b/tests/golden/error-offline-no-cache-flags.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr Error: invalid input: cannot combine --offline with --no-cache diff --git a/tests/golden/error-provider-failure.json.golden b/tests/golden/error-provider-failure.json.golden index 9c72e99..485ff72 100644 --- a/tests/golden/error-provider-failure.json.golden +++ b/tests/golden/error-provider-failure.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 4 --- stdout --- stderr -{ - "code": "PROVIDERUNAVAILABLE", - "error": true, - "message": "provider unavailable" -} +{"code":"PROVIDERUNAVAILABLE","error":true,"message":"provider unavailable"} diff --git a/tests/golden/error-provider-failure.table.golden b/tests/golden/error-provider-failure.table.golden index fc7d924..711b361 100644 --- a/tests/golden/error-provider-failure.table.golden +++ b/tests/golden/error-provider-failure.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 4 --- stdout --- stderr Error: provider unavailable diff --git a/tests/golden/error-screen-region.json.golden b/tests/golden/error-screen-region.json.golden index e3ad825..8cf7204 100644 --- a/tests/golden/error-screen-region.json.golden +++ b/tests/golden/error-screen-region.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr -{ - "code": "INVALIDINPUT", - "error": true, - "message": "invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)" -} +{"code":"INVALIDINPUT","error":true,"message":"invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp)"} diff --git a/tests/golden/error-screen-region.jsonl.golden b/tests/golden/error-screen-region.jsonl.golden new file mode 100644 index 0000000..8cf7204 --- /dev/null +++ b/tests/golden/error-screen-region.jsonl.golden @@ -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)"} diff --git a/tests/golden/error-screen-region.table.golden b/tests/golden/error-screen-region.table.golden index 39ac5be..4b13a9e 100644 --- a/tests/golden/error-screen-region.table.golden +++ b/tests/golden/error-screen-region.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 2 --- stdout --- stderr Error: invalid input: invalid screener region 'eu' (expected one of: id, us, sg, hk, jp) diff --git a/tests/golden/error-stdin-empty.json.golden b/tests/golden/error-stdin-empty.json.golden new file mode 100644 index 0000000..512e7c8 --- /dev/null +++ b/tests/golden/error-stdin-empty.json.golden @@ -0,0 +1,4 @@ +exit: 2 +--- stdout +--- stderr +{"code":"INVALIDINPUT","error":true,"message":"invalid input: no symbols read from stdin"} diff --git a/tests/golden/error-stdin-empty.table.golden b/tests/golden/error-stdin-empty.table.golden new file mode 100644 index 0000000..9b22c0f --- /dev/null +++ b/tests/golden/error-stdin-empty.table.golden @@ -0,0 +1,4 @@ +exit: 2 +--- stdout +--- stderr +Error: invalid input: no symbols read from stdin diff --git a/tests/golden/error-yahoo-profile-unsupported.json.golden b/tests/golden/error-yahoo-profile-unsupported.json.golden index 237c99d..12d072c 100644 --- a/tests/golden/error-yahoo-profile-unsupported.json.golden +++ b/tests/golden/error-yahoo-profile-unsupported.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 5 --- stdout --- stderr -{ - "code": "UNSUPPORTED", - "error": true, - "message": "unsupported: BBCA.JK: command requires --provider msn" -} +{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: command requires --provider msn"} diff --git a/tests/golden/error-yahoo-profile-unsupported.table.golden b/tests/golden/error-yahoo-profile-unsupported.table.golden index 762324c..c3789d8 100644 --- a/tests/golden/error-yahoo-profile-unsupported.table.golden +++ b/tests/golden/error-yahoo-profile-unsupported.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 5 --- stdout --- stderr Error: unsupported: BBCA.JK: command requires --provider msn diff --git a/tests/golden/help-cache.golden b/tests/golden/help-cache.golden index 40f2b4f..27f05b0 100644 --- a/tests/golden/help-cache.golden +++ b/tests/golden/help-cache.golden @@ -10,7 +10,7 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [possible values: table, json] + -o, --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) diff --git a/tests/golden/help-config.golden b/tests/golden/help-config.golden index 485641f..e594fbc 100644 --- a/tests/golden/help-config.golden +++ b/tests/golden/help-config.golden @@ -12,7 +12,7 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [possible values: table, json] + -o, --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) diff --git a/tests/golden/help-ownership.golden b/tests/golden/help-ownership.golden index 44bb1e8..54501d7 100644 --- a/tests/golden/help-ownership.golden +++ b/tests/golden/help-ownership.golden @@ -27,9 +27,9 @@ Commands: Options: -o, --output - Output format + Output format [default: auto = table on a terminal, json when piped] - [possible values: table, json] + [possible values: auto, table, json, jsonl] --no-color Disable colored output diff --git a/tests/golden/help-root.golden b/tests/golden/help-root.golden index 6095643..2814cde 100644 --- a/tests/golden/help-root.golden +++ b/tests/golden/help-root.golden @@ -16,9 +16,9 @@ Commands: Options: -o, --output - Output format + Output format [default: auto = table on a terminal, json when piped] - [possible values: table, json] + [possible values: auto, table, json, jsonl] --no-color Disable colored output diff --git a/tests/golden/help-stocks.golden b/tests/golden/help-stocks.golden index 1c51546..8210119 100644 --- a/tests/golden/help-stocks.golden +++ b/tests/golden/help-stocks.golden @@ -23,7 +23,7 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [possible values: table, json] + -o, --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) diff --git a/tests/golden/ownership-changes.json.golden b/tests/golden/ownership-changes.json.golden index 138b1cb..78e42e5 100644 --- a/tests/golden/ownership-changes.json.golden +++ b/tests/golden/ownership-changes.json.golden @@ -1,109 +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 - } -] +[{"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 diff --git a/tests/golden/ownership-changes.jsonl.golden b/tests/golden/ownership-changes.jsonl.golden new file mode 100644 index 0000000..a158f77 --- /dev/null +++ b/tests/golden/ownership-changes.jsonl.golden @@ -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 diff --git a/tests/golden/ownership-changes.table.golden b/tests/golden/ownership-changes.table.golden index 7f08993..5f35d3c 100644 --- a/tests/golden/ownership-changes.table.golden +++ b/tests/golden/ownership-changes.table.golden @@ -1,32 +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% │ -└────────┴───────────────────────────┴─────────────────────┴────────┴────────┴────────┘ +┌────────┬───────────────────────────┬───────────┬────────┬────────┬────────┐ +│ 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 diff --git a/tests/golden/ownership-concentration.json.golden b/tests/golden/ownership-concentration.json.golden index 2c5afc3..35cfcd4 100644 --- a/tests/golden/ownership-concentration.json.golden +++ b/tests/golden/ownership-concentration.json.golden @@ -1,45 +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 - } - ] -] +[["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 diff --git a/tests/golden/ownership-cross-holders.json.golden b/tests/golden/ownership-cross-holders.json.golden index 9c4685e..3a1e2a6 100644 --- a/tests/golden/ownership-cross-holders.json.golden +++ b/tests/golden/ownership-cross-holders.json.golden @@ -1,175 +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 - } -] +[{"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 diff --git a/tests/golden/ownership-entity.json.golden b/tests/golden/ownership-entity.json.golden index ece9e47..7084f9f 100644 --- a/tests/golden/ownership-entity.json.golden +++ b/tests/golden/ownership-entity.json.golden @@ -1,26 +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" - } - ] -} +{"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 diff --git a/tests/golden/ownership-entity.table.golden b/tests/golden/ownership-entity.table.golden index 3fb0cae..3969857 100644 --- a/tests/golden/ownership-entity.table.golden +++ b/tests/golden/ownership-entity.table.golden @@ -1,10 +1,10 @@ exit: 0 --- stdout -Entity: ANTHONI SALIM +Entity: ANTHONI SALIM ┌────────┬────────┬───────────────┬───────┬─────────────┐ │ TICKER ┆ SOURCE ┆ SHARES ┆ % ┆ REPORT_DATE │ ╞════════╪════════╪═══════════════╪═══════╪═════════════╡ │ BBCA ┆ KSEI ┆ 1,416,306,835 ┆ 1.15% ┆ 2026-08-31 │ └────────┴────────┴───────────────┴───────┴─────────────┘ -Total tickers: 1 +Total tickers: 1 --- stderr diff --git a/tests/golden/ownership-graph-dot.json.golden b/tests/golden/ownership-graph-dot.json.golden index ffebccb..83e2349 100644 --- a/tests/golden/ownership-graph-dot.json.golden +++ b/tests/golden/ownership-graph-dot.json.golden @@ -1,58 +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" - } - ] -} +{"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 diff --git a/tests/golden/ownership-graph.json.golden b/tests/golden/ownership-graph.json.golden index ffebccb..83e2349 100644 --- a/tests/golden/ownership-graph.json.golden +++ b/tests/golden/ownership-graph.json.golden @@ -1,58 +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" - } - ] -} +{"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 diff --git a/tests/golden/ownership-import-archive-txt.json.golden b/tests/golden/ownership-import-archive-txt.json.golden index 0717683..58baa64 100644 --- a/tests/golden/ownership-import-archive-txt.json.golden +++ b/tests/golden/ownership-import-archive-txt.json.golden @@ -1,11 +1,4 @@ exit: 0 --- stdout -{ - "action": "imported", - "inserted_rows": 18, - "ticker_count": 1, - "as_of_date": "2026-02-27", - "sha256": "7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547", - "source_url": null -} +{"action":"imported","inserted_rows":18,"ticker_count":1,"as_of_date":"2026-02-27","sha256":"7b8da1d820f67062410c48988bb6c70fec2088c24961d481d1c0fce8b9372547","source_url":null} --- stderr diff --git a/tests/golden/ownership-import-xlsx.json.golden b/tests/golden/ownership-import-xlsx.json.golden index 30fb0d3..30245c2 100644 --- a/tests/golden/ownership-import-xlsx.json.golden +++ b/tests/golden/ownership-import-xlsx.json.golden @@ -1,11 +1,4 @@ exit: 0 --- stdout -{ - "action": "imported", - "inserted_rows": 17, - "ticker_count": 4, - "as_of_date": "2026-08-31", - "sha256": "7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3", - "source_url": null -} +{"action":"imported","inserted_rows":17,"ticker_count":4,"as_of_date":"2026-08-31","sha256":"7c1a4c209b8ce5bde9112436a0c64d66b042915995b584d2bbf7ca7b15ce81e3","source_url":null} --- stderr diff --git a/tests/golden/ownership-releases.json.golden b/tests/golden/ownership-releases.json.golden index a555050..6145fee 100644 --- a/tests/golden/ownership-releases.json.golden +++ b/tests/golden/ownership-releases.json.golden @@ -1,29 +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 - } -] +[{"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 diff --git a/tests/golden/ownership-resolve-map.json.golden b/tests/golden/ownership-resolve-map.json.golden index baf20c6..daa7080 100644 --- a/tests/golden/ownership-resolve-map.json.golden +++ b/tests/golden/ownership-resolve-map.json.golden @@ -1,8 +1,4 @@ exit: 0 --- stdout -{ - "action": "mapped", - "alias": "SALIM ANTHONI", - "entity": "ANTHONI SALIM" -} +{"action":"mapped","alias":"SALIM ANTHONI","entity":"ANTHONI SALIM"} --- stderr diff --git a/tests/golden/ownership-resolve-merge.json.golden b/tests/golden/ownership-resolve-merge.json.golden index 6ea677b..9e65e1c 100644 --- a/tests/golden/ownership-resolve-merge.json.golden +++ b/tests/golden/ownership-resolve-merge.json.golden @@ -1,11 +1,4 @@ exit: 0 --- stdout -{ - "action": "merged", - "alias_updates": 1, - "bing_holdings_updates": 0, - "keep": 1, - "ksei_holdings_updates": 1, - "merge": 2 -} +{"action":"merged","alias_updates":1,"bing_holdings_updates":0,"keep":1,"ksei_holdings_updates":1,"merge":2} --- stderr diff --git a/tests/golden/ownership-search.json.golden b/tests/golden/ownership-search.json.golden index 81ef014..664aadc 100644 --- a/tests/golden/ownership-search.json.golden +++ b/tests/golden/ownership-search.json.golden @@ -1,11 +1,4 @@ exit: 0 --- stdout -[ - { - "id": 17, - "canonical_name": "ANTHONI SALIM", - "entity_type": null, - "country": null - } -] +[{"id":17,"canonical_name":"ANTHONI SALIM","entity_type":null,"country":null}] --- stderr diff --git a/tests/golden/ownership-ticker-empty.json.golden b/tests/golden/ownership-ticker-empty.json.golden index f165894..57c52fa 100644 --- a/tests/golden/ownership-ticker-empty.json.golden +++ b/tests/golden/ownership-ticker-empty.json.golden @@ -1,8 +1,4 @@ -exit: 1 +exit: 3 --- stdout --- stderr -{ - "code": "SYMBOLNOTFOUND", - "error": true, - "message": "symbol not found: BBCA" -} +{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: BBCA"} diff --git a/tests/golden/ownership-ticker-empty.table.golden b/tests/golden/ownership-ticker-empty.table.golden index b92ace9..8470d72 100644 --- a/tests/golden/ownership-ticker-empty.table.golden +++ b/tests/golden/ownership-ticker-empty.table.golden @@ -1,4 +1,4 @@ -exit: 1 +exit: 3 --- stdout --- stderr Error: symbol not found: BBCA diff --git a/tests/golden/ownership-ticker-ksei.json.golden b/tests/golden/ownership-ticker-ksei.json.golden index de19ce7..cbe8b50 100644 --- a/tests/golden/ownership-ticker-ksei.json.golden +++ b/tests/golden/ownership-ticker-ksei.json.golden @@ -1,67 +1,4 @@ exit: 0 --- stdout -{ - "ticker": { - "id": 4, - "code": "BBCA", - "name": "BANK CENTRAL ASIA Tbk", - "sector": null - }, - "ksei_as_of": "2026-08-31", - "bing_as_of": null, - "holders": [ - { - "rank": 1, - "source": "ksei", - "name": "DWIMURIA INVESTAMA ANDALAN", - "entity_id": 14, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 67729950000, - "percentage_bps": 5494, - "signal": null - }, - { - "rank": 2, - "source": "ksei", - "name": "ANTHONI SALIM", - "entity_id": 17, - "investor_type": "INDIVIDUAL", - "locality": "Local", - "shares": 1416306835, - "percentage_bps": 115, - "signal": null - }, - { - "rank": 3, - "source": "ksei", - "name": "TRICIPTA MANDHALA GUMILANG", - "entity_id": 16, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 1313250000, - "percentage_bps": 107, - "signal": null - }, - { - "rank": 4, - "source": "ksei", - "name": "CATURGUWIRATNA SUMAPALA", - "entity_id": 15, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 1261750000, - "percentage_bps": 102, - "signal": null - } - ], - "concentration": { - "top1_bps": 5494, - "top3_bps": 5716, - "hhi": 3021, - "free_float_bps": 4182, - "holder_count": 4 - }, - "flow": null -} +{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"ksei_as_of":"2026-08-31","bing_as_of":null,"holders":[{"rank":1,"source":"ksei","name":"DWIMURIA INVESTAMA ANDALAN","entity_id":14,"investor_type":"CORPORATE","locality":"Local","shares":67729950000,"percentage_bps":5494,"signal":null},{"rank":2,"source":"ksei","name":"ANTHONI SALIM","entity_id":17,"investor_type":"INDIVIDUAL","locality":"Local","shares":1416306835,"percentage_bps":115,"signal":null},{"rank":3,"source":"ksei","name":"TRICIPTA MANDHALA GUMILANG","entity_id":16,"investor_type":"CORPORATE","locality":"Local","shares":1313250000,"percentage_bps":107,"signal":null},{"rank":4,"source":"ksei","name":"CATURGUWIRATNA SUMAPALA","entity_id":15,"investor_type":"CORPORATE","locality":"Local","shares":1261750000,"percentage_bps":102,"signal":null}],"concentration":{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4},"flow":null} --- stderr diff --git a/tests/golden/ownership-ticker-ksei.table.golden b/tests/golden/ownership-ticker-ksei.table.golden index e59444a..b8e1931 100644 --- a/tests/golden/ownership-ticker-ksei.table.golden +++ b/tests/golden/ownership-ticker-ksei.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -Ownership: BBCA KSEI=2026-08-31 Bing=- +Ownership: BBCA KSEI=2026-08-31 Bing=- ┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐ │ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │ ╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡ @@ -12,5 +12,5 @@ exit: 0 ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │ └──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘ -Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 +Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 --- stderr diff --git a/tests/golden/ownership-ticker.json.golden b/tests/golden/ownership-ticker.json.golden index de19ce7..cbe8b50 100644 --- a/tests/golden/ownership-ticker.json.golden +++ b/tests/golden/ownership-ticker.json.golden @@ -1,67 +1,4 @@ exit: 0 --- stdout -{ - "ticker": { - "id": 4, - "code": "BBCA", - "name": "BANK CENTRAL ASIA Tbk", - "sector": null - }, - "ksei_as_of": "2026-08-31", - "bing_as_of": null, - "holders": [ - { - "rank": 1, - "source": "ksei", - "name": "DWIMURIA INVESTAMA ANDALAN", - "entity_id": 14, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 67729950000, - "percentage_bps": 5494, - "signal": null - }, - { - "rank": 2, - "source": "ksei", - "name": "ANTHONI SALIM", - "entity_id": 17, - "investor_type": "INDIVIDUAL", - "locality": "Local", - "shares": 1416306835, - "percentage_bps": 115, - "signal": null - }, - { - "rank": 3, - "source": "ksei", - "name": "TRICIPTA MANDHALA GUMILANG", - "entity_id": 16, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 1313250000, - "percentage_bps": 107, - "signal": null - }, - { - "rank": 4, - "source": "ksei", - "name": "CATURGUWIRATNA SUMAPALA", - "entity_id": 15, - "investor_type": "CORPORATE", - "locality": "Local", - "shares": 1261750000, - "percentage_bps": 102, - "signal": null - } - ], - "concentration": { - "top1_bps": 5494, - "top3_bps": 5716, - "hhi": 3021, - "free_float_bps": 4182, - "holder_count": 4 - }, - "flow": null -} +{"ticker":{"id":4,"code":"BBCA","name":"BANK CENTRAL ASIA Tbk","sector":null},"ksei_as_of":"2026-08-31","bing_as_of":null,"holders":[{"rank":1,"source":"ksei","name":"DWIMURIA INVESTAMA ANDALAN","entity_id":14,"investor_type":"CORPORATE","locality":"Local","shares":67729950000,"percentage_bps":5494,"signal":null},{"rank":2,"source":"ksei","name":"ANTHONI SALIM","entity_id":17,"investor_type":"INDIVIDUAL","locality":"Local","shares":1416306835,"percentage_bps":115,"signal":null},{"rank":3,"source":"ksei","name":"TRICIPTA MANDHALA GUMILANG","entity_id":16,"investor_type":"CORPORATE","locality":"Local","shares":1313250000,"percentage_bps":107,"signal":null},{"rank":4,"source":"ksei","name":"CATURGUWIRATNA SUMAPALA","entity_id":15,"investor_type":"CORPORATE","locality":"Local","shares":1261750000,"percentage_bps":102,"signal":null}],"concentration":{"top1_bps":5494,"top3_bps":5716,"hhi":3021,"free_float_bps":4182,"holder_count":4},"flow":null} --- stderr diff --git a/tests/golden/ownership-ticker.table.golden b/tests/golden/ownership-ticker.table.golden index e59444a..b8e1931 100644 --- a/tests/golden/ownership-ticker.table.golden +++ b/tests/golden/ownership-ticker.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -Ownership: BBCA KSEI=2026-08-31 Bing=- +Ownership: BBCA KSEI=2026-08-31 Bing=- ┌──────┬────────┬────────────────────────────┬────────────┬─────┬────────────────┬────────┬────────┐ │ RANK ┆ SOURCE ┆ INVESTOR ┆ TYPE ┆ L/F ┆ SHARES ┆ % ┆ SIGNAL │ ╞══════╪════════╪════════════════════════════╪════════════╪═════╪════════════════╪════════╪════════╡ @@ -12,5 +12,5 @@ exit: 0 ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ 4 ┆ KSEI ┆ CATURGUWIRATNA SUMAPALA ┆ CORPORATE ┆ L ┆ 1,261,750,000 ┆ 1.02% ┆ - │ └──────┴────────┴────────────────────────────┴────────────┴─────┴────────────────┴────────┴────────┘ -Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 +Concentration: top1=54.94% top3=57.16% hhi=3021 free_float=41.82% holders=4 --- stderr diff --git a/tests/golden/stocks-compare-stdin.json.golden b/tests/golden/stocks-compare-stdin.json.golden new file mode 100644 index 0000000..157f866 --- /dev/null +++ b/tests/golden/stocks-compare-stdin.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"},{"symbol":"BBRI.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"}] +--- stderr diff --git a/tests/golden/stocks-compare-stdin.table.golden b/tests/golden/stocks-compare-stdin.table.golden new file mode 100644 index 0000000..31710bd --- /dev/null +++ b/tests/golden/stocks-compare-stdin.table.golden @@ -0,0 +1,23 @@ +exit: 0 +--- stdout +Fundamental Comparison +┌────────────────┬───────────────┬───────────────┐ +│ METRIC ┆ BBCA.JK ┆ BBRI.JK │ +╞════════════════╪═══════════════╪═══════════════╡ +│ Symbol ┆ BBCA.JK ┆ BBRI.JK │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Growth ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Valuation ┆ expensive ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Risk ┆ moderate risk ┆ moderate risk │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E ┆ 25.40 ┆ 25.40 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ +19.80% │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Revenue Growth ┆ +8.10% ┆ +8.10% │ +└────────────────┴───────────────┴───────────────┘ +--- stderr diff --git a/tests/golden/stocks-compare.json.golden b/tests/golden/stocks-compare.json.golden index c5cf145..157f866 100644 --- a/tests/golden/stocks-compare.json.golden +++ b/tests/golden/stocks-compare.json.golden @@ -1,81 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "growth": { - "revenue_growth": 0.081, - "earnings_growth": 0.121, - "revenue_growth_pct": 8.1, - "earnings_growth_pct": 12.1, - "revenue_signal": "slow", - "earnings_signal": "moderate", - "overall_signal": "mixed" - }, - "valuation": { - "pe_trailing": 25.4, - "pe_forward": null, - "pb": 4.6, - "roe": 0.198, - "roe_pct": 19.8, - "net_margin": 0.324, - "net_margin_pct": 32.4, - "ev_ebitda": null, - "pe_signal": "premium", - "pb_signal": "expensive", - "roe_signal": "strong", - "margin_signal": "excellent", - "ev_ebitda_signal": "no data", - "overall_signal": "expensive" - }, - "risk": { - "debt_to_equity": 0.42, - "current_ratio": 1.18, - "roa": 0.031, - "roa_pct": 3.1, - "de_signal": "conservative", - "current_ratio_signal": "tight", - "overall_signal": "moderate risk" - }, - "overall_signal": "mixed" - }, - { - "symbol": "BBRI.JK", - "growth": { - "revenue_growth": 0.081, - "earnings_growth": 0.121, - "revenue_growth_pct": 8.1, - "earnings_growth_pct": 12.1, - "revenue_signal": "slow", - "earnings_signal": "moderate", - "overall_signal": "mixed" - }, - "valuation": { - "pe_trailing": 25.4, - "pe_forward": null, - "pb": 4.6, - "roe": 0.198, - "roe_pct": 19.8, - "net_margin": 0.324, - "net_margin_pct": 32.4, - "ev_ebitda": null, - "pe_signal": "premium", - "pb_signal": "expensive", - "roe_signal": "strong", - "margin_signal": "excellent", - "ev_ebitda_signal": "no data", - "overall_signal": "expensive" - }, - "risk": { - "debt_to_equity": 0.42, - "current_ratio": 1.18, - "roa": 0.031, - "roa_pct": 3.1, - "de_signal": "conservative", - "current_ratio_signal": "tight", - "overall_signal": "moderate risk" - }, - "overall_signal": "mixed" - } -] +[{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"},{"symbol":"BBRI.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"}] --- stderr diff --git a/tests/golden/stocks-compare.table.golden b/tests/golden/stocks-compare.table.golden index c913fbc..31710bd 100644 --- a/tests/golden/stocks-compare.table.golden +++ b/tests/golden/stocks-compare.table.golden @@ -1,23 +1,23 @@ exit: 0 --- stdout -Fundamental Comparison -┌────────────────┬─────────────────────────┬─────────────────────────┐ -│ METRIC ┆ BBCA.JK ┆ BBRI.JK │ -╞════════════════╪═════════════════════════╪═════════════════════════╡ -│ Symbol ┆ BBCA.JK ┆ BBRI.JK │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ mixed ┆ mixed │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Growth ┆ mixed ┆ mixed │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Valuation ┆ expensive ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Risk ┆ moderate risk ┆ moderate risk │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ P/E ┆ 25.40 ┆ 25.40 │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROE ┆ +19.80% ┆ +19.80% │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Revenue Growth ┆ +8.10% ┆ +8.10% │ -└────────────────┴─────────────────────────┴─────────────────────────┘ +Fundamental Comparison +┌────────────────┬───────────────┬───────────────┐ +│ METRIC ┆ BBCA.JK ┆ BBRI.JK │ +╞════════════════╪═══════════════╪═══════════════╡ +│ Symbol ┆ BBCA.JK ┆ BBRI.JK │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Growth ┆ mixed ┆ mixed │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Valuation ┆ expensive ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Risk ┆ moderate risk ┆ moderate risk │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E ┆ 25.40 ┆ 25.40 │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ +19.80% │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Revenue Growth ┆ +8.10% ┆ +8.10% │ +└────────────────┴───────────────┴───────────────┘ --- stderr diff --git a/tests/golden/stocks-earnings-forecast-annual.json.golden b/tests/golden/stocks-earnings-forecast-annual.json.golden index ca4b219..e6dac47 100644 --- a/tests/golden/stocks-earnings-forecast-annual.json.golden +++ b/tests/golden/stocks-earnings-forecast-annual.json.golden @@ -1,22 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "eps_last_year": 1200.0, - "revenue_last_year": 100000000000.0, - "forecast": [ - { - "eps_actual": null, - "eps_forecast": 1300.0, - "eps_surprise": null, - "eps_surprise_pct": null, - "revenue_actual": null, - "revenue_forecast": 110000000000.0, - "revenue_surprise": null, - "earning_release_date": "2026-03-15", - "period_type": "2026" - } - ], - "history": [] -} +{"symbol":"BBCA.JK","eps_last_year":1200.0,"revenue_last_year":100000000000.0,"forecast":[{"eps_actual":null,"eps_forecast":1300.0,"eps_surprise":null,"eps_surprise_pct":null,"revenue_actual":null,"revenue_forecast":110000000000.0,"revenue_surprise":null,"earning_release_date":"2026-03-15","period_type":"2026"}],"history":[]} --- stderr diff --git a/tests/golden/stocks-earnings-history-quarterly.json.golden b/tests/golden/stocks-earnings-history-quarterly.json.golden index 2c8eef8..f713693 100644 --- a/tests/golden/stocks-earnings-history-quarterly.json.golden +++ b/tests/golden/stocks-earnings-history-quarterly.json.golden @@ -1,22 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "eps_last_year": 1200.0, - "revenue_last_year": 100000000000.0, - "forecast": [], - "history": [ - { - "eps_actual": 320.0, - "eps_forecast": null, - "eps_surprise": 5.0, - "eps_surprise_pct": 1.5, - "revenue_actual": 27000000000.0, - "revenue_forecast": null, - "revenue_surprise": 200000000.0, - "earning_release_date": "2025-11-20", - "period_type": "Q42025" - } - ] -} +{"symbol":"BBCA.JK","eps_last_year":1200.0,"revenue_last_year":100000000000.0,"forecast":[],"history":[{"eps_actual":320.0,"eps_forecast":null,"eps_surprise":5.0,"eps_surprise_pct":1.5,"revenue_actual":27000000000.0,"revenue_forecast":null,"revenue_surprise":200000000.0,"earning_release_date":"2025-11-20","period_type":"Q42025"}]} --- stderr diff --git a/tests/golden/stocks-earnings.json.golden b/tests/golden/stocks-earnings.json.golden index f9fcc31..9085b29 100644 --- a/tests/golden/stocks-earnings.json.golden +++ b/tests/golden/stocks-earnings.json.golden @@ -1,56 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "eps_last_year": 1200.0, - "revenue_last_year": 100000000000.0, - "forecast": [ - { - "eps_actual": null, - "eps_forecast": 1300.0, - "eps_surprise": null, - "eps_surprise_pct": null, - "revenue_actual": null, - "revenue_forecast": 110000000000.0, - "revenue_surprise": null, - "earning_release_date": "2026-03-15", - "period_type": "2026" - }, - { - "eps_actual": null, - "eps_forecast": 330.0, - "eps_surprise": null, - "eps_surprise_pct": null, - "revenue_actual": null, - "revenue_forecast": 28000000000.0, - "revenue_surprise": null, - "earning_release_date": "2026-04-20", - "period_type": "Q12026" - } - ], - "history": [ - { - "eps_actual": 1250.0, - "eps_forecast": null, - "eps_surprise": 20.0, - "eps_surprise_pct": 1.6, - "revenue_actual": 105000000000.0, - "revenue_forecast": null, - "revenue_surprise": 500000000.0, - "earning_release_date": "2025-03-15", - "period_type": "2025" - }, - { - "eps_actual": 320.0, - "eps_forecast": null, - "eps_surprise": 5.0, - "eps_surprise_pct": 1.5, - "revenue_actual": 27000000000.0, - "revenue_forecast": null, - "revenue_surprise": 200000000.0, - "earning_release_date": "2025-11-20", - "period_type": "Q42025" - } - ] -} +{"symbol":"BBCA.JK","eps_last_year":1200.0,"revenue_last_year":100000000000.0,"forecast":[{"eps_actual":null,"eps_forecast":1300.0,"eps_surprise":null,"eps_surprise_pct":null,"revenue_actual":null,"revenue_forecast":110000000000.0,"revenue_surprise":null,"earning_release_date":"2026-03-15","period_type":"2026"},{"eps_actual":null,"eps_forecast":330.0,"eps_surprise":null,"eps_surprise_pct":null,"revenue_actual":null,"revenue_forecast":28000000000.0,"revenue_surprise":null,"earning_release_date":"2026-04-20","period_type":"Q12026"}],"history":[{"eps_actual":1250.0,"eps_forecast":null,"eps_surprise":20.0,"eps_surprise_pct":1.6,"revenue_actual":105000000000.0,"revenue_forecast":null,"revenue_surprise":500000000.0,"earning_release_date":"2025-03-15","period_type":"2025"},{"eps_actual":320.0,"eps_forecast":null,"eps_surprise":5.0,"eps_surprise_pct":1.5,"revenue_actual":27000000000.0,"revenue_forecast":null,"revenue_surprise":200000000.0,"earning_release_date":"2025-11-20","period_type":"Q42025"}]} --- stderr diff --git a/tests/golden/stocks-financials-income.json.golden b/tests/golden/stocks-financials-income.json.golden index 83cd079..cfa9dde 100644 --- a/tests/golden/stocks-financials-income.json.golden +++ b/tests/golden/stocks-financials-income.json.golden @@ -1,21 +1,4 @@ exit: 0 --- stdout -{ - "instrument": { - "id": "bn91jc", - "symbol": "BBCA.JK", - "name": "Bank Central Asia Tbk PT" - }, - "balance_sheet": null, - "cash_flow": null, - "income_statement": { - "values": { - "netIncome": 400000000.0, - "totalRevenue": 1000000000.0 - }, - "currency": "IDR", - "report_date": "2025-12-31", - "end_date": "2025-12-31" - } -} +{"instrument":{"id":"bn91jc","symbol":"BBCA.JK","name":"Bank Central Asia Tbk PT"},"balance_sheet":null,"cash_flow":null,"income_statement":{"values":{"netIncome":400000000.0,"totalRevenue":1000000000.0},"currency":"IDR","report_date":"2025-12-31","end_date":"2025-12-31"}} --- stderr diff --git a/tests/golden/stocks-financials.json.golden b/tests/golden/stocks-financials.json.golden index 34bab31..a935f13 100644 --- a/tests/golden/stocks-financials.json.golden +++ b/tests/golden/stocks-financials.json.golden @@ -1,36 +1,4 @@ exit: 0 --- stdout -{ - "instrument": { - "id": "bn91jc", - "symbol": "BBCA.JK", - "name": "Bank Central Asia Tbk PT" - }, - "balance_sheet": { - "values": { - "cashAndShortTermInvestments": 500000000.0, - "totalEquity": 900000000.0 - }, - "currency": "IDR", - "report_date": "2025-12-31", - "end_date": "2025-12-31" - }, - "cash_flow": { - "values": { - "operatingCashFlow": 600000000.0 - }, - "currency": "IDR", - "report_date": "2025-12-31", - "end_date": "2025-12-31" - }, - "income_statement": { - "values": { - "netIncome": 400000000.0, - "totalRevenue": 1000000000.0 - }, - "currency": "IDR", - "report_date": "2025-12-31", - "end_date": "2025-12-31" - } -} +{"instrument":{"id":"bn91jc","symbol":"BBCA.JK","name":"Bank Central Asia Tbk PT"},"balance_sheet":{"values":{"cashAndShortTermInvestments":500000000.0,"totalEquity":900000000.0},"currency":"IDR","report_date":"2025-12-31","end_date":"2025-12-31"},"cash_flow":{"values":{"operatingCashFlow":600000000.0},"currency":"IDR","report_date":"2025-12-31","end_date":"2025-12-31"},"income_statement":{"values":{"netIncome":400000000.0,"totalRevenue":1000000000.0},"currency":"IDR","report_date":"2025-12-31","end_date":"2025-12-31"}} --- stderr diff --git a/tests/golden/stocks-fundamental.json.golden b/tests/golden/stocks-fundamental.json.golden index a24505b..b04537b 100644 --- a/tests/golden/stocks-fundamental.json.golden +++ b/tests/golden/stocks-fundamental.json.golden @@ -1,41 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "growth": { - "revenue_growth": 0.081, - "earnings_growth": 0.121, - "revenue_growth_pct": 8.1, - "earnings_growth_pct": 12.1, - "revenue_signal": "slow", - "earnings_signal": "moderate", - "overall_signal": "mixed" - }, - "valuation": { - "pe_trailing": 25.4, - "pe_forward": null, - "pb": 4.6, - "roe": 0.198, - "roe_pct": 19.8, - "net_margin": 0.324, - "net_margin_pct": 32.4, - "ev_ebitda": null, - "pe_signal": "premium", - "pb_signal": "expensive", - "roe_signal": "strong", - "margin_signal": "excellent", - "ev_ebitda_signal": "no data", - "overall_signal": "expensive" - }, - "risk": { - "debt_to_equity": 0.42, - "current_ratio": 1.18, - "roa": 0.031, - "roa_pct": 3.1, - "de_signal": "conservative", - "current_ratio_signal": "tight", - "overall_signal": "moderate risk" - }, - "overall_signal": "mixed" -} +{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"} --- stderr diff --git a/tests/golden/stocks-fundamental.jsonl.golden b/tests/golden/stocks-fundamental.jsonl.golden new file mode 100644 index 0000000..b04537b --- /dev/null +++ b/tests/golden/stocks-fundamental.jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","growth":{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"},"valuation":{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"},"risk":{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"} +--- stderr diff --git a/tests/golden/stocks-fundamental.table.golden b/tests/golden/stocks-fundamental.table.golden index d697200..f4c8139 100644 --- a/tests/golden/stocks-fundamental.table.golden +++ b/tests/golden/stocks-fundamental.table.golden @@ -1,49 +1,49 @@ exit: 0 --- stdout -Fundamental Analysis for BBCA.JK +Fundamental Analysis for BBCA.JK -Growth Analysis for BBCA.JK -┌─────────────────┬─────────┬────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═════════════════╪═════════╪════════════════════╡ -│ Revenue Growth ┆ +8.10% ┆ slow │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Earnings Growth ┆ +12.10% ┆ moderate │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ mixed │ -└─────────────────┴─────────┴────────────────────┘ +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬──────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪══════════╡ +│ Revenue Growth ┆ +8.10% ┆ slow │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ mixed │ +└─────────────────┴─────────┴──────────┘ -Valuation Analysis for BBCA.JK -┌────────────────┬─────────┬─────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞════════════════╪═════════╪═════════════════════╡ -│ P/E (Trailing) ┆ 25.40 ┆ premium │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ P/E (Forward) ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Price/Book ┆ 4.60 ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROE ┆ +19.80% ┆ strong │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Net Margin ┆ +32.40% ┆ excellent │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ EV/EBITDA ┆ - ┆ no data │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ expensive │ -└────────────────┴─────────┴─────────────────────┘ +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬───────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═══════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +32.40% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴───────────┘ -Risk Analysis for BBCA.JK -┌───────────────┬────────┬─────────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═══════════════╪════════╪═════════════════════════╡ -│ Debt/Equity ┆ 0.42 ┆ conservative │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Current Ratio ┆ 1.18 ┆ tight │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROA ┆ +3.10% ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ moderate risk │ -└───────────────┴────────┴─────────────────────────┘ +Risk Analysis for BBCA.JK +┌───────────────┬────────┬───────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═══════════════╡ +│ Debt/Equity ┆ 0.42 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.18 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.10% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴───────────────┘ -Overall Signal: mixed +Overall Signal: mixed --- stderr diff --git a/tests/golden/stocks-growth.json.golden b/tests/golden/stocks-growth.json.golden index d2327bf..bcd7ccd 100644 --- a/tests/golden/stocks-growth.json.golden +++ b/tests/golden/stocks-growth.json.golden @@ -1,12 +1,4 @@ exit: 0 --- stdout -{ - "revenue_growth": 0.081, - "earnings_growth": 0.121, - "revenue_growth_pct": 8.1, - "earnings_growth_pct": 12.1, - "revenue_signal": "slow", - "earnings_signal": "moderate", - "overall_signal": "mixed" -} +{"revenue_growth":0.081,"earnings_growth":0.121,"revenue_growth_pct":8.1,"earnings_growth_pct":12.1,"revenue_signal":"slow","earnings_signal":"moderate","overall_signal":"mixed"} --- stderr diff --git a/tests/golden/stocks-growth.table.golden b/tests/golden/stocks-growth.table.golden index a26e3a1..fdfc0c7 100644 --- a/tests/golden/stocks-growth.table.golden +++ b/tests/golden/stocks-growth.table.golden @@ -1,13 +1,13 @@ exit: 0 --- stdout -Growth Analysis for BBCA.JK -┌─────────────────┬─────────┬────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═════════════════╪═════════╪════════════════════╡ -│ Revenue Growth ┆ +8.10% ┆ slow │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Earnings Growth ┆ +12.10% ┆ moderate │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ mixed │ -└─────────────────┴─────────┴────────────────────┘ +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬──────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪══════════╡ +│ Revenue Growth ┆ +8.10% ┆ slow │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ mixed │ +└─────────────────┴─────────┴──────────┘ --- stderr diff --git a/tests/golden/stocks-history-msn.json.golden b/tests/golden/stocks-history-msn.json.golden index e6eb3e6..faec72d 100644 --- a/tests/golden/stocks-history-msn.json.golden +++ b/tests/golden/stocks-history-msn.json.golden @@ -1,29 +1,4 @@ exit: 0 --- stdout -[ - { - "date": "2026-01-14", - "open": 8000, - "high": 8000, - "low": 8000, - "close": 8000, - "volume": 0 - }, - { - "date": "2026-01-15", - "open": 8075, - "high": 8075, - "low": 8075, - "close": 8075, - "volume": 0 - }, - { - "date": "2026-01-16", - "open": 8025, - "high": 8025, - "low": 8025, - "close": 8025, - "volume": 0 - } -] +[{"date":"2026-01-14","open":8000,"high":8000,"low":8000,"close":8000,"volume":0},{"date":"2026-01-15","open":8075,"high":8075,"low":8075,"close":8075,"volume":0},{"date":"2026-01-16","open":8025,"high":8025,"low":8025,"close":8025,"volume":0}] --- stderr diff --git a/tests/golden/stocks-history-msn.table.golden b/tests/golden/stocks-history-msn.table.golden index b47c365..fef3c67 100644 --- a/tests/golden/stocks-history-msn.table.golden +++ b/tests/golden/stocks-history-msn.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -History for BBCA.JK +History for BBCA.JK ┌────────────┬───────┬───────┬───────┬───────┬────────┐ │ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ ╞════════════╪═══════╪═══════╪═══════╪═══════╪════════╡ diff --git a/tests/golden/stocks-history.json.golden b/tests/golden/stocks-history.json.golden index 423e9c6..317447c 100644 --- a/tests/golden/stocks-history.json.golden +++ b/tests/golden/stocks-history.json.golden @@ -1,29 +1,4 @@ exit: 0 --- stdout -[ - { - "date": "2024-03-01", - "open": 9800, - "high": 9900, - "low": 9750, - "close": 9875, - "volume": 12300000 - }, - { - "date": "2024-03-02", - "open": 9850, - "high": 9920, - "low": 9800, - "close": 9880, - "volume": 11000000 - }, - { - "date": "2024-03-03", - "open": 9860, - "high": 9950, - "low": 9820, - "close": 9925, - "volume": 14000000 - } -] +[{"date":"2024-03-01","open":9800,"high":9900,"low":9750,"close":9875,"volume":12300000},{"date":"2024-03-02","open":9850,"high":9920,"low":9800,"close":9880,"volume":11000000},{"date":"2024-03-03","open":9860,"high":9950,"low":9820,"close":9925,"volume":14000000}] --- stderr diff --git a/tests/golden/stocks-history.jsonl.golden b/tests/golden/stocks-history.jsonl.golden new file mode 100644 index 0000000..34aaf68 --- /dev/null +++ b/tests/golden/stocks-history.jsonl.golden @@ -0,0 +1,6 @@ +exit: 0 +--- stdout +{"date":"2024-03-01","open":9800,"high":9900,"low":9750,"close":9875,"volume":12300000} +{"date":"2024-03-02","open":9850,"high":9920,"low":9800,"close":9880,"volume":11000000} +{"date":"2024-03-03","open":9860,"high":9950,"low":9820,"close":9925,"volume":14000000} +--- stderr diff --git a/tests/golden/stocks-history.table.golden b/tests/golden/stocks-history.table.golden index 50e34a6..5ede18b 100644 --- a/tests/golden/stocks-history.table.golden +++ b/tests/golden/stocks-history.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -History for BBCA.JK +History for BBCA.JK ┌────────────┬───────┬───────┬───────┬───────┬────────────┐ │ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ ╞════════════╪═══════╪═══════╪═══════╪═══════╪════════════╡ diff --git a/tests/golden/stocks-insights.json.golden b/tests/golden/stocks-insights.json.golden index 0050c83..4249c18 100644 --- a/tests/golden/stocks-insights.json.golden +++ b/tests/golden/stocks-insights.json.golden @@ -1,16 +1,4 @@ exit: 0 --- stdout -{ - "id": "bn91jc", - "symbol": "BBCA.JK", - "summary": "Mixed analyst signals for Bank Central Asia Tbk PT: 1 positive, 1 negative, 1 neutral.", - "highlights": [ - "P/E ratio: Earnings data unavailable, can't compare P/E ratio with peers", - "Analyst price target: Analysts forecast more than 20% upside for 12-month price target" - ], - "risks": [ - "Quarterly Revenue YoY Growth: Revenue grew worse than peers" - ], - "last_updated": "2026-03-26T04:14:57.9197955Z" -} +{"id":"bn91jc","symbol":"BBCA.JK","summary":"Mixed analyst signals for Bank Central Asia Tbk PT: 1 positive, 1 negative, 1 neutral.","highlights":["P/E ratio: Earnings data unavailable, can't compare P/E ratio with peers","Analyst price target: Analysts forecast more than 20% upside for 12-month price target"],"risks":["Quarterly Revenue YoY Growth: Revenue grew worse than peers"],"last_updated":"2026-03-26T04:14:57.9197955Z"} --- stderr diff --git a/tests/golden/stocks-news.json.golden b/tests/golden/stocks-news.json.golden index 71cf540..647f530 100644 --- a/tests/golden/stocks-news.json.golden +++ b/tests/golden/stocks-news.json.golden @@ -1,15 +1,4 @@ exit: 0 --- stdout -[ - { - "id": "news-1", - "symbol": "BBCA.JK", - "title": "BCA reports steady growth", - "url": "https://example.com/news/bca-growth", - "description": "BCA delivered steady quarterly growth across lending and deposits.", - "provider": "Contoso News", - "published_at": "2026-03-20T10:00:00Z", - "read_time_min": 3 - } -] +[{"id":"news-1","symbol":"BBCA.JK","title":"BCA reports steady growth","url":"https://example.com/news/bca-growth","description":"BCA delivered steady quarterly growth across lending and deposits.","provider":"Contoso News","published_at":"2026-03-20T10:00:00Z","read_time_min":3}] --- stderr diff --git a/tests/golden/stocks-profile.json.golden b/tests/golden/stocks-profile.json.golden index afafd3f..4822115 100644 --- a/tests/golden/stocks-profile.json.golden +++ b/tests/golden/stocks-profile.json.golden @@ -1,27 +1,4 @@ exit: 0 --- stdout -{ - "id": "feee16af-c94c-465d-a59d-a9727c22bd0e", - "symbol": "BBCA", - "short_name": "Bank Cntrl Asia", - "long_name": "PT Bank Central Asia Tbk", - "description": "PT Bank Central Asia Tbk (the Bank) is an Indonesia-based commercial bank.", - "sector": "Financials", - "industry": "Banking Services", - "website": "https://www.bca.co.id/", - "employees": 27937, - "address": "Menara BCA, Grand, Jl. MH Thamrin No. 1", - "city": "JAKARTA PUSAT", - "country": "Indonesia", - "phone": "+62 21 23588000", - "officers": [ - { - "name": "Jahja Setiaatmadja", - "title": "President Director", - "age": 69, - "year_born": 1955, - "total_pay": 12000000000 - } - ] -} +{"id":"feee16af-c94c-465d-a59d-a9727c22bd0e","symbol":"BBCA","short_name":"Bank Cntrl Asia","long_name":"PT Bank Central Asia Tbk","description":"PT Bank Central Asia Tbk (the Bank) is an Indonesia-based commercial bank.","sector":"Financials","industry":"Banking Services","website":"https://www.bca.co.id/","employees":27937,"address":"Menara BCA, Grand, Jl. MH Thamrin No. 1","city":"JAKARTA PUSAT","country":"Indonesia","phone":"+62 21 23588000","officers":[{"name":"Jahja Setiaatmadja","title":"President Director","age":69,"year_born":1955,"total_pay":12000000000}]} --- stderr diff --git a/tests/golden/stocks-quote-multi.json.golden b/tests/golden/stocks-quote-multi.json.golden index 6095d47..9df5da7 100644 --- a/tests/golden/stocks-quote-multi.json.golden +++ b/tests/golden/stocks-quote-multi.json.golden @@ -1,35 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10250, - "week52_low": 7800, - "week52_position": 0.8469387755102041, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - }, - { - "symbol": "BBRI.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10250, - "week52_low": 7800, - "week52_position": 0.8469387755102041, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"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 diff --git a/tests/golden/stocks-quote-multi.jsonl.golden b/tests/golden/stocks-quote-multi.jsonl.golden new file mode 100644 index 0000000..36b651b --- /dev/null +++ b/tests/golden/stocks-quote-multi.jsonl.golden @@ -0,0 +1,5 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} +{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} +--- stderr diff --git a/tests/golden/stocks-quote-offline-warm.json.golden b/tests/golden/stocks-quote-offline-warm.json.golden index bfcbbaf..5f15d77 100644 --- a/tests/golden/stocks-quote-offline-warm.json.golden +++ b/tests/golden/stocks-quote-offline-warm.json.golden @@ -1,20 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10250, - "week52_low": 7800, - "week52_position": 0.8469387755102041, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-quote-stdin.json.golden b/tests/golden/stocks-quote-stdin.json.golden new file mode 100644 index 0000000..9df5da7 --- /dev/null +++ b/tests/golden/stocks-quote-stdin.json.golden @@ -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 diff --git a/tests/golden/stocks-quote-stdin.table.golden b/tests/golden/stocks-quote-stdin.table.golden new file mode 100644 index 0000000..99eb4e0 --- /dev/null +++ b/tests/golden/stocks-quote-stdin.table.golden @@ -0,0 +1,10 @@ +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 ┆ ████████░░ │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBRI.JK ┆ 9,875 ┆ +117 ┆ +1.20% ┆ 12,300,000 ┆ 1,215,200,000,000,000 ┆ ████████░░ │ +└─────────┴───────┴──────┴────────┴────────────┴───────────────────────┴────────────┘ +--- stderr diff --git a/tests/golden/stocks-quote.json.golden b/tests/golden/stocks-quote.json.golden index bfcbbaf..5f15d77 100644 --- a/tests/golden/stocks-quote.json.golden +++ b/tests/golden/stocks-quote.json.golden @@ -1,20 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10250, - "week52_low": 7800, - "week52_position": 0.8469387755102041, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-risk.json.golden b/tests/golden/stocks-risk.json.golden index 0cce202..fd4d313 100644 --- a/tests/golden/stocks-risk.json.golden +++ b/tests/golden/stocks-risk.json.golden @@ -1,12 +1,4 @@ exit: 0 --- stdout -{ - "debt_to_equity": 0.42, - "current_ratio": 1.18, - "roa": 0.031, - "roa_pct": 3.1, - "de_signal": "conservative", - "current_ratio_signal": "tight", - "overall_signal": "moderate risk" -} +{"debt_to_equity":0.42,"current_ratio":1.18,"roa":0.031,"roa_pct":3.1,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"} --- stderr diff --git a/tests/golden/stocks-risk.table.golden b/tests/golden/stocks-risk.table.golden index 9888a27..6360460 100644 --- a/tests/golden/stocks-risk.table.golden +++ b/tests/golden/stocks-risk.table.golden @@ -1,15 +1,15 @@ exit: 0 --- stdout -Risk Analysis for BBCA.JK -┌───────────────┬────────┬─────────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═══════════════╪════════╪═════════════════════════╡ -│ Debt/Equity ┆ 0.42 ┆ conservative │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Current Ratio ┆ 1.18 ┆ tight │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROA ┆ +3.10% ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ moderate risk │ -└───────────────┴────────┴─────────────────────────┘ +Risk Analysis for BBCA.JK +┌───────────────┬────────┬───────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═══════════════╡ +│ Debt/Equity ┆ 0.42 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.18 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.10% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴───────────────┘ --- stderr diff --git a/tests/golden/stocks-screen-high-volume.json.golden b/tests/golden/stocks-screen-high-volume.json.golden index a822087..191abda 100644 --- a/tests/golden/stocks-screen-high-volume.json.golden +++ b/tests/golden/stocks-screen-high-volume.json.golden @@ -1,35 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBRI.JK", - "price": 5100, - "change": 210, - "change_pct": 4.3, - "volume": 22200000, - "market_cap": 780000000000000, - "week52_high": 5400, - "week52_low": 3900, - "week52_position": 0.8, - "range_signal": "upper", - "prev_close": 4890, - "avg_volume": 18000000, - "as_of": null - }, - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10500, - "week52_low": 8000, - "week52_position": 0.75, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-screen-large-cap.json.golden b/tests/golden/stocks-screen-large-cap.json.golden index 94ac385..5303d7b 100644 --- a/tests/golden/stocks-screen-large-cap.json.golden +++ b/tests/golden/stocks-screen-large-cap.json.golden @@ -1,35 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10500, - "week52_low": 8000, - "week52_position": 0.75, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - }, - { - "symbol": "BBRI.JK", - "price": 5100, - "change": 210, - "change_pct": 4.3, - "volume": 22200000, - "market_cap": 780000000000000, - "week52_high": 5400, - "week52_low": 3900, - "week52_position": 0.8, - "range_signal": "upper", - "prev_close": 4890, - "avg_volume": 18000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-screen-worst.json.golden b/tests/golden/stocks-screen-worst.json.golden index 94ac385..5303d7b 100644 --- a/tests/golden/stocks-screen-worst.json.golden +++ b/tests/golden/stocks-screen-worst.json.golden @@ -1,35 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10500, - "week52_low": 8000, - "week52_position": 0.75, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - }, - { - "symbol": "BBRI.JK", - "price": 5100, - "change": 210, - "change_pct": 4.3, - "volume": 22200000, - "market_cap": 780000000000000, - "week52_high": 5400, - "week52_low": 3900, - "week52_position": 0.8, - "range_signal": "upper", - "prev_close": 4890, - "avg_volume": 18000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-screen.json.golden b/tests/golden/stocks-screen.json.golden index a822087..191abda 100644 --- a/tests/golden/stocks-screen.json.golden +++ b/tests/golden/stocks-screen.json.golden @@ -1,35 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBRI.JK", - "price": 5100, - "change": 210, - "change_pct": 4.3, - "volume": 22200000, - "market_cap": 780000000000000, - "week52_high": 5400, - "week52_low": 3900, - "week52_position": 0.8, - "range_signal": "upper", - "prev_close": 4890, - "avg_volume": 18000000, - "as_of": null - }, - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.2, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10500, - "week52_low": 8000, - "week52_position": 0.75, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] --- stderr diff --git a/tests/golden/stocks-sentiment.json.golden b/tests/golden/stocks-sentiment.json.golden index e4d6d4a..0aca7de 100644 --- a/tests/golden/stocks-sentiment.json.golden +++ b/tests/golden/stocks-sentiment.json.golden @@ -1,20 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "statistics": [ - { - "time_range": "1D", - "bullish": 10, - "bearish": 2, - "neutral": 3 - }, - { - "time_range": "1W", - "bullish": 25, - "bearish": 5, - "neutral": 7 - } - ] -} +{"symbol":"BBCA.JK","statistics":[{"time_range":"1D","bullish":10,"bearish":2,"neutral":3},{"time_range":"1W","bullish":25,"bearish":5,"neutral":7}]} --- stderr diff --git a/tests/golden/stocks-technical.json.golden b/tests/golden/stocks-technical.json.golden index 3e587b3..d0b773d 100644 --- a/tests/golden/stocks-technical.json.golden +++ b/tests/golden/stocks-technical.json.golden @@ -1,28 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "as_of": "2024-03-03", - "current_price": 9925, - "sma20": null, - "sma50": null, - "sma200": null, - "rsi14": null, - "macd": { - "line": null, - "signal": null, - "histogram": null - }, - "volume": { - "current": 14000000, - "average20": null, - "ratio20": null - }, - "signals": { - "rsi": "Neutral", - "macd": "Neutral", - "trend": "Neutral", - "overall": "Neutral" - } -} +{"symbol":"BBCA.JK","as_of":"2024-03-03","current_price":9925,"sma20":null,"sma50":null,"sma200":null,"rsi14":null,"macd":{"line":null,"signal":null,"histogram":null},"volume":{"current":14000000,"average20":null,"ratio20":null},"signals":{"rsi":"Neutral","macd":"Neutral","trend":"Neutral","overall":"Neutral"}} --- stderr diff --git a/tests/golden/stocks-technical.table.golden b/tests/golden/stocks-technical.table.golden index d1099d0..2bed5e0 100644 --- a/tests/golden/stocks-technical.table.golden +++ b/tests/golden/stocks-technical.table.golden @@ -1,26 +1,26 @@ exit: 0 --- stdout -Technical Analysis for BBCA.JK (2024-03-03) -┌───────────────────┬─────────────────────────────────────────────────────┬───────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═══════════════════╪═════════════════════════════════════════════════════╪═══════════════════╡ -│ Current Price ┆ 9,925 ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ SMA 20 ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ SMA 50 ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ SMA 200 ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ RSI (14) ┆ - ┆ Neutral │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ MACD (12,26,9) ┆ -/-/- ┆ Neutral │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Trend ┆ Trend unavailable (need at least 200 daily candles) ┆ Neutral │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Volume Ratio (20) ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall Signal ┆ - ┆ NEUTRAL │ -└───────────────────┴─────────────────────────────────────────────────────┴───────────────────┘ +Technical Analysis for BBCA.JK (2024-03-03) +┌───────────────────┬─────────────────────────────────────────────────────┬─────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════════╪═════════════════════════════════════════════════════╪═════════╡ +│ Current Price ┆ 9,925 ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ SMA 20 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ SMA 50 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ SMA 200 ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ RSI (14) ┆ - ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ MACD (12,26,9) ┆ -/-/- ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ Trend ┆ Trend unavailable (need at least 200 daily candles) ┆ Neutral │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ Volume Ratio (20) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ Overall Signal ┆ - ┆ NEUTRAL │ +└───────────────────┴─────────────────────────────────────────────────────┴─────────┘ --- stderr info: history provider fallback active (msn -> yahoo) diff --git a/tests/golden/stocks-valuation-nonfinite.json.golden b/tests/golden/stocks-valuation-nonfinite.json.golden index ef8159f..d5761cf 100644 --- a/tests/golden/stocks-valuation-nonfinite.json.golden +++ b/tests/golden/stocks-valuation-nonfinite.json.golden @@ -1,19 +1,4 @@ exit: 0 --- stdout -{ - "pe_trailing": null, - "pe_forward": null, - "pb": 4.6, - "roe": 0.198, - "roe_pct": 19.8, - "net_margin": 0.442, - "net_margin_pct": 44.2, - "ev_ebitda": null, - "pe_signal": "no data", - "pb_signal": "expensive", - "roe_signal": "strong", - "margin_signal": "excellent", - "ev_ebitda_signal": "no data", - "overall_signal": "expensive" -} +{"pe_trailing":null,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.442,"net_margin_pct":44.2,"ev_ebitda":null,"pe_signal":"no data","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"} --- stderr diff --git a/tests/golden/stocks-valuation-nonfinite.table.golden b/tests/golden/stocks-valuation-nonfinite.table.golden index 6ccd5d2..9626075 100644 --- a/tests/golden/stocks-valuation-nonfinite.table.golden +++ b/tests/golden/stocks-valuation-nonfinite.table.golden @@ -1,21 +1,21 @@ exit: 0 --- stdout -Valuation Analysis for BBCA.JK -┌────────────────┬─────────┬─────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞════════════════╪═════════╪═════════════════════╡ -│ P/E (Trailing) ┆ - ┆ no data │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ P/E (Forward) ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Price/Book ┆ 4.60 ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROE ┆ +19.80% ┆ strong │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Net Margin ┆ +44.20% ┆ excellent │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ EV/EBITDA ┆ - ┆ no data │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ expensive │ -└────────────────┴─────────┴─────────────────────┘ +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬───────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═══════════╡ +│ P/E (Trailing) ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +44.20% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴───────────┘ --- stderr diff --git a/tests/golden/stocks-valuation.json.golden b/tests/golden/stocks-valuation.json.golden index f795bfb..3cdc063 100644 --- a/tests/golden/stocks-valuation.json.golden +++ b/tests/golden/stocks-valuation.json.golden @@ -1,19 +1,4 @@ exit: 0 --- stdout -{ - "pe_trailing": 25.4, - "pe_forward": null, - "pb": 4.6, - "roe": 0.198, - "roe_pct": 19.8, - "net_margin": 0.324, - "net_margin_pct": 32.4, - "ev_ebitda": null, - "pe_signal": "premium", - "pb_signal": "expensive", - "roe_signal": "strong", - "margin_signal": "excellent", - "ev_ebitda_signal": "no data", - "overall_signal": "expensive" -} +{"pe_trailing":25.4,"pe_forward":null,"pb":4.6,"roe":0.198,"roe_pct":19.8,"net_margin":0.324,"net_margin_pct":32.4,"ev_ebitda":null,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"strong","margin_signal":"excellent","ev_ebitda_signal":"no data","overall_signal":"expensive"} --- stderr diff --git a/tests/golden/stocks-valuation.table.golden b/tests/golden/stocks-valuation.table.golden index 2a0666c..5964aa0 100644 --- a/tests/golden/stocks-valuation.table.golden +++ b/tests/golden/stocks-valuation.table.golden @@ -1,21 +1,21 @@ exit: 0 --- stdout -Valuation Analysis for BBCA.JK -┌────────────────┬─────────┬─────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞════════════════╪═════════╪═════════════════════╡ -│ P/E (Trailing) ┆ 25.40 ┆ premium │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ P/E (Forward) ┆ - ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Price/Book ┆ 4.60 ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROE ┆ +19.80% ┆ strong │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Net Margin ┆ +32.40% ┆ excellent │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ EV/EBITDA ┆ - ┆ no data │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ expensive │ -└────────────────┴─────────┴─────────────────────┘ +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬───────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═══════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ - ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +19.80% ┆ strong │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +32.40% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ - ┆ no data │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴───────────┘ --- stderr diff --git a/tests/golden/yahoo-fundamental.json.golden b/tests/golden/yahoo-fundamental.json.golden index 8857e28..d463687 100644 --- a/tests/golden/yahoo-fundamental.json.golden +++ b/tests/golden/yahoo-fundamental.json.golden @@ -1,41 +1,4 @@ exit: 0 --- stdout -{ - "symbol": "BBCA.JK", - "growth": { - "revenue_growth": 0.118, - "earnings_growth": 0.121, - "revenue_growth_pct": 11.8, - "earnings_growth_pct": 12.1, - "revenue_signal": "moderate", - "earnings_signal": "moderate", - "overall_signal": "growing" - }, - "valuation": { - "pe_trailing": 25.4, - "pe_forward": 23.1, - "pb": 4.6, - "roe": 0.202, - "roe_pct": 20.2, - "net_margin": 0.385, - "net_margin_pct": 38.5, - "ev_ebitda": 21.28, - "pe_signal": "premium", - "pb_signal": "expensive", - "roe_signal": "excellent", - "margin_signal": "excellent", - "ev_ebitda_signal": "expensive", - "overall_signal": "expensive" - }, - "risk": { - "debt_to_equity": 18.5, - "current_ratio": 1.21, - "roa": 0.038, - "roa_pct": 3.8, - "de_signal": "conservative", - "current_ratio_signal": "tight", - "overall_signal": "moderate risk" - }, - "overall_signal": "mixed" -} +{"symbol":"BBCA.JK","growth":{"revenue_growth":0.118,"earnings_growth":0.121,"revenue_growth_pct":11.8,"earnings_growth_pct":12.1,"revenue_signal":"moderate","earnings_signal":"moderate","overall_signal":"growing"},"valuation":{"pe_trailing":25.4,"pe_forward":23.1,"pb":4.6,"roe":0.202,"roe_pct":20.2,"net_margin":0.385,"net_margin_pct":38.5,"ev_ebitda":21.28,"pe_signal":"premium","pb_signal":"expensive","roe_signal":"excellent","margin_signal":"excellent","ev_ebitda_signal":"expensive","overall_signal":"expensive"},"risk":{"debt_to_equity":18.5,"current_ratio":1.21,"roa":0.038,"roa_pct":3.8,"de_signal":"conservative","current_ratio_signal":"tight","overall_signal":"moderate risk"},"overall_signal":"mixed"} --- stderr diff --git a/tests/golden/yahoo-fundamental.table.golden b/tests/golden/yahoo-fundamental.table.golden index 651349a..13cfbad 100644 --- a/tests/golden/yahoo-fundamental.table.golden +++ b/tests/golden/yahoo-fundamental.table.golden @@ -1,49 +1,49 @@ exit: 0 --- stdout -Fundamental Analysis for BBCA.JK +Fundamental Analysis for BBCA.JK -Growth Analysis for BBCA.JK -┌─────────────────┬─────────┬────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═════════════════╪═════════╪════════════════════╡ -│ Revenue Growth ┆ +11.80% ┆ moderate │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Earnings Growth ┆ +12.10% ┆ moderate │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ growing │ -└─────────────────┴─────────┴────────────────────┘ +Growth Analysis for BBCA.JK +┌─────────────────┬─────────┬──────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═════════════════╪═════════╪══════════╡ +│ Revenue Growth ┆ +11.80% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Earnings Growth ┆ +12.10% ┆ moderate │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ growing │ +└─────────────────┴─────────┴──────────┘ -Valuation Analysis for BBCA.JK -┌────────────────┬─────────┬─────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞════════════════╪═════════╪═════════════════════╡ -│ P/E (Trailing) ┆ 25.40 ┆ premium │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ P/E (Forward) ┆ 23.10 ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Price/Book ┆ 4.60 ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROE ┆ +20.20% ┆ excellent │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Net Margin ┆ +38.50% ┆ excellent │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ EV/EBITDA ┆ 21.28 ┆ expensive │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ expensive │ -└────────────────┴─────────┴─────────────────────┘ +Valuation Analysis for BBCA.JK +┌────────────────┬─────────┬───────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞════════════════╪═════════╪═══════════╡ +│ P/E (Trailing) ┆ 25.40 ┆ premium │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ P/E (Forward) ┆ 23.10 ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Price/Book ┆ 4.60 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROE ┆ +20.20% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Net Margin ┆ +38.50% ┆ excellent │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ EV/EBITDA ┆ 21.28 ┆ expensive │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ expensive │ +└────────────────┴─────────┴───────────┘ -Risk Analysis for BBCA.JK -┌───────────────┬────────┬─────────────────────────┐ -│ METRIC ┆ VALUE ┆ SIGNAL │ -╞═══════════════╪════════╪═════════════════════════╡ -│ Debt/Equity ┆ 18.50 ┆ conservative │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Current Ratio ┆ 1.21 ┆ tight │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ ROA ┆ +3.80% ┆ - │ -├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ Overall ┆ - ┆ moderate risk │ -└───────────────┴────────┴─────────────────────────┘ +Risk Analysis for BBCA.JK +┌───────────────┬────────┬───────────────┐ +│ METRIC ┆ VALUE ┆ SIGNAL │ +╞═══════════════╪════════╪═══════════════╡ +│ Debt/Equity ┆ 18.50 ┆ conservative │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Current Ratio ┆ 1.21 ┆ tight │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ ROA ┆ +3.80% ┆ - │ +├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Overall ┆ - ┆ moderate risk │ +└───────────────┴────────┴───────────────┘ -Overall Signal: mixed +Overall Signal: mixed --- stderr diff --git a/tests/golden/yahoo-history.json.golden b/tests/golden/yahoo-history.json.golden index 423e9c6..317447c 100644 --- a/tests/golden/yahoo-history.json.golden +++ b/tests/golden/yahoo-history.json.golden @@ -1,29 +1,4 @@ exit: 0 --- stdout -[ - { - "date": "2024-03-01", - "open": 9800, - "high": 9900, - "low": 9750, - "close": 9875, - "volume": 12300000 - }, - { - "date": "2024-03-02", - "open": 9850, - "high": 9920, - "low": 9800, - "close": 9880, - "volume": 11000000 - }, - { - "date": "2024-03-03", - "open": 9860, - "high": 9950, - "low": 9820, - "close": 9925, - "volume": 14000000 - } -] +[{"date":"2024-03-01","open":9800,"high":9900,"low":9750,"close":9875,"volume":12300000},{"date":"2024-03-02","open":9850,"high":9920,"low":9800,"close":9880,"volume":11000000},{"date":"2024-03-03","open":9860,"high":9950,"low":9820,"close":9925,"volume":14000000}] --- stderr diff --git a/tests/golden/yahoo-history.table.golden b/tests/golden/yahoo-history.table.golden index 59954ee..b20b772 100644 --- a/tests/golden/yahoo-history.table.golden +++ b/tests/golden/yahoo-history.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -History for BBCA.JK +History for BBCA.JK ┌────────────┬───────┬───────┬───────┬───────┬────────────┐ │ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ ╞════════════╪═══════╪═══════╪═══════╪═══════╪════════════╡ diff --git a/tests/golden/yahoo-quote.json.golden b/tests/golden/yahoo-quote.json.golden index ead2991..74ddf4e 100644 --- a/tests/golden/yahoo-quote.json.golden +++ b/tests/golden/yahoo-quote.json.golden @@ -1,20 +1,4 @@ exit: 0 --- stdout -[ - { - "symbol": "BBCA.JK", - "price": 9875, - "change": 117, - "change_pct": 1.1990161918425908, - "volume": 12300000, - "market_cap": 1215200000000000, - "week52_high": 10250, - "week52_low": 7800, - "week52_position": 0.8469387755102041, - "range_signal": "upper", - "prev_close": 9758, - "avg_volume": 10000000, - "as_of": null - } -] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] --- stderr From 15947de51a0efe446c9b1df5122fa8f700657f24 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 09:13:46 +0000 Subject: [PATCH 05/14] refactor: split into idx-core library and idx-cli binary workspace Phase 2 of docs/DESIGN.md, no behavior change: - crates/idx-core (library): api, analysis, cache, config file handling and provider selection, curl_impersonate, error, ownership, runtime, plus the shared test fixtures. clap derives are behind an optional `clap` feature so the library does not require clap. - crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output format and color resolution). Package and binary names are unchanged. - config.rs is split: file read/write and validation move to core; a CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting. - ownership::db::open_db/db_path drop their unused IdxConfig argument. - One workspace version; shared dependency versions; no third-party version changes in Cargo.lock. LICENSE is symlinked into each crate. - flake: workspace version, new source paths, and a per-crate package check (idx-cli is built from its tarball against packaged idx-core, since idx-core is not on crates.io yet). CI cache key, release path filter, and the golden whitespace-hook exclusion follow the new layout. Parity: all 143 golden outputs match byte for byte without regeneration; 157 core + 17 CLI unit tests (173 before plus the new OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main: 16/16 stocks cases identical; ownership discover got IDX 403 on both (exit 4 vs 1 is the phase 1 exit-code change). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 1 + .pre-commit-config.yaml | 4 +- AGENTS.md | 23 +-- Cargo.lock | 20 ++- Cargo.toml | 49 ++--- crates/idx-cli/Cargo.toml | 43 +++++ crates/idx-cli/LICENSE | 1 + {src => crates/idx-cli/src}/cli/cache.rs | 6 +- {src => crates/idx-cli/src}/cli/config.rs | 2 +- {src => crates/idx-cli/src}/cli/mod.rs | 0 {src => crates/idx-cli/src}/cli/ownership.rs | 44 ++--- {src => crates/idx-cli/src}/cli/stocks.rs | 78 ++++---- crates/idx-cli/src/config.rs | 167 ++++++++++++++++++ {src => crates/idx-cli/src}/main.rs | 38 +--- {src => crates/idx-cli/src}/output/json.rs | 2 +- {src => crates/idx-cli/src}/output/mod.rs | 10 +- {src => crates/idx-cli/src}/output/style.rs | 0 {src => crates/idx-cli/src}/output/table.rs | 18 +- {tests => crates/idx-cli/tests}/cli.rs | 45 ++--- {tests => crates/idx-cli/tests}/golden.rs | 23 ++- .../golden/auto-format-env-table.golden | 0 .../tests}/golden/auto-format-piped.golden | 0 .../tests}/golden/cache-clear.json.golden | 0 .../tests}/golden/cache-clear.table.golden | 0 .../golden/cache-info-empty.json.golden | 0 .../golden/cache-info-empty.table.golden | 0 .../idx-cli/tests}/golden/cases.toml | 0 .../tests}/golden/completions-bash.golden | 0 .../idx-cli/tests}/golden/config-get.golden | 0 .../idx-cli/tests}/golden/config-init.golden | 0 .../config-output-table-pins-format.golden | 0 .../idx-cli/tests}/golden/config-path.golden | 0 .../golden/config-set-output-invalid.golden | 0 .../golden/config-set-output-jsonl.golden | 0 .../idx-cli/tests}/golden/config-set.golden | 0 .../error-compare-total-failure.json.golden | 0 .../error-compare-total-failure.table.golden | 0 .../error-fetch-bing-unsupported.json.golden | 0 .../error-fetch-bing-unsupported.table.golden | 0 ...ror-industry-only-fundamentals.json.golden | 0 ...or-industry-only-fundamentals.table.golden | 0 .../golden/error-invalid-output-env.golden | 0 .../golden/error-invalid-provider.json.golden | 0 .../error-invalid-provider.table.golden | 0 .../error-offline-cache-miss.json.golden | 0 .../error-offline-cache-miss.table.golden | 0 .../error-offline-no-cache-flags.json.golden | 0 .../error-offline-no-cache-flags.table.golden | 0 .../golden/error-provider-failure.json.golden | 0 .../error-provider-failure.table.golden | 0 .../golden/error-screen-region.json.golden | 0 .../golden/error-screen-region.jsonl.golden | 0 .../golden/error-screen-region.table.golden | 0 .../golden/error-stdin-empty.json.golden | 0 .../golden/error-stdin-empty.table.golden | 0 .../error-usage-missing-symbol.json.golden | 0 .../error-usage-missing-symbol.table.golden | 0 ...rror-yahoo-profile-unsupported.json.golden | 0 ...ror-yahoo-profile-unsupported.table.golden | 0 .../idx-cli/tests}/golden/help-cache.golden | 0 .../idx-cli/tests}/golden/help-config.golden | 0 .../tests}/golden/help-ownership.golden | 0 .../idx-cli/tests}/golden/help-root.golden | 0 .../idx-cli/tests}/golden/help-stocks.golden | 0 .../golden/ownership-changes.json.golden | 0 .../golden/ownership-changes.jsonl.golden | 0 .../golden/ownership-changes.table.golden | 0 .../ownership-concentration.json.golden | 0 .../ownership-concentration.table.golden | 0 .../ownership-cross-holders.json.golden | 0 .../ownership-cross-holders.table.golden | 0 .../golden/ownership-entity.json.golden | 0 .../golden/ownership-entity.table.golden | 0 .../tests}/golden/ownership-flow.json.golden | 0 .../tests}/golden/ownership-flow.table.golden | 0 .../golden/ownership-graph-dot.json.golden | 0 .../golden/ownership-graph-dot.table.golden | 0 .../tests}/golden/ownership-graph.json.golden | 0 .../golden/ownership-graph.table.golden | 0 .../ownership-import-archive-txt.json.golden | 0 .../ownership-import-archive-txt.table.golden | 0 .../golden/ownership-import-xlsx.json.golden | 0 .../golden/ownership-import-xlsx.table.golden | 0 .../ownership-releases-empty.json.golden | 0 .../ownership-releases-empty.table.golden | 0 .../golden/ownership-releases.json.golden | 0 .../golden/ownership-releases.table.golden | 0 .../golden/ownership-resolve-map.json.golden | 0 .../golden/ownership-resolve-map.table.golden | 0 .../ownership-resolve-merge.json.golden | 0 .../ownership-resolve-merge.table.golden | 0 .../ownership-resolve-unresolved.json.golden | 0 .../ownership-resolve-unresolved.table.golden | 0 .../golden/ownership-search.json.golden | 0 .../golden/ownership-search.table.golden | 0 .../golden/ownership-ticker-empty.json.golden | 0 .../ownership-ticker-empty.table.golden | 0 .../golden/ownership-ticker-ksei.json.golden | 0 .../golden/ownership-ticker-ksei.table.golden | 0 .../golden/ownership-ticker.json.golden | 0 .../golden/ownership-ticker.table.golden | 0 .../golden/stocks-compare-stdin.json.golden | 0 .../golden/stocks-compare-stdin.table.golden | 0 .../tests}/golden/stocks-compare.json.golden | 0 .../tests}/golden/stocks-compare.table.golden | 0 ...tocks-earnings-forecast-annual.json.golden | 0 ...ocks-earnings-forecast-annual.table.golden | 0 ...cks-earnings-history-quarterly.json.golden | 0 ...ks-earnings-history-quarterly.table.golden | 0 .../tests}/golden/stocks-earnings.json.golden | 0 .../golden/stocks-earnings.table.golden | 0 .../stocks-financials-income.json.golden | 0 .../stocks-financials-income.table.golden | 0 .../golden/stocks-financials.json.golden | 0 .../golden/stocks-financials.table.golden | 0 .../golden/stocks-fundamental.json.golden | 0 .../golden/stocks-fundamental.jsonl.golden | 0 .../golden/stocks-fundamental.table.golden | 0 .../tests}/golden/stocks-growth.json.golden | 0 .../tests}/golden/stocks-growth.table.golden | 0 .../golden/stocks-history-msn.json.golden | 0 .../golden/stocks-history-msn.table.golden | 0 .../tests}/golden/stocks-history.json.golden | 0 .../tests}/golden/stocks-history.jsonl.golden | 0 .../tests}/golden/stocks-history.table.golden | 0 .../tests}/golden/stocks-insights.json.golden | 0 .../golden/stocks-insights.table.golden | 0 .../tests}/golden/stocks-news.json.golden | 0 .../tests}/golden/stocks-news.table.golden | 0 .../tests}/golden/stocks-profile.json.golden | 0 .../tests}/golden/stocks-profile.table.golden | 0 .../golden/stocks-quote-multi.json.golden | 0 .../golden/stocks-quote-multi.jsonl.golden | 0 .../golden/stocks-quote-multi.table.golden | 0 .../stocks-quote-offline-warm.json.golden | 0 .../stocks-quote-offline-warm.table.golden | 0 .../golden/stocks-quote-stdin.json.golden | 0 .../golden/stocks-quote-stdin.table.golden | 0 .../tests}/golden/stocks-quote.json.golden | 0 .../tests}/golden/stocks-quote.table.golden | 0 .../tests}/golden/stocks-risk.json.golden | 0 .../tests}/golden/stocks-risk.table.golden | 0 .../stocks-screen-high-volume.json.golden | 0 .../stocks-screen-high-volume.table.golden | 0 .../stocks-screen-large-cap.json.golden | 0 .../stocks-screen-large-cap.table.golden | 0 .../golden/stocks-screen-worst.json.golden | 0 .../golden/stocks-screen-worst.table.golden | 0 .../tests}/golden/stocks-screen.json.golden | 0 .../tests}/golden/stocks-screen.table.golden | 0 .../golden/stocks-sentiment.json.golden | 0 .../golden/stocks-sentiment.table.golden | 0 .../golden/stocks-technical.json.golden | 0 .../golden/stocks-technical.table.golden | 0 .../stocks-valuation-nonfinite.json.golden | 0 .../stocks-valuation-nonfinite.table.golden | 0 .../golden/stocks-valuation.json.golden | 0 .../golden/stocks-valuation.table.golden | 0 .../golden/yahoo-fundamental.json.golden | 0 .../golden/yahoo-fundamental.table.golden | 0 .../tests}/golden/yahoo-history.json.golden | 0 .../tests}/golden/yahoo-history.table.golden | 0 .../tests}/golden/yahoo-quote.json.golden | 0 .../tests}/golden/yahoo-quote.table.golden | 0 crates/idx-core/Cargo.toml | 34 ++++ crates/idx-core/LICENSE | 1 + .../idx-core/src}/analysis/fundamental.rs | 0 {src => crates/idx-core/src}/analysis/mod.rs | 0 .../idx-core/src}/analysis/signals.rs | 0 .../idx-core/src}/analysis/technical.rs | 0 {src => crates/idx-core/src}/api/mod.rs | 0 {src => crates/idx-core/src}/api/msn/bing.rs | 0 .../idx-core/src}/api/msn/client.rs | 0 {src => crates/idx-core/src}/api/msn/map.rs | 0 {src => crates/idx-core/src}/api/msn/mod.rs | 0 {src => crates/idx-core/src}/api/msn/parse.rs | 0 .../idx-core/src}/api/msn/raw_types.rs | 0 .../idx-core/src}/api/msn/symbol_ids.tsv | 0 .../idx-core/src}/api/msn/symbols.rs | 0 {src => crates/idx-core/src}/api/types.rs | 29 +-- .../idx-core/src}/api/yahoo/client.rs | 0 {src => crates/idx-core/src}/api/yahoo/map.rs | 0 {src => crates/idx-core/src}/api/yahoo/mod.rs | 0 .../idx-core/src}/api/yahoo/parse.rs | 0 .../idx-core/src}/api/yahoo/raw_types.rs | 0 {src => crates/idx-core/src}/cache.rs | 0 {src => crates/idx-core/src}/config.rs | 160 ++--------------- .../idx-core/src}/curl_impersonate.rs | 0 {src => crates/idx-core/src}/error.rs | 0 crates/idx-core/src/lib.rs | 39 ++++ .../idx-core/src}/ownership/archive.rs | 0 {src => crates/idx-core/src}/ownership/db.rs | 8 +- .../idx-core/src}/ownership/entities.rs | 0 .../idx-core/src}/ownership/graph.rs | 0 {src => crates/idx-core/src}/ownership/mod.rs | 0 .../idx-core/src}/ownership/parser.rs | 0 .../idx-core/src}/ownership/remote.rs | 0 .../idx-core/src}/ownership/search.rs | 0 .../idx-core/src}/ownership/snapshot.rs | 0 .../idx-core/src}/ownership/types.rs | 0 .../idx-core/src}/ownership/xlsx.rs | 0 .../tests}/fixtures/bing_buyers_bbca.json | 0 .../tests}/fixtures/bing_holders_bbca.json | 0 .../tests}/fixtures/chart_bbca_1d.json | 0 .../tests}/fixtures/chart_bbca_3mo.json | 0 .../tests}/fixtures/chart_bbca_with_gap.json | 0 .../idx_announcement_kepemilikan.json | 0 .../idx_share_ownership_page_excerpt.html | 0 .../ksei_above1_20260831_excerpt.xlsx | Bin .../ksei_above1_spaced_stext_excerpt.xml | 0 .../fixtures/ksei_above1_stext_excerpt.xml | 0 .../ksei_above1_stext_excerpt_prev.xml | 0 .../ksei_above5_20260923_excerpt.xlsx | Bin .../fixtures/ksei_above5_stext_excerpt.xml | 0 ...sei_announcement_wrapper_stext_excerpt.xml | 0 .../ksei_balancepos_20260130_excerpt.txt | 0 .../ksei_balancepos_20260227_excerpt.txt | 0 .../ksei_investor_type_stext_excerpt.xml | 0 .../tests}/fixtures/ksei_stext_sample.xml | 0 .../tests}/fixtures/msn_chart_bbca_3m.json | 0 .../tests}/fixtures/msn_chart_bbca_3mo.json | 0 .../tests}/fixtures/msn_earnings_bbca.json | 0 .../tests}/fixtures/msn_financials_bbca.json | 0 .../tests}/fixtures/msn_insights_bbca.json | 0 .../tests}/fixtures/msn_keyratios_bbca.json | 0 .../fixtures/msn_keyratios_industry_only.json | 0 .../fixtures/msn_keyratios_infinity.json | 0 .../msn_keyratios_negative_infinity.json | 0 .../tests}/fixtures/msn_news_bbca.json | 0 .../tests}/fixtures/msn_profile_bbca.json | 0 .../tests}/fixtures/msn_quote_bbca.json | 0 .../fixtures/msn_screener_id_topperfs.json | 0 .../tests}/fixtures/msn_sentiment_bbca.json | 0 .../tests}/fixtures/quotesummary_bbca.json | 0 docs/ARCHITECTURE.md | 21 ++- docs/DESIGN.md | 4 +- flake.nix | 47 +++-- 238 files changed, 544 insertions(+), 375 deletions(-) create mode 100644 crates/idx-cli/Cargo.toml create mode 120000 crates/idx-cli/LICENSE rename {src => crates/idx-cli/src}/cli/cache.rs (96%) rename {src => crates/idx-cli/src}/cli/config.rs (97%) rename {src => crates/idx-cli/src}/cli/mod.rs (100%) rename {src => crates/idx-cli/src}/cli/ownership.rs (97%) rename {src => crates/idx-cli/src}/cli/stocks.rs (95%) create mode 100644 crates/idx-cli/src/config.rs rename {src => crates/idx-cli/src}/main.rs (80%) rename {src => crates/idx-cli/src}/output/json.rs (97%) rename {src => crates/idx-cli/src}/output/mod.rs (97%) rename {src => crates/idx-cli/src}/output/style.rs (100%) rename {src => crates/idx-cli/src}/output/table.rs (98%) rename {tests => crates/idx-cli/tests}/cli.rs (97%) rename {tests => crates/idx-cli/tests}/golden.rs (91%) rename {tests => crates/idx-cli/tests}/golden/auto-format-env-table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/auto-format-piped.golden (100%) rename {tests => crates/idx-cli/tests}/golden/cache-clear.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/cache-clear.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/cache-info-empty.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/cache-info-empty.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/cases.toml (100%) rename {tests => crates/idx-cli/tests}/golden/completions-bash.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-get.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-init.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-output-table-pins-format.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-path.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-set-output-invalid.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-set-output-jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/config-set.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-compare-total-failure.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-compare-total-failure.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-fetch-bing-unsupported.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-fetch-bing-unsupported.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-industry-only-fundamentals.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-industry-only-fundamentals.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-invalid-output-env.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-invalid-provider.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-invalid-provider.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-offline-cache-miss.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-offline-cache-miss.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-offline-no-cache-flags.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-offline-no-cache-flags.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-provider-failure.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-provider-failure.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-screen-region.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-screen-region.jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-screen-region.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-stdin-empty.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-stdin-empty.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-usage-missing-symbol.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-usage-missing-symbol.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-yahoo-profile-unsupported.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/error-yahoo-profile-unsupported.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/help-cache.golden (100%) rename {tests => crates/idx-cli/tests}/golden/help-config.golden (100%) rename {tests => crates/idx-cli/tests}/golden/help-ownership.golden (100%) rename {tests => crates/idx-cli/tests}/golden/help-root.golden (100%) rename {tests => crates/idx-cli/tests}/golden/help-stocks.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-changes.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-changes.jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-changes.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-concentration.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-concentration.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-cross-holders.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-cross-holders.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-entity.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-entity.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-flow.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-flow.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-graph-dot.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-graph-dot.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-graph.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-graph.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-import-archive-txt.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-import-archive-txt.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-import-xlsx.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-import-xlsx.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-releases-empty.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-releases-empty.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-releases.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-releases.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-map.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-map.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-merge.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-merge.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-unresolved.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-resolve-unresolved.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-search.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-search.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker-empty.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker-empty.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker-ksei.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker-ksei.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/ownership-ticker.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-compare-stdin.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-compare-stdin.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-compare.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-compare.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings-forecast-annual.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings-forecast-annual.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings-history-quarterly.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings-history-quarterly.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-earnings.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-financials-income.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-financials-income.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-financials.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-financials.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-fundamental.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-fundamental.jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-fundamental.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-growth.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-growth.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-history-msn.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-history-msn.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-history.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-history.jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-history.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-insights.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-insights.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-news.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-news.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-profile.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-profile.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-multi.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-multi.jsonl.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-multi.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-offline-warm.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-offline-warm.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-stdin.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote-stdin.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-quote.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-risk.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-risk.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-high-volume.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-high-volume.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-large-cap.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-large-cap.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-worst.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen-worst.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-screen.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-sentiment.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-sentiment.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-technical.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-technical.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-valuation-nonfinite.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-valuation-nonfinite.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-valuation.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/stocks-valuation.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-fundamental.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-fundamental.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-history.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-history.table.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-quote.json.golden (100%) rename {tests => crates/idx-cli/tests}/golden/yahoo-quote.table.golden (100%) create mode 100644 crates/idx-core/Cargo.toml create mode 120000 crates/idx-core/LICENSE rename {src => crates/idx-core/src}/analysis/fundamental.rs (100%) rename {src => crates/idx-core/src}/analysis/mod.rs (100%) rename {src => crates/idx-core/src}/analysis/signals.rs (100%) rename {src => crates/idx-core/src}/analysis/technical.rs (100%) rename {src => crates/idx-core/src}/api/mod.rs (100%) rename {src => crates/idx-core/src}/api/msn/bing.rs (100%) rename {src => crates/idx-core/src}/api/msn/client.rs (100%) rename {src => crates/idx-core/src}/api/msn/map.rs (100%) rename {src => crates/idx-core/src}/api/msn/mod.rs (100%) rename {src => crates/idx-core/src}/api/msn/parse.rs (100%) rename {src => crates/idx-core/src}/api/msn/raw_types.rs (100%) rename {src => crates/idx-core/src}/api/msn/symbol_ids.tsv (100%) rename {src => crates/idx-core/src}/api/msn/symbols.rs (100%) rename {src => crates/idx-core/src}/api/types.rs (92%) rename {src => crates/idx-core/src}/api/yahoo/client.rs (100%) rename {src => crates/idx-core/src}/api/yahoo/map.rs (100%) rename {src => crates/idx-core/src}/api/yahoo/mod.rs (100%) rename {src => crates/idx-core/src}/api/yahoo/parse.rs (100%) rename {src => crates/idx-core/src}/api/yahoo/raw_types.rs (100%) rename {src => crates/idx-core/src}/cache.rs (100%) rename {src => crates/idx-core/src}/config.rs (75%) rename {src => crates/idx-core/src}/curl_impersonate.rs (100%) rename {src => crates/idx-core/src}/error.rs (100%) create mode 100644 crates/idx-core/src/lib.rs rename {src => crates/idx-core/src}/ownership/archive.rs (100%) rename {src => crates/idx-core/src}/ownership/db.rs (99%) rename {src => crates/idx-core/src}/ownership/entities.rs (100%) rename {src => crates/idx-core/src}/ownership/graph.rs (100%) rename {src => crates/idx-core/src}/ownership/mod.rs (100%) rename {src => crates/idx-core/src}/ownership/parser.rs (100%) rename {src => crates/idx-core/src}/ownership/remote.rs (100%) rename {src => crates/idx-core/src}/ownership/search.rs (100%) rename {src => crates/idx-core/src}/ownership/snapshot.rs (100%) rename {src => crates/idx-core/src}/ownership/types.rs (100%) rename {src => crates/idx-core/src}/ownership/xlsx.rs (100%) rename {tests => crates/idx-core/tests}/fixtures/bing_buyers_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/bing_holders_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/chart_bbca_1d.json (100%) rename {tests => crates/idx-core/tests}/fixtures/chart_bbca_3mo.json (100%) rename {tests => crates/idx-core/tests}/fixtures/chart_bbca_with_gap.json (100%) rename {tests => crates/idx-core/tests}/fixtures/idx_announcement_kepemilikan.json (100%) rename {tests => crates/idx-core/tests}/fixtures/idx_share_ownership_page_excerpt.html (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above1_20260831_excerpt.xlsx (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above1_spaced_stext_excerpt.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above1_stext_excerpt.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above1_stext_excerpt_prev.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above5_20260923_excerpt.xlsx (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_above5_stext_excerpt.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_announcement_wrapper_stext_excerpt.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_balancepos_20260130_excerpt.txt (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_balancepos_20260227_excerpt.txt (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_investor_type_stext_excerpt.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/ksei_stext_sample.xml (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_chart_bbca_3m.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_chart_bbca_3mo.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_earnings_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_financials_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_insights_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_keyratios_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_keyratios_industry_only.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_keyratios_infinity.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_keyratios_negative_infinity.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_news_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_profile_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_quote_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_screener_id_topperfs.json (100%) rename {tests => crates/idx-core/tests}/fixtures/msn_sentiment_bbca.json (100%) rename {tests => crates/idx-core/tests}/fixtures/quotesummary_bbca.json (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ee893e..91a4a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }}- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 887cfcb..f18dc43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: paths: - ".github/workflows/release.yml" - "Cargo.toml" + - "crates/*/Cargo.toml" - "Cargo.lock" - "package.json" - "scripts/install.js" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dfb9713..f1c407f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,9 @@ repos: hooks: # Golden files pin exact CLI output, including trailing spaces. - id: trailing-whitespace - exclude: ^tests/golden/.*\.golden$ + exclude: ^crates/idx-cli/tests/golden/.*\.golden$ - id: end-of-file-fixer - exclude: ^tests/golden/.*\.golden$ + exclude: ^crates/idx-cli/tests/golden/.*\.golden$ - id: check-merge-conflict - repo: local diff --git a/AGENTS.md b/AGENTS.md index 70b7779..122a90a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,16 +5,19 @@ ## 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, -│ snapshot sync, db (SQLite/FTS5), entities, search, graph -└── output/ table.rs, json.rs -tests/ cli.rs (behavior), golden.rs + golden/ (output parity), fixtures/ -scripts/ smoke, installer, and ownership snapshot publisher +crates/idx-core/ library: all data access and analysis +├── 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 +├── 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) +scripts/ smoke, installer, and ownership snapshot publisher ``` Docs: diff --git a/Cargo.lock b/Cargo.lock index d9b9acf..f8f727a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,10 +718,26 @@ dependencies = [ "clap", "clap_complete", "comfy-table", - "directories", - "fastrand", + "idx-core", "owo-colors", "predicates", + "rusqlite", + "serde", + "serde_json", + "sha2", + "toml", + "ureq", + "zip", +] + +[[package]] +name = "idx-core" +version = "0.2.4" +dependencies = [ + "chrono", + "clap", + "directories", + "fastrand", "quick-xml", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0cac163..68c9bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,50 +1,23 @@ -[package] -name = "idx-cli" +[workspace] +resolver = "3" +members = ["crates/idx-core", "crates/idx-cli"] + +[workspace.package] version = "0.2.4" 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.2.4" } clap = { version = "4", features = ["derive"] } -clap_complete = "4" -ureq = { version = "3", features = ["json", "cookies"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } -comfy-table = "7" -owo-colors = "4" 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"] } diff --git a/crates/idx-cli/Cargo.toml b/crates/idx-cli/Cargo.toml new file mode 100644 index 0000000..53fa329 --- /dev/null +++ b/crates/idx-cli/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "idx-cli" +description = "CLI tool for Indonesian stock market (IDX) analysis" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme = "../../README.md" +keywords = ["idx", "stocks", "indonesia", "cli", "finance"] +categories = ["command-line-utilities", "finance"] +include = ["/Cargo.toml", "/LICENSE", "/src/**"] + +[[bin]] +name = "idx" +path = "src/main.rs" + +[features] +default = ["ownership"] +# rusqlite/sha2/ureq back ownership import code that phase 3 moves into idx-core. +ownership = ["idx-core/ownership", "dep:rusqlite", "dep:sha2", "dep:ureq"] + +[dependencies] +idx-core = { workspace = true, features = ["clap"] } +clap.workspace = true +clap_complete = "4" +serde.workspace = true +serde_json.workspace = true +comfy-table = "7" +owo-colors = "4" +toml.workspace = true +chrono.workspace = true +rusqlite = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +ureq = { workspace = true, optional = true } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +rusqlite.workspace = true +sha2.workspace = true +zip.workspace = true diff --git a/crates/idx-cli/LICENSE b/crates/idx-cli/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/idx-cli/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/src/cli/cache.rs b/crates/idx-cli/src/cli/cache.rs similarity index 96% rename from src/cli/cache.rs rename to crates/idx-cli/src/cli/cache.rs index 8b190a7..f542d10 100644 --- a/src/cli/cache.rs +++ b/crates/idx-cli/src/cli/cache.rs @@ -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")] diff --git a/src/cli/config.rs b/crates/idx-cli/src/cli/config.rs similarity index 97% rename from src/cli/config.rs rename to crates/idx-cli/src/cli/config.rs index 17f7cc7..cd77da2 100644 --- a/src/cli/config.rs +++ b/crates/idx-cli/src/cli/config.rs @@ -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")] diff --git a/src/cli/mod.rs b/crates/idx-cli/src/cli/mod.rs similarity index 100% rename from src/cli/mod.rs rename to crates/idx-cli/src/cli/mod.rs diff --git a/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs similarity index 97% rename from src/cli/ownership.rs rename to crates/idx-cli/src/cli/ownership.rs index eeadaea..f5d33b5 100644 --- a/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -10,13 +10,13 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use crate::config::IdxConfig; -use crate::error::IdxError; use crate::output::json; use crate::output::table::format_idr; -use crate::ownership::types::{ +use idx_core::error::IdxError; +use idx_core::ownership::types::{ ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource, }; -use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx}; +use idx_core::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx}; #[derive(Debug, Args)] #[command( @@ -215,7 +215,7 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError 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 db_path = db::db_path()?; let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?; if config.output.is_json() { @@ -301,7 +301,7 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr } fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let symbol = args.symbol.trim().to_uppercase(); let source = args.source.trim().to_lowercase(); if !["all", "ksei", "bing"].contains(&source.as_str()) { @@ -368,8 +368,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,7 +393,7 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> } fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let q = format!("%{}%", args.name.trim()); let entity = conn @@ -436,7 +436,7 @@ fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> } fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let entities = search::fts_search(&conn, &args.query, args.limit)?; if config.output.is_json() { @@ -471,7 +471,7 @@ fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> } fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?; if config.output.is_json() { @@ -498,7 +498,7 @@ fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<() } fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let by = args.by.trim().to_lowercase(); if !["top1", "top3", "hhi"].contains(&by.as_str()) { return Err(IdxError::ParseError( @@ -543,7 +543,7 @@ fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result< } fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let symbol = args.symbol.trim().to_uppercase(); let ticker_id = db::get_ticker_id(&conn, &symbol)? .ok_or_else(|| IdxError::SymbolNotFound(symbol.clone()))?; @@ -551,7 +551,9 @@ fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { let flow = db::query_bing_flow(&conn, ticker_id)?; let Some(flow) = flow else { if config.output.is_json() { - return json::print_json(&Option::::None); + return json::print_json( + &Option::::None, + ); } println!("No institutional flow data available for {symbol}."); return Ok(()); @@ -595,14 +597,14 @@ fn print_flow_section(title: &str, rows: &[HolderRow]) { } fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let (nodes, edges) = graph::query_ownership_graph(&conn, &args.root, args.depth)?; 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,7 +628,7 @@ 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 conn = db::open_db()?; let changes = db::query_changes(&conn, &args.from, &args.to)?; if config.output.is_json() { @@ -672,7 +674,7 @@ fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError } fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; match &args.command { ResolveCommand::Unresolved { limit } => { @@ -799,7 +801,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> return Ok(()); }; - let conn = db::open_db(config)?; + let conn = db::open_db()?; let sha256 = sha256_file(&import_input.import_path)?; if !args.force && db::release_exists(&conn, &sha256)? { @@ -911,7 +913,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> } fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db(config)?; + let conn = db::open_db()?; let releases = db::query_releases(&conn)?; if config.output.is_json() { @@ -1042,7 +1044,7 @@ fn cache_download_path(url: &str, format: ImportInputFormat) -> Result ".xlsx", _ => ".pdf", }; - let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw"); + let raw_dir = idx_core::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 diff --git a/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs similarity index 95% rename from src/cli/stocks.rs rename to crates/idx-cli/src/cli/stocks.rs index 430c1ce..8aaae85 100644 --- a/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,27 +4,27 @@ use std::io::Read; use clap::{Args, Subcommand, ValueEnum}; use serde::{Serialize, de::DeserializeOwned}; -use crate::analysis::fundamental::{ - FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, - analyze_growth, analyze_risk, analyze_valuation, -}; -use crate::analysis::signals::{self, Signal, TechnicalSignal}; -use crate::analysis::technical; -use crate::api::types::{ - CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - NewsItem, Ohlc, Period, Quote, SentimentData, -}; -use crate::api::{MarketDataProvider, SelectedProvider, history_provider}; -use crate::cache::Cache; use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind}; -use crate::error::IdxError; use crate::output::{ MacdSnapshot, TechnicalReport, VolumeSnapshot, 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 crate::runtime; +use idx_core::analysis::fundamental::{ + FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, + analyze_growth, analyze_risk, analyze_valuation, +}; +use idx_core::analysis::signals::{self, Signal, TechnicalSignal}; +use idx_core::analysis::technical; +use idx_core::api::types::{ + CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, + NewsItem, Ohlc, Period, Quote, SentimentData, +}; +use idx_core::api::{MarketDataProvider, SelectedProvider, history_provider}; +use idx_core::cache::Cache; +use idx_core::error::IdxError; +use idx_core::runtime; struct FundamentalCacheSpec { bucket: String, @@ -287,7 +287,7 @@ pub fn handle( let quote_bucket = cache_bucket(provider.kind(), "quote"); let mut quotes = Vec::new(); for sym in expand_symbols(symbols)? { - let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { quotes.push(q); continue; @@ -343,10 +343,10 @@ pub fn handle( )); } let history_bucket = format!("{}-history", history_source.as_str()); - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let key = format!("{}-{}", period.as_str(), interval.as_str()); if !no_cache - && let Some(history) = cache.get::>( + && let Some(history) = cache.get::>( &history_bucket, &format!("{resolved}-{key}"), )? @@ -355,7 +355,7 @@ pub fn handle( } if offline { let stale = cache - .get_offline::>( + .get_offline::>( &history_bucket, &format!("{resolved}-{key}"), )? @@ -379,7 +379,7 @@ pub fn handle( } Err(err) => { if !no_cache - && let Some(stale) = cache.get_stale::>( + && let Some(stale) = cache.get_stale::>( &history_bucket, &format!("{resolved}-{key}"), )? @@ -411,7 +411,7 @@ pub fn handle( )); } let technical_bucket = format!("{}-technical", history_source.as_str()); - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; if !no_cache && let Some(report) = cache.get::(&technical_bucket, &resolved)? { @@ -447,7 +447,7 @@ pub fn handle( } } StocksSubcommand::Growth { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let report: GrowthReport = fetch_fundamental_analysis_report( &cache, provider.market(), @@ -463,7 +463,7 @@ pub fn handle( render_growth(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Valuation { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let report: ValuationReport = fetch_fundamental_analysis_report( &cache, provider.market(), @@ -479,7 +479,7 @@ pub fn handle( render_valuation(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Risk { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let report: RiskReport = fetch_fundamental_analysis_report( &cache, provider.market(), @@ -495,7 +495,7 @@ pub fn handle( render_risk(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Fundamental { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let report: FundamentalReport = fetch_fundamental_analysis_report( &cache, provider.market(), @@ -511,7 +511,7 @@ pub fn handle( render_fundamental(&report, &config.output, config.no_color) } StocksSubcommand::Profile { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "profile-v2"); let profile_provider = provider.profile_provider(&resolved)?; let profile: CompanyProfile = fetch_with_cache( @@ -529,7 +529,7 @@ pub fn handle( render_profile(&profile, &config.output) } StocksSubcommand::Financials { symbol, filters } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "financials"); let financials_provider = provider.financials_provider(&resolved)?; let mut financials: FinancialStatements = fetch_with_cache( @@ -553,7 +553,7 @@ pub fn handle( render_financials(&filtered, &config.output) } StocksSubcommand::Earnings { symbol, filters } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "earnings"); let earnings_provider = provider.earnings_provider(&resolved)?; let mut earnings: EarningsReport = fetch_with_cache( @@ -575,7 +575,7 @@ pub fn handle( render_earnings(&filtered, &config.output) } StocksSubcommand::Sentiment { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "sentiment"); let sentiment_provider = provider.sentiment_provider(&resolved)?; let sentiment: SentimentData = fetch_with_cache( @@ -593,7 +593,7 @@ pub fn handle( render_sentiment(&sentiment, &config.output) } StocksSubcommand::Insights { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "insights-v2"); let insights_provider = provider.insights_provider(&resolved)?; let mut insights: InsightData = fetch_with_cache( @@ -614,7 +614,7 @@ pub fn handle( render_insights(&insights, &config.output) } StocksSubcommand::News { symbol, limit } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; let bucket = cache_bucket(provider.kind(), "news"); let key = format!("{resolved}-{limit}"); let news_provider = provider.news_provider(&resolved)?; @@ -679,7 +679,7 @@ pub fn handle( let mut failures = Vec::new(); for sym in expand_symbols(symbols)? { - let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; + let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; match fetch_fundamental_analysis_report( &cache, provider.market(), @@ -920,7 +920,7 @@ fn filter_financial_statements( } fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport { - let filter_rows = |rows: &[crate::api::types::EarningsData]| { + let filter_rows = |rows: &[idx_core::api::types::EarningsData]| { rows.iter() .filter(|row| filters.includes_period(&row.period_type)) .cloned() @@ -1044,11 +1044,11 @@ mod tests { build_technical_report, classify_earnings_period, filter_earnings_report, filter_financial_statements, }; - use crate::analysis::signals::Signal; - use crate::api::types::{ + use idx_core::analysis::signals::Signal; + use idx_core::api::types::{ EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection, }; - use crate::error::IdxError; + use idx_core::error::IdxError; #[test] fn technical_report_uses_latest_values() { @@ -1080,7 +1080,7 @@ mod tests { #[test] fn sort_screener_top_performers_descending() { - use crate::api::types::Quote; + use idx_core::api::types::Quote; let mut quotes = vec![ Quote { @@ -1137,7 +1137,7 @@ mod tests { #[test] fn sort_screener_worst_performers_ascending() { - use crate::api::types::Quote; + use idx_core::api::types::Quote; let mut quotes = vec![ Quote { @@ -1205,8 +1205,8 @@ mod tests { assert_eq!(quotes.last().unwrap().symbol, "U"); } - fn sample_quote(symbol: &str, change_pct: Option) -> crate::api::types::Quote { - crate::api::types::Quote { + fn sample_quote(symbol: &str, change_pct: Option) -> idx_core::api::types::Quote { + idx_core::api::types::Quote { symbol: symbol.into(), price: 100, change: None, diff --git a/crates/idx-cli/src/config.rs b/crates/idx-cli/src/config.rs new file mode 100644 index 0000000..c41c1d1 --- /dev/null +++ b/crates/idx-cli/src/config.rs @@ -0,0 +1,167 @@ +use std::fs; +use std::io::IsTerminal; + +use serde::Deserialize; + +pub use idx_core::config::*; + +use crate::cli::Cli; +use crate::output::{OutputFormat, OutputSetting}; +use idx_core::error::IdxError; + +#[derive(Debug, Clone)] +pub struct IdxConfig { + pub provider: ProviderKind, + pub history_provider: HistoryProviderKind, + pub exchange: String, + pub output: OutputFormat, + pub no_color: bool, + pub quote_ttl: u64, + pub fundamental_ttl: u64, +} + +#[derive(Debug, Deserialize, Default)] +struct FileConfig { + general: Option, + cache: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileGeneral { + provider: Option, + history_provider: Option, + exchange: Option, + output: Option, + color: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileCache { + quote_ttl: Option, + fundamental_ttl: Option, +} + +impl Default for IdxConfig { + fn default() -> Self { + Self { + provider: ProviderKind::Msn, + history_provider: HistoryProviderKind::Auto, + exchange: "JK".to_string(), + output: OutputSetting::Auto.resolve(stdout_is_terminal()), + no_color: false, + quote_ttl: 300, + fundamental_ttl: 3600, + } + } +} + +impl IdxConfig { + pub fn load_with_cli(cli: &Cli) -> Result { + let mut cfg = Self::load()?; + + if let Ok(provider) = std::env::var("IDX_PROVIDER") { + cfg.provider = ProviderKind::parse(&provider)?; + } + if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") { + cfg.history_provider = HistoryProviderKind::parse(&history_provider)?; + } + if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { + cfg.exchange = exchange; + } + if let Ok(output) = std::env::var("IDX_OUTPUT") { + let setting = OutputSetting::parse(&output).ok_or_else(|| { + IdxError::ConfigError(format!( + "invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'" + )) + })?; + cfg.output = setting.resolve(stdout_is_terminal()); + } + if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { + cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true"); + } + if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? { + cfg.quote_ttl = parsed; + } + if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? { + cfg.fundamental_ttl = parsed; + } + + if let Some(output) = cli.output { + cfg.output = output.resolve(stdout_is_terminal()); + } + cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal(); + + Ok(cfg) + } + + pub fn load() -> Result { + let mut cfg = Self::default(); + let path = config_path()?; + if path.exists() { + let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; + let parsed: FileConfig = + toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; + if let Some(general) = parsed.general { + if let Some(provider) = general.provider { + cfg.provider = provider; + } + if let Some(history_provider) = general.history_provider { + cfg.history_provider = history_provider; + } + if let Some(exchange) = general.exchange { + cfg.exchange = exchange; + } + if let Some(output) = general.output { + cfg.output = output.resolve(stdout_is_terminal()); + } + if let Some(color) = general.color { + cfg.no_color = !color; + } + } + if let Some(cache) = parsed.cache { + if let Some(v) = cache.quote_ttl { + cfg.quote_ttl = v; + } + if let Some(v) = cache.fundamental_ttl { + cfg.fundamental_ttl = v; + } + } + } + Ok(cfg) + } +} + +pub fn stdout_is_terminal() -> bool { + std::io::stdout().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::IdxConfig; + + #[test] + fn default_values_are_sane() { + let cfg = IdxConfig::default(); + assert_eq!(cfg.provider, super::ProviderKind::Msn); + assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto); + assert_eq!(cfg.exchange, "JK"); + assert_eq!(cfg.quote_ttl, 300); + } +} + +#[cfg(test)] +mod output_values_tests { + use clap::ValueEnum; + + use crate::output::OutputSetting; + + #[test] + fn core_output_values_match_cli_output_setting() { + let cli: Vec = 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); + } +} diff --git a/src/main.rs b/crates/idx-cli/src/main.rs similarity index 80% rename from src/main.rs rename to crates/idx-cli/src/main.rs index 0d38ea5..4c75dd0 100644 --- a/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -1,50 +1,18 @@ -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, OutputSetting, emit_error}; +use idx_core::api::default_provider; +use idx_core::error::IdxError; fn main() { if let Err(err) = run() { diff --git a/src/output/json.rs b/crates/idx-cli/src/output/json.rs similarity index 97% rename from src/output/json.rs rename to crates/idx-cli/src/output/json.rs index 1b8d223..9dd7115 100644 --- a/src/output/json.rs +++ b/crates/idx-cli/src/output/json.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use serde::Serialize; -use crate::error::IdxError; +use idx_core::error::IdxError; const PRETTY: u8 = 0; const COMPACT: u8 = 1; diff --git a/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs similarity index 97% rename from src/output/mod.rs rename to crates/idx-cli/src/output/mod.rs index 4559a2a..5c43bda 100644 --- a/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -8,13 +8,15 @@ use chrono::NaiveDate; use clap::ValueEnum; use serde::{Deserialize, Serialize}; -use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; -use crate::analysis::signals::TechnicalSignal; -use crate::api::types::{ +use idx_core::analysis::fundamental::{ + FundamentalReport, GrowthReport, RiskReport, ValuationReport, +}; +use idx_core::analysis::signals::TechnicalSignal; +use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; -use 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)] diff --git a/src/output/style.rs b/crates/idx-cli/src/output/style.rs similarity index 100% rename from src/output/style.rs rename to crates/idx-cli/src/output/style.rs diff --git a/src/output/table.rs b/crates/idx-cli/src/output/table.rs similarity index 98% rename from src/output/table.rs rename to crates/idx-cli/src/output/table.rs index 2f26f94..f17f8d5 100644 --- a/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1,14 +1,16 @@ use crate::output::style::Paint; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; -use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; -use crate::analysis::signals::Signal; -use crate::api::types::{ +use crate::output::TechnicalReport; +use idx_core::analysis::fundamental::{ + FundamentalReport, GrowthReport, RiskReport, ValuationReport, +}; +use idx_core::analysis::signals::Signal; +use idx_core::api::types::{ CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; -use 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 { diff --git a/tests/cli.rs b/crates/idx-cli/tests/cli.rs similarity index 97% rename from tests/cli.rs rename to crates/idx-cli/tests/cli.rs index 5e3f34d..9baa2f0 100644 --- a/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -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) { @@ -113,7 +113,8 @@ 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. @@ -128,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 { @@ -375,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"), ), ], ); @@ -1064,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); @@ -1098,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); @@ -1332,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"); @@ -1369,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"); @@ -1408,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()); @@ -1446,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"); @@ -1495,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"); @@ -1523,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"); @@ -1571,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"); @@ -1593,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"); @@ -1616,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"); @@ -1654,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) diff --git a/tests/golden.rs b/crates/idx-cli/tests/golden.rs similarity index 91% rename from tests/golden.rs rename to crates/idx-cli/tests/golden.rs index d6c5aba..e670b9e 100644 --- a/tests/golden.rs +++ b/crates/idx-cli/tests/golden.rs @@ -18,7 +18,7 @@ use std::sync::OnceLock; use serde::Deserialize; const IDX: &str = env!("CARGO_BIN_EXE_idx"); -const REPO: &str = env!("CARGO_MANIFEST_DIR"); +const CRATE: &str = env!("CARGO_MANIFEST_DIR"); #[derive(Deserialize)] struct CaseFile { @@ -50,8 +50,17 @@ fn default_formats() -> Vec { 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(REPO).join("tests").join("golden") + Path::new(CRATE).join("tests").join("golden") } fn work_dir() -> PathBuf { @@ -70,7 +79,7 @@ fn idx_with_stdin( stdin: Option<&str>, ) -> std::process::Output { let mut cmd = Command::new(IDX); - cmd.current_dir(REPO).args(args); + 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); @@ -105,14 +114,14 @@ fn seeded_ownership_db() -> &'static Path { // The importer derives the release from the IDX file name. let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx"); fs::copy( - Path::new(REPO).join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"), + core_dir().join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"), &xlsx, ) .expect("copy xlsx fixture"); let inputs = [ xlsx, - Path::new(REPO).join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"), - Path::new(REPO).join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"), + 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()] @@ -137,7 +146,7 @@ fn render(root: &Path, out: &std::process::Output) -> String { let normalize = |bytes: &[u8]| { String::from_utf8_lossy(bytes) .replace(root.to_str().unwrap(), "") - .replace(REPO, "") + .replace(core_dir().to_str().unwrap(), "") }; format!( "exit: {}\n--- stdout\n{}--- stderr\n{}", diff --git a/tests/golden/auto-format-env-table.golden b/crates/idx-cli/tests/golden/auto-format-env-table.golden similarity index 100% rename from tests/golden/auto-format-env-table.golden rename to crates/idx-cli/tests/golden/auto-format-env-table.golden diff --git a/tests/golden/auto-format-piped.golden b/crates/idx-cli/tests/golden/auto-format-piped.golden similarity index 100% rename from tests/golden/auto-format-piped.golden rename to crates/idx-cli/tests/golden/auto-format-piped.golden diff --git a/tests/golden/cache-clear.json.golden b/crates/idx-cli/tests/golden/cache-clear.json.golden similarity index 100% rename from tests/golden/cache-clear.json.golden rename to crates/idx-cli/tests/golden/cache-clear.json.golden diff --git a/tests/golden/cache-clear.table.golden b/crates/idx-cli/tests/golden/cache-clear.table.golden similarity index 100% rename from tests/golden/cache-clear.table.golden rename to crates/idx-cli/tests/golden/cache-clear.table.golden diff --git a/tests/golden/cache-info-empty.json.golden b/crates/idx-cli/tests/golden/cache-info-empty.json.golden similarity index 100% rename from tests/golden/cache-info-empty.json.golden rename to crates/idx-cli/tests/golden/cache-info-empty.json.golden diff --git a/tests/golden/cache-info-empty.table.golden b/crates/idx-cli/tests/golden/cache-info-empty.table.golden similarity index 100% rename from tests/golden/cache-info-empty.table.golden rename to crates/idx-cli/tests/golden/cache-info-empty.table.golden diff --git a/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml similarity index 100% rename from tests/golden/cases.toml rename to crates/idx-cli/tests/golden/cases.toml diff --git a/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden similarity index 100% rename from tests/golden/completions-bash.golden rename to crates/idx-cli/tests/golden/completions-bash.golden diff --git a/tests/golden/config-get.golden b/crates/idx-cli/tests/golden/config-get.golden similarity index 100% rename from tests/golden/config-get.golden rename to crates/idx-cli/tests/golden/config-get.golden diff --git a/tests/golden/config-init.golden b/crates/idx-cli/tests/golden/config-init.golden similarity index 100% rename from tests/golden/config-init.golden rename to crates/idx-cli/tests/golden/config-init.golden diff --git a/tests/golden/config-output-table-pins-format.golden b/crates/idx-cli/tests/golden/config-output-table-pins-format.golden similarity index 100% rename from tests/golden/config-output-table-pins-format.golden rename to crates/idx-cli/tests/golden/config-output-table-pins-format.golden diff --git a/tests/golden/config-path.golden b/crates/idx-cli/tests/golden/config-path.golden similarity index 100% rename from tests/golden/config-path.golden rename to crates/idx-cli/tests/golden/config-path.golden diff --git a/tests/golden/config-set-output-invalid.golden b/crates/idx-cli/tests/golden/config-set-output-invalid.golden similarity index 100% rename from tests/golden/config-set-output-invalid.golden rename to crates/idx-cli/tests/golden/config-set-output-invalid.golden diff --git a/tests/golden/config-set-output-jsonl.golden b/crates/idx-cli/tests/golden/config-set-output-jsonl.golden similarity index 100% rename from tests/golden/config-set-output-jsonl.golden rename to crates/idx-cli/tests/golden/config-set-output-jsonl.golden diff --git a/tests/golden/config-set.golden b/crates/idx-cli/tests/golden/config-set.golden similarity index 100% rename from tests/golden/config-set.golden rename to crates/idx-cli/tests/golden/config-set.golden diff --git a/tests/golden/error-compare-total-failure.json.golden b/crates/idx-cli/tests/golden/error-compare-total-failure.json.golden similarity index 100% rename from tests/golden/error-compare-total-failure.json.golden rename to crates/idx-cli/tests/golden/error-compare-total-failure.json.golden diff --git a/tests/golden/error-compare-total-failure.table.golden b/crates/idx-cli/tests/golden/error-compare-total-failure.table.golden similarity index 100% rename from tests/golden/error-compare-total-failure.table.golden rename to crates/idx-cli/tests/golden/error-compare-total-failure.table.golden diff --git a/tests/golden/error-fetch-bing-unsupported.json.golden b/crates/idx-cli/tests/golden/error-fetch-bing-unsupported.json.golden similarity index 100% rename from tests/golden/error-fetch-bing-unsupported.json.golden rename to crates/idx-cli/tests/golden/error-fetch-bing-unsupported.json.golden diff --git a/tests/golden/error-fetch-bing-unsupported.table.golden b/crates/idx-cli/tests/golden/error-fetch-bing-unsupported.table.golden similarity index 100% rename from tests/golden/error-fetch-bing-unsupported.table.golden rename to crates/idx-cli/tests/golden/error-fetch-bing-unsupported.table.golden diff --git a/tests/golden/error-industry-only-fundamentals.json.golden b/crates/idx-cli/tests/golden/error-industry-only-fundamentals.json.golden similarity index 100% rename from tests/golden/error-industry-only-fundamentals.json.golden rename to crates/idx-cli/tests/golden/error-industry-only-fundamentals.json.golden diff --git a/tests/golden/error-industry-only-fundamentals.table.golden b/crates/idx-cli/tests/golden/error-industry-only-fundamentals.table.golden similarity index 100% rename from tests/golden/error-industry-only-fundamentals.table.golden rename to crates/idx-cli/tests/golden/error-industry-only-fundamentals.table.golden diff --git a/tests/golden/error-invalid-output-env.golden b/crates/idx-cli/tests/golden/error-invalid-output-env.golden similarity index 100% rename from tests/golden/error-invalid-output-env.golden rename to crates/idx-cli/tests/golden/error-invalid-output-env.golden diff --git a/tests/golden/error-invalid-provider.json.golden b/crates/idx-cli/tests/golden/error-invalid-provider.json.golden similarity index 100% rename from tests/golden/error-invalid-provider.json.golden rename to crates/idx-cli/tests/golden/error-invalid-provider.json.golden diff --git a/tests/golden/error-invalid-provider.table.golden b/crates/idx-cli/tests/golden/error-invalid-provider.table.golden similarity index 100% rename from tests/golden/error-invalid-provider.table.golden rename to crates/idx-cli/tests/golden/error-invalid-provider.table.golden diff --git a/tests/golden/error-offline-cache-miss.json.golden b/crates/idx-cli/tests/golden/error-offline-cache-miss.json.golden similarity index 100% rename from tests/golden/error-offline-cache-miss.json.golden rename to crates/idx-cli/tests/golden/error-offline-cache-miss.json.golden diff --git a/tests/golden/error-offline-cache-miss.table.golden b/crates/idx-cli/tests/golden/error-offline-cache-miss.table.golden similarity index 100% rename from tests/golden/error-offline-cache-miss.table.golden rename to crates/idx-cli/tests/golden/error-offline-cache-miss.table.golden diff --git a/tests/golden/error-offline-no-cache-flags.json.golden b/crates/idx-cli/tests/golden/error-offline-no-cache-flags.json.golden similarity index 100% rename from tests/golden/error-offline-no-cache-flags.json.golden rename to crates/idx-cli/tests/golden/error-offline-no-cache-flags.json.golden diff --git a/tests/golden/error-offline-no-cache-flags.table.golden b/crates/idx-cli/tests/golden/error-offline-no-cache-flags.table.golden similarity index 100% rename from tests/golden/error-offline-no-cache-flags.table.golden rename to crates/idx-cli/tests/golden/error-offline-no-cache-flags.table.golden diff --git a/tests/golden/error-provider-failure.json.golden b/crates/idx-cli/tests/golden/error-provider-failure.json.golden similarity index 100% rename from tests/golden/error-provider-failure.json.golden rename to crates/idx-cli/tests/golden/error-provider-failure.json.golden diff --git a/tests/golden/error-provider-failure.table.golden b/crates/idx-cli/tests/golden/error-provider-failure.table.golden similarity index 100% rename from tests/golden/error-provider-failure.table.golden rename to crates/idx-cli/tests/golden/error-provider-failure.table.golden diff --git a/tests/golden/error-screen-region.json.golden b/crates/idx-cli/tests/golden/error-screen-region.json.golden similarity index 100% rename from tests/golden/error-screen-region.json.golden rename to crates/idx-cli/tests/golden/error-screen-region.json.golden diff --git a/tests/golden/error-screen-region.jsonl.golden b/crates/idx-cli/tests/golden/error-screen-region.jsonl.golden similarity index 100% rename from tests/golden/error-screen-region.jsonl.golden rename to crates/idx-cli/tests/golden/error-screen-region.jsonl.golden diff --git a/tests/golden/error-screen-region.table.golden b/crates/idx-cli/tests/golden/error-screen-region.table.golden similarity index 100% rename from tests/golden/error-screen-region.table.golden rename to crates/idx-cli/tests/golden/error-screen-region.table.golden diff --git a/tests/golden/error-stdin-empty.json.golden b/crates/idx-cli/tests/golden/error-stdin-empty.json.golden similarity index 100% rename from tests/golden/error-stdin-empty.json.golden rename to crates/idx-cli/tests/golden/error-stdin-empty.json.golden diff --git a/tests/golden/error-stdin-empty.table.golden b/crates/idx-cli/tests/golden/error-stdin-empty.table.golden similarity index 100% rename from tests/golden/error-stdin-empty.table.golden rename to crates/idx-cli/tests/golden/error-stdin-empty.table.golden diff --git a/tests/golden/error-usage-missing-symbol.json.golden b/crates/idx-cli/tests/golden/error-usage-missing-symbol.json.golden similarity index 100% rename from tests/golden/error-usage-missing-symbol.json.golden rename to crates/idx-cli/tests/golden/error-usage-missing-symbol.json.golden diff --git a/tests/golden/error-usage-missing-symbol.table.golden b/crates/idx-cli/tests/golden/error-usage-missing-symbol.table.golden similarity index 100% rename from tests/golden/error-usage-missing-symbol.table.golden rename to crates/idx-cli/tests/golden/error-usage-missing-symbol.table.golden diff --git a/tests/golden/error-yahoo-profile-unsupported.json.golden b/crates/idx-cli/tests/golden/error-yahoo-profile-unsupported.json.golden similarity index 100% rename from tests/golden/error-yahoo-profile-unsupported.json.golden rename to crates/idx-cli/tests/golden/error-yahoo-profile-unsupported.json.golden diff --git a/tests/golden/error-yahoo-profile-unsupported.table.golden b/crates/idx-cli/tests/golden/error-yahoo-profile-unsupported.table.golden similarity index 100% rename from tests/golden/error-yahoo-profile-unsupported.table.golden rename to crates/idx-cli/tests/golden/error-yahoo-profile-unsupported.table.golden diff --git a/tests/golden/help-cache.golden b/crates/idx-cli/tests/golden/help-cache.golden similarity index 100% rename from tests/golden/help-cache.golden rename to crates/idx-cli/tests/golden/help-cache.golden diff --git a/tests/golden/help-config.golden b/crates/idx-cli/tests/golden/help-config.golden similarity index 100% rename from tests/golden/help-config.golden rename to crates/idx-cli/tests/golden/help-config.golden diff --git a/tests/golden/help-ownership.golden b/crates/idx-cli/tests/golden/help-ownership.golden similarity index 100% rename from tests/golden/help-ownership.golden rename to crates/idx-cli/tests/golden/help-ownership.golden diff --git a/tests/golden/help-root.golden b/crates/idx-cli/tests/golden/help-root.golden similarity index 100% rename from tests/golden/help-root.golden rename to crates/idx-cli/tests/golden/help-root.golden diff --git a/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden similarity index 100% rename from tests/golden/help-stocks.golden rename to crates/idx-cli/tests/golden/help-stocks.golden diff --git a/tests/golden/ownership-changes.json.golden b/crates/idx-cli/tests/golden/ownership-changes.json.golden similarity index 100% rename from tests/golden/ownership-changes.json.golden rename to crates/idx-cli/tests/golden/ownership-changes.json.golden diff --git a/tests/golden/ownership-changes.jsonl.golden b/crates/idx-cli/tests/golden/ownership-changes.jsonl.golden similarity index 100% rename from tests/golden/ownership-changes.jsonl.golden rename to crates/idx-cli/tests/golden/ownership-changes.jsonl.golden diff --git a/tests/golden/ownership-changes.table.golden b/crates/idx-cli/tests/golden/ownership-changes.table.golden similarity index 100% rename from tests/golden/ownership-changes.table.golden rename to crates/idx-cli/tests/golden/ownership-changes.table.golden diff --git a/tests/golden/ownership-concentration.json.golden b/crates/idx-cli/tests/golden/ownership-concentration.json.golden similarity index 100% rename from tests/golden/ownership-concentration.json.golden rename to crates/idx-cli/tests/golden/ownership-concentration.json.golden diff --git a/tests/golden/ownership-concentration.table.golden b/crates/idx-cli/tests/golden/ownership-concentration.table.golden similarity index 100% rename from tests/golden/ownership-concentration.table.golden rename to crates/idx-cli/tests/golden/ownership-concentration.table.golden diff --git a/tests/golden/ownership-cross-holders.json.golden b/crates/idx-cli/tests/golden/ownership-cross-holders.json.golden similarity index 100% rename from tests/golden/ownership-cross-holders.json.golden rename to crates/idx-cli/tests/golden/ownership-cross-holders.json.golden diff --git a/tests/golden/ownership-cross-holders.table.golden b/crates/idx-cli/tests/golden/ownership-cross-holders.table.golden similarity index 100% rename from tests/golden/ownership-cross-holders.table.golden rename to crates/idx-cli/tests/golden/ownership-cross-holders.table.golden diff --git a/tests/golden/ownership-entity.json.golden b/crates/idx-cli/tests/golden/ownership-entity.json.golden similarity index 100% rename from tests/golden/ownership-entity.json.golden rename to crates/idx-cli/tests/golden/ownership-entity.json.golden diff --git a/tests/golden/ownership-entity.table.golden b/crates/idx-cli/tests/golden/ownership-entity.table.golden similarity index 100% rename from tests/golden/ownership-entity.table.golden rename to crates/idx-cli/tests/golden/ownership-entity.table.golden diff --git a/tests/golden/ownership-flow.json.golden b/crates/idx-cli/tests/golden/ownership-flow.json.golden similarity index 100% rename from tests/golden/ownership-flow.json.golden rename to crates/idx-cli/tests/golden/ownership-flow.json.golden diff --git a/tests/golden/ownership-flow.table.golden b/crates/idx-cli/tests/golden/ownership-flow.table.golden similarity index 100% rename from tests/golden/ownership-flow.table.golden rename to crates/idx-cli/tests/golden/ownership-flow.table.golden diff --git a/tests/golden/ownership-graph-dot.json.golden b/crates/idx-cli/tests/golden/ownership-graph-dot.json.golden similarity index 100% rename from tests/golden/ownership-graph-dot.json.golden rename to crates/idx-cli/tests/golden/ownership-graph-dot.json.golden diff --git a/tests/golden/ownership-graph-dot.table.golden b/crates/idx-cli/tests/golden/ownership-graph-dot.table.golden similarity index 100% rename from tests/golden/ownership-graph-dot.table.golden rename to crates/idx-cli/tests/golden/ownership-graph-dot.table.golden diff --git a/tests/golden/ownership-graph.json.golden b/crates/idx-cli/tests/golden/ownership-graph.json.golden similarity index 100% rename from tests/golden/ownership-graph.json.golden rename to crates/idx-cli/tests/golden/ownership-graph.json.golden diff --git a/tests/golden/ownership-graph.table.golden b/crates/idx-cli/tests/golden/ownership-graph.table.golden similarity index 100% rename from tests/golden/ownership-graph.table.golden rename to crates/idx-cli/tests/golden/ownership-graph.table.golden diff --git a/tests/golden/ownership-import-archive-txt.json.golden b/crates/idx-cli/tests/golden/ownership-import-archive-txt.json.golden similarity index 100% rename from tests/golden/ownership-import-archive-txt.json.golden rename to crates/idx-cli/tests/golden/ownership-import-archive-txt.json.golden diff --git a/tests/golden/ownership-import-archive-txt.table.golden b/crates/idx-cli/tests/golden/ownership-import-archive-txt.table.golden similarity index 100% rename from tests/golden/ownership-import-archive-txt.table.golden rename to crates/idx-cli/tests/golden/ownership-import-archive-txt.table.golden diff --git a/tests/golden/ownership-import-xlsx.json.golden b/crates/idx-cli/tests/golden/ownership-import-xlsx.json.golden similarity index 100% rename from tests/golden/ownership-import-xlsx.json.golden rename to crates/idx-cli/tests/golden/ownership-import-xlsx.json.golden diff --git a/tests/golden/ownership-import-xlsx.table.golden b/crates/idx-cli/tests/golden/ownership-import-xlsx.table.golden similarity index 100% rename from tests/golden/ownership-import-xlsx.table.golden rename to crates/idx-cli/tests/golden/ownership-import-xlsx.table.golden diff --git a/tests/golden/ownership-releases-empty.json.golden b/crates/idx-cli/tests/golden/ownership-releases-empty.json.golden similarity index 100% rename from tests/golden/ownership-releases-empty.json.golden rename to crates/idx-cli/tests/golden/ownership-releases-empty.json.golden diff --git a/tests/golden/ownership-releases-empty.table.golden b/crates/idx-cli/tests/golden/ownership-releases-empty.table.golden similarity index 100% rename from tests/golden/ownership-releases-empty.table.golden rename to crates/idx-cli/tests/golden/ownership-releases-empty.table.golden diff --git a/tests/golden/ownership-releases.json.golden b/crates/idx-cli/tests/golden/ownership-releases.json.golden similarity index 100% rename from tests/golden/ownership-releases.json.golden rename to crates/idx-cli/tests/golden/ownership-releases.json.golden diff --git a/tests/golden/ownership-releases.table.golden b/crates/idx-cli/tests/golden/ownership-releases.table.golden similarity index 100% rename from tests/golden/ownership-releases.table.golden rename to crates/idx-cli/tests/golden/ownership-releases.table.golden diff --git a/tests/golden/ownership-resolve-map.json.golden b/crates/idx-cli/tests/golden/ownership-resolve-map.json.golden similarity index 100% rename from tests/golden/ownership-resolve-map.json.golden rename to crates/idx-cli/tests/golden/ownership-resolve-map.json.golden diff --git a/tests/golden/ownership-resolve-map.table.golden b/crates/idx-cli/tests/golden/ownership-resolve-map.table.golden similarity index 100% rename from tests/golden/ownership-resolve-map.table.golden rename to crates/idx-cli/tests/golden/ownership-resolve-map.table.golden diff --git a/tests/golden/ownership-resolve-merge.json.golden b/crates/idx-cli/tests/golden/ownership-resolve-merge.json.golden similarity index 100% rename from tests/golden/ownership-resolve-merge.json.golden rename to crates/idx-cli/tests/golden/ownership-resolve-merge.json.golden diff --git a/tests/golden/ownership-resolve-merge.table.golden b/crates/idx-cli/tests/golden/ownership-resolve-merge.table.golden similarity index 100% rename from tests/golden/ownership-resolve-merge.table.golden rename to crates/idx-cli/tests/golden/ownership-resolve-merge.table.golden diff --git a/tests/golden/ownership-resolve-unresolved.json.golden b/crates/idx-cli/tests/golden/ownership-resolve-unresolved.json.golden similarity index 100% rename from tests/golden/ownership-resolve-unresolved.json.golden rename to crates/idx-cli/tests/golden/ownership-resolve-unresolved.json.golden diff --git a/tests/golden/ownership-resolve-unresolved.table.golden b/crates/idx-cli/tests/golden/ownership-resolve-unresolved.table.golden similarity index 100% rename from tests/golden/ownership-resolve-unresolved.table.golden rename to crates/idx-cli/tests/golden/ownership-resolve-unresolved.table.golden diff --git a/tests/golden/ownership-search.json.golden b/crates/idx-cli/tests/golden/ownership-search.json.golden similarity index 100% rename from tests/golden/ownership-search.json.golden rename to crates/idx-cli/tests/golden/ownership-search.json.golden diff --git a/tests/golden/ownership-search.table.golden b/crates/idx-cli/tests/golden/ownership-search.table.golden similarity index 100% rename from tests/golden/ownership-search.table.golden rename to crates/idx-cli/tests/golden/ownership-search.table.golden diff --git a/tests/golden/ownership-ticker-empty.json.golden b/crates/idx-cli/tests/golden/ownership-ticker-empty.json.golden similarity index 100% rename from tests/golden/ownership-ticker-empty.json.golden rename to crates/idx-cli/tests/golden/ownership-ticker-empty.json.golden diff --git a/tests/golden/ownership-ticker-empty.table.golden b/crates/idx-cli/tests/golden/ownership-ticker-empty.table.golden similarity index 100% rename from tests/golden/ownership-ticker-empty.table.golden rename to crates/idx-cli/tests/golden/ownership-ticker-empty.table.golden diff --git a/tests/golden/ownership-ticker-ksei.json.golden b/crates/idx-cli/tests/golden/ownership-ticker-ksei.json.golden similarity index 100% rename from tests/golden/ownership-ticker-ksei.json.golden rename to crates/idx-cli/tests/golden/ownership-ticker-ksei.json.golden diff --git a/tests/golden/ownership-ticker-ksei.table.golden b/crates/idx-cli/tests/golden/ownership-ticker-ksei.table.golden similarity index 100% rename from tests/golden/ownership-ticker-ksei.table.golden rename to crates/idx-cli/tests/golden/ownership-ticker-ksei.table.golden diff --git a/tests/golden/ownership-ticker.json.golden b/crates/idx-cli/tests/golden/ownership-ticker.json.golden similarity index 100% rename from tests/golden/ownership-ticker.json.golden rename to crates/idx-cli/tests/golden/ownership-ticker.json.golden diff --git a/tests/golden/ownership-ticker.table.golden b/crates/idx-cli/tests/golden/ownership-ticker.table.golden similarity index 100% rename from tests/golden/ownership-ticker.table.golden rename to crates/idx-cli/tests/golden/ownership-ticker.table.golden diff --git a/tests/golden/stocks-compare-stdin.json.golden b/crates/idx-cli/tests/golden/stocks-compare-stdin.json.golden similarity index 100% rename from tests/golden/stocks-compare-stdin.json.golden rename to crates/idx-cli/tests/golden/stocks-compare-stdin.json.golden diff --git a/tests/golden/stocks-compare-stdin.table.golden b/crates/idx-cli/tests/golden/stocks-compare-stdin.table.golden similarity index 100% rename from tests/golden/stocks-compare-stdin.table.golden rename to crates/idx-cli/tests/golden/stocks-compare-stdin.table.golden diff --git a/tests/golden/stocks-compare.json.golden b/crates/idx-cli/tests/golden/stocks-compare.json.golden similarity index 100% rename from tests/golden/stocks-compare.json.golden rename to crates/idx-cli/tests/golden/stocks-compare.json.golden diff --git a/tests/golden/stocks-compare.table.golden b/crates/idx-cli/tests/golden/stocks-compare.table.golden similarity index 100% rename from tests/golden/stocks-compare.table.golden rename to crates/idx-cli/tests/golden/stocks-compare.table.golden diff --git a/tests/golden/stocks-earnings-forecast-annual.json.golden b/crates/idx-cli/tests/golden/stocks-earnings-forecast-annual.json.golden similarity index 100% rename from tests/golden/stocks-earnings-forecast-annual.json.golden rename to crates/idx-cli/tests/golden/stocks-earnings-forecast-annual.json.golden diff --git a/tests/golden/stocks-earnings-forecast-annual.table.golden b/crates/idx-cli/tests/golden/stocks-earnings-forecast-annual.table.golden similarity index 100% rename from tests/golden/stocks-earnings-forecast-annual.table.golden rename to crates/idx-cli/tests/golden/stocks-earnings-forecast-annual.table.golden diff --git a/tests/golden/stocks-earnings-history-quarterly.json.golden b/crates/idx-cli/tests/golden/stocks-earnings-history-quarterly.json.golden similarity index 100% rename from tests/golden/stocks-earnings-history-quarterly.json.golden rename to crates/idx-cli/tests/golden/stocks-earnings-history-quarterly.json.golden diff --git a/tests/golden/stocks-earnings-history-quarterly.table.golden b/crates/idx-cli/tests/golden/stocks-earnings-history-quarterly.table.golden similarity index 100% rename from tests/golden/stocks-earnings-history-quarterly.table.golden rename to crates/idx-cli/tests/golden/stocks-earnings-history-quarterly.table.golden diff --git a/tests/golden/stocks-earnings.json.golden b/crates/idx-cli/tests/golden/stocks-earnings.json.golden similarity index 100% rename from tests/golden/stocks-earnings.json.golden rename to crates/idx-cli/tests/golden/stocks-earnings.json.golden diff --git a/tests/golden/stocks-earnings.table.golden b/crates/idx-cli/tests/golden/stocks-earnings.table.golden similarity index 100% rename from tests/golden/stocks-earnings.table.golden rename to crates/idx-cli/tests/golden/stocks-earnings.table.golden diff --git a/tests/golden/stocks-financials-income.json.golden b/crates/idx-cli/tests/golden/stocks-financials-income.json.golden similarity index 100% rename from tests/golden/stocks-financials-income.json.golden rename to crates/idx-cli/tests/golden/stocks-financials-income.json.golden diff --git a/tests/golden/stocks-financials-income.table.golden b/crates/idx-cli/tests/golden/stocks-financials-income.table.golden similarity index 100% rename from tests/golden/stocks-financials-income.table.golden rename to crates/idx-cli/tests/golden/stocks-financials-income.table.golden diff --git a/tests/golden/stocks-financials.json.golden b/crates/idx-cli/tests/golden/stocks-financials.json.golden similarity index 100% rename from tests/golden/stocks-financials.json.golden rename to crates/idx-cli/tests/golden/stocks-financials.json.golden diff --git a/tests/golden/stocks-financials.table.golden b/crates/idx-cli/tests/golden/stocks-financials.table.golden similarity index 100% rename from tests/golden/stocks-financials.table.golden rename to crates/idx-cli/tests/golden/stocks-financials.table.golden diff --git a/tests/golden/stocks-fundamental.json.golden b/crates/idx-cli/tests/golden/stocks-fundamental.json.golden similarity index 100% rename from tests/golden/stocks-fundamental.json.golden rename to crates/idx-cli/tests/golden/stocks-fundamental.json.golden diff --git a/tests/golden/stocks-fundamental.jsonl.golden b/crates/idx-cli/tests/golden/stocks-fundamental.jsonl.golden similarity index 100% rename from tests/golden/stocks-fundamental.jsonl.golden rename to crates/idx-cli/tests/golden/stocks-fundamental.jsonl.golden diff --git a/tests/golden/stocks-fundamental.table.golden b/crates/idx-cli/tests/golden/stocks-fundamental.table.golden similarity index 100% rename from tests/golden/stocks-fundamental.table.golden rename to crates/idx-cli/tests/golden/stocks-fundamental.table.golden diff --git a/tests/golden/stocks-growth.json.golden b/crates/idx-cli/tests/golden/stocks-growth.json.golden similarity index 100% rename from tests/golden/stocks-growth.json.golden rename to crates/idx-cli/tests/golden/stocks-growth.json.golden diff --git a/tests/golden/stocks-growth.table.golden b/crates/idx-cli/tests/golden/stocks-growth.table.golden similarity index 100% rename from tests/golden/stocks-growth.table.golden rename to crates/idx-cli/tests/golden/stocks-growth.table.golden diff --git a/tests/golden/stocks-history-msn.json.golden b/crates/idx-cli/tests/golden/stocks-history-msn.json.golden similarity index 100% rename from tests/golden/stocks-history-msn.json.golden rename to crates/idx-cli/tests/golden/stocks-history-msn.json.golden diff --git a/tests/golden/stocks-history-msn.table.golden b/crates/idx-cli/tests/golden/stocks-history-msn.table.golden similarity index 100% rename from tests/golden/stocks-history-msn.table.golden rename to crates/idx-cli/tests/golden/stocks-history-msn.table.golden diff --git a/tests/golden/stocks-history.json.golden b/crates/idx-cli/tests/golden/stocks-history.json.golden similarity index 100% rename from tests/golden/stocks-history.json.golden rename to crates/idx-cli/tests/golden/stocks-history.json.golden diff --git a/tests/golden/stocks-history.jsonl.golden b/crates/idx-cli/tests/golden/stocks-history.jsonl.golden similarity index 100% rename from tests/golden/stocks-history.jsonl.golden rename to crates/idx-cli/tests/golden/stocks-history.jsonl.golden diff --git a/tests/golden/stocks-history.table.golden b/crates/idx-cli/tests/golden/stocks-history.table.golden similarity index 100% rename from tests/golden/stocks-history.table.golden rename to crates/idx-cli/tests/golden/stocks-history.table.golden diff --git a/tests/golden/stocks-insights.json.golden b/crates/idx-cli/tests/golden/stocks-insights.json.golden similarity index 100% rename from tests/golden/stocks-insights.json.golden rename to crates/idx-cli/tests/golden/stocks-insights.json.golden diff --git a/tests/golden/stocks-insights.table.golden b/crates/idx-cli/tests/golden/stocks-insights.table.golden similarity index 100% rename from tests/golden/stocks-insights.table.golden rename to crates/idx-cli/tests/golden/stocks-insights.table.golden diff --git a/tests/golden/stocks-news.json.golden b/crates/idx-cli/tests/golden/stocks-news.json.golden similarity index 100% rename from tests/golden/stocks-news.json.golden rename to crates/idx-cli/tests/golden/stocks-news.json.golden diff --git a/tests/golden/stocks-news.table.golden b/crates/idx-cli/tests/golden/stocks-news.table.golden similarity index 100% rename from tests/golden/stocks-news.table.golden rename to crates/idx-cli/tests/golden/stocks-news.table.golden diff --git a/tests/golden/stocks-profile.json.golden b/crates/idx-cli/tests/golden/stocks-profile.json.golden similarity index 100% rename from tests/golden/stocks-profile.json.golden rename to crates/idx-cli/tests/golden/stocks-profile.json.golden diff --git a/tests/golden/stocks-profile.table.golden b/crates/idx-cli/tests/golden/stocks-profile.table.golden similarity index 100% rename from tests/golden/stocks-profile.table.golden rename to crates/idx-cli/tests/golden/stocks-profile.table.golden diff --git a/tests/golden/stocks-quote-multi.json.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden similarity index 100% rename from tests/golden/stocks-quote-multi.json.golden rename to crates/idx-cli/tests/golden/stocks-quote-multi.json.golden diff --git a/tests/golden/stocks-quote-multi.jsonl.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden similarity index 100% rename from tests/golden/stocks-quote-multi.jsonl.golden rename to crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden diff --git a/tests/golden/stocks-quote-multi.table.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.table.golden similarity index 100% rename from tests/golden/stocks-quote-multi.table.golden rename to crates/idx-cli/tests/golden/stocks-quote-multi.table.golden diff --git a/tests/golden/stocks-quote-offline-warm.json.golden b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden similarity index 100% rename from tests/golden/stocks-quote-offline-warm.json.golden rename to crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden diff --git a/tests/golden/stocks-quote-offline-warm.table.golden b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.table.golden similarity index 100% rename from tests/golden/stocks-quote-offline-warm.table.golden rename to crates/idx-cli/tests/golden/stocks-quote-offline-warm.table.golden diff --git a/tests/golden/stocks-quote-stdin.json.golden b/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden similarity index 100% rename from tests/golden/stocks-quote-stdin.json.golden rename to crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden diff --git a/tests/golden/stocks-quote-stdin.table.golden b/crates/idx-cli/tests/golden/stocks-quote-stdin.table.golden similarity index 100% rename from tests/golden/stocks-quote-stdin.table.golden rename to crates/idx-cli/tests/golden/stocks-quote-stdin.table.golden diff --git a/tests/golden/stocks-quote.json.golden b/crates/idx-cli/tests/golden/stocks-quote.json.golden similarity index 100% rename from tests/golden/stocks-quote.json.golden rename to crates/idx-cli/tests/golden/stocks-quote.json.golden diff --git a/tests/golden/stocks-quote.table.golden b/crates/idx-cli/tests/golden/stocks-quote.table.golden similarity index 100% rename from tests/golden/stocks-quote.table.golden rename to crates/idx-cli/tests/golden/stocks-quote.table.golden diff --git a/tests/golden/stocks-risk.json.golden b/crates/idx-cli/tests/golden/stocks-risk.json.golden similarity index 100% rename from tests/golden/stocks-risk.json.golden rename to crates/idx-cli/tests/golden/stocks-risk.json.golden diff --git a/tests/golden/stocks-risk.table.golden b/crates/idx-cli/tests/golden/stocks-risk.table.golden similarity index 100% rename from tests/golden/stocks-risk.table.golden rename to crates/idx-cli/tests/golden/stocks-risk.table.golden diff --git a/tests/golden/stocks-screen-high-volume.json.golden b/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden similarity index 100% rename from tests/golden/stocks-screen-high-volume.json.golden rename to crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden diff --git a/tests/golden/stocks-screen-high-volume.table.golden b/crates/idx-cli/tests/golden/stocks-screen-high-volume.table.golden similarity index 100% rename from tests/golden/stocks-screen-high-volume.table.golden rename to crates/idx-cli/tests/golden/stocks-screen-high-volume.table.golden diff --git a/tests/golden/stocks-screen-large-cap.json.golden b/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden similarity index 100% rename from tests/golden/stocks-screen-large-cap.json.golden rename to crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden diff --git a/tests/golden/stocks-screen-large-cap.table.golden b/crates/idx-cli/tests/golden/stocks-screen-large-cap.table.golden similarity index 100% rename from tests/golden/stocks-screen-large-cap.table.golden rename to crates/idx-cli/tests/golden/stocks-screen-large-cap.table.golden diff --git a/tests/golden/stocks-screen-worst.json.golden b/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden similarity index 100% rename from tests/golden/stocks-screen-worst.json.golden rename to crates/idx-cli/tests/golden/stocks-screen-worst.json.golden diff --git a/tests/golden/stocks-screen-worst.table.golden b/crates/idx-cli/tests/golden/stocks-screen-worst.table.golden similarity index 100% rename from tests/golden/stocks-screen-worst.table.golden rename to crates/idx-cli/tests/golden/stocks-screen-worst.table.golden diff --git a/tests/golden/stocks-screen.json.golden b/crates/idx-cli/tests/golden/stocks-screen.json.golden similarity index 100% rename from tests/golden/stocks-screen.json.golden rename to crates/idx-cli/tests/golden/stocks-screen.json.golden diff --git a/tests/golden/stocks-screen.table.golden b/crates/idx-cli/tests/golden/stocks-screen.table.golden similarity index 100% rename from tests/golden/stocks-screen.table.golden rename to crates/idx-cli/tests/golden/stocks-screen.table.golden diff --git a/tests/golden/stocks-sentiment.json.golden b/crates/idx-cli/tests/golden/stocks-sentiment.json.golden similarity index 100% rename from tests/golden/stocks-sentiment.json.golden rename to crates/idx-cli/tests/golden/stocks-sentiment.json.golden diff --git a/tests/golden/stocks-sentiment.table.golden b/crates/idx-cli/tests/golden/stocks-sentiment.table.golden similarity index 100% rename from tests/golden/stocks-sentiment.table.golden rename to crates/idx-cli/tests/golden/stocks-sentiment.table.golden diff --git a/tests/golden/stocks-technical.json.golden b/crates/idx-cli/tests/golden/stocks-technical.json.golden similarity index 100% rename from tests/golden/stocks-technical.json.golden rename to crates/idx-cli/tests/golden/stocks-technical.json.golden diff --git a/tests/golden/stocks-technical.table.golden b/crates/idx-cli/tests/golden/stocks-technical.table.golden similarity index 100% rename from tests/golden/stocks-technical.table.golden rename to crates/idx-cli/tests/golden/stocks-technical.table.golden diff --git a/tests/golden/stocks-valuation-nonfinite.json.golden b/crates/idx-cli/tests/golden/stocks-valuation-nonfinite.json.golden similarity index 100% rename from tests/golden/stocks-valuation-nonfinite.json.golden rename to crates/idx-cli/tests/golden/stocks-valuation-nonfinite.json.golden diff --git a/tests/golden/stocks-valuation-nonfinite.table.golden b/crates/idx-cli/tests/golden/stocks-valuation-nonfinite.table.golden similarity index 100% rename from tests/golden/stocks-valuation-nonfinite.table.golden rename to crates/idx-cli/tests/golden/stocks-valuation-nonfinite.table.golden diff --git a/tests/golden/stocks-valuation.json.golden b/crates/idx-cli/tests/golden/stocks-valuation.json.golden similarity index 100% rename from tests/golden/stocks-valuation.json.golden rename to crates/idx-cli/tests/golden/stocks-valuation.json.golden diff --git a/tests/golden/stocks-valuation.table.golden b/crates/idx-cli/tests/golden/stocks-valuation.table.golden similarity index 100% rename from tests/golden/stocks-valuation.table.golden rename to crates/idx-cli/tests/golden/stocks-valuation.table.golden diff --git a/tests/golden/yahoo-fundamental.json.golden b/crates/idx-cli/tests/golden/yahoo-fundamental.json.golden similarity index 100% rename from tests/golden/yahoo-fundamental.json.golden rename to crates/idx-cli/tests/golden/yahoo-fundamental.json.golden diff --git a/tests/golden/yahoo-fundamental.table.golden b/crates/idx-cli/tests/golden/yahoo-fundamental.table.golden similarity index 100% rename from tests/golden/yahoo-fundamental.table.golden rename to crates/idx-cli/tests/golden/yahoo-fundamental.table.golden diff --git a/tests/golden/yahoo-history.json.golden b/crates/idx-cli/tests/golden/yahoo-history.json.golden similarity index 100% rename from tests/golden/yahoo-history.json.golden rename to crates/idx-cli/tests/golden/yahoo-history.json.golden diff --git a/tests/golden/yahoo-history.table.golden b/crates/idx-cli/tests/golden/yahoo-history.table.golden similarity index 100% rename from tests/golden/yahoo-history.table.golden rename to crates/idx-cli/tests/golden/yahoo-history.table.golden diff --git a/tests/golden/yahoo-quote.json.golden b/crates/idx-cli/tests/golden/yahoo-quote.json.golden similarity index 100% rename from tests/golden/yahoo-quote.json.golden rename to crates/idx-cli/tests/golden/yahoo-quote.json.golden diff --git a/tests/golden/yahoo-quote.table.golden b/crates/idx-cli/tests/golden/yahoo-quote.table.golden similarity index 100% rename from tests/golden/yahoo-quote.table.golden rename to crates/idx-cli/tests/golden/yahoo-quote.table.golden diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml new file mode 100644 index 0000000..70b9b25 --- /dev/null +++ b/crates/idx-core/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "idx-core" +description = "Data layer for Indonesian stock market (IDX) market, financial, and ownership data" +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", "finance"] +categories = ["finance"] +include = ["/Cargo.toml", "/LICENSE", "/src/**", "/tests/fixtures/**"] + +[features] +default = ["ownership"] +ownership = ["dep:rusqlite", "dep:quick-xml", "dep:sha2", "dep:zip"] +# Derives clap::ValueEnum on enums frontends expose as CLI arguments. +clap = ["dep:clap"] + +[dependencies] +clap = { workspace = true, optional = true } +ureq.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +directories = "5" +chrono.workspace = true +thiserror = "2" +fastrand = "2" +rusqlite = { workspace = true, optional = true } +quick-xml = { version = "0.37", optional = true } +sha2 = { workspace = true, optional = true } +zip = { workspace = true, optional = true } diff --git a/crates/idx-core/LICENSE b/crates/idx-core/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/idx-core/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/src/analysis/fundamental.rs b/crates/idx-core/src/analysis/fundamental.rs similarity index 100% rename from src/analysis/fundamental.rs rename to crates/idx-core/src/analysis/fundamental.rs diff --git a/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs similarity index 100% rename from src/analysis/mod.rs rename to crates/idx-core/src/analysis/mod.rs diff --git a/src/analysis/signals.rs b/crates/idx-core/src/analysis/signals.rs similarity index 100% rename from src/analysis/signals.rs rename to crates/idx-core/src/analysis/signals.rs diff --git a/src/analysis/technical.rs b/crates/idx-core/src/analysis/technical.rs similarity index 100% rename from src/analysis/technical.rs rename to crates/idx-core/src/analysis/technical.rs diff --git a/src/api/mod.rs b/crates/idx-core/src/api/mod.rs similarity index 100% rename from src/api/mod.rs rename to crates/idx-core/src/api/mod.rs diff --git a/src/api/msn/bing.rs b/crates/idx-core/src/api/msn/bing.rs similarity index 100% rename from src/api/msn/bing.rs rename to crates/idx-core/src/api/msn/bing.rs diff --git a/src/api/msn/client.rs b/crates/idx-core/src/api/msn/client.rs similarity index 100% rename from src/api/msn/client.rs rename to crates/idx-core/src/api/msn/client.rs diff --git a/src/api/msn/map.rs b/crates/idx-core/src/api/msn/map.rs similarity index 100% rename from src/api/msn/map.rs rename to crates/idx-core/src/api/msn/map.rs diff --git a/src/api/msn/mod.rs b/crates/idx-core/src/api/msn/mod.rs similarity index 100% rename from src/api/msn/mod.rs rename to crates/idx-core/src/api/msn/mod.rs diff --git a/src/api/msn/parse.rs b/crates/idx-core/src/api/msn/parse.rs similarity index 100% rename from src/api/msn/parse.rs rename to crates/idx-core/src/api/msn/parse.rs diff --git a/src/api/msn/raw_types.rs b/crates/idx-core/src/api/msn/raw_types.rs similarity index 100% rename from src/api/msn/raw_types.rs rename to crates/idx-core/src/api/msn/raw_types.rs diff --git a/src/api/msn/symbol_ids.tsv b/crates/idx-core/src/api/msn/symbol_ids.tsv similarity index 100% rename from src/api/msn/symbol_ids.tsv rename to crates/idx-core/src/api/msn/symbol_ids.tsv diff --git a/src/api/msn/symbols.rs b/crates/idx-core/src/api/msn/symbols.rs similarity index 100% rename from src/api/msn/symbols.rs rename to crates/idx-core/src/api/msn/symbols.rs diff --git a/src/api/types.rs b/crates/idx-core/src/api/types.rs similarity index 92% rename from src/api/types.rs rename to crates/idx-core/src/api/types.rs index d68d52b..130046d 100644 --- a/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, FixedOffset, NaiveDate}; -use clap::ValueEnum; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; /// Snapshot quote data normalized from Yahoo Finance `/v8/finance/chart` response. @@ -273,23 +272,24 @@ where }) } -#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum Period { - #[value(name = "1d")] + #[cfg_attr(feature = "clap", value(name = "1d"))] OneDay, - #[value(name = "5d")] + #[cfg_attr(feature = "clap", value(name = "5d"))] FiveDays, - #[value(name = "1mo")] + #[cfg_attr(feature = "clap", value(name = "1mo"))] OneMonth, - #[value(name = "3mo")] + #[cfg_attr(feature = "clap", value(name = "3mo"))] ThreeMonths, - #[value(name = "6mo")] + #[cfg_attr(feature = "clap", value(name = "6mo"))] SixMonths, - #[value(name = "1y")] + #[cfg_attr(feature = "clap", value(name = "1y"))] OneYear, - #[value(name = "2y")] + #[cfg_attr(feature = "clap", value(name = "2y"))] TwoYears, - #[value(name = "5y")] + #[cfg_attr(feature = "clap", value(name = "5y"))] FiveYears, } @@ -308,13 +308,14 @@ impl Period { } } -#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum Interval { - #[value(name = "1d")] + #[cfg_attr(feature = "clap", value(name = "1d"))] Day, - #[value(name = "1wk")] + #[cfg_attr(feature = "clap", value(name = "1wk"))] Week, - #[value(name = "1mo")] + #[cfg_attr(feature = "clap", value(name = "1mo"))] Month, } diff --git a/src/api/yahoo/client.rs b/crates/idx-core/src/api/yahoo/client.rs similarity index 100% rename from src/api/yahoo/client.rs rename to crates/idx-core/src/api/yahoo/client.rs diff --git a/src/api/yahoo/map.rs b/crates/idx-core/src/api/yahoo/map.rs similarity index 100% rename from src/api/yahoo/map.rs rename to crates/idx-core/src/api/yahoo/map.rs diff --git a/src/api/yahoo/mod.rs b/crates/idx-core/src/api/yahoo/mod.rs similarity index 100% rename from src/api/yahoo/mod.rs rename to crates/idx-core/src/api/yahoo/mod.rs diff --git a/src/api/yahoo/parse.rs b/crates/idx-core/src/api/yahoo/parse.rs similarity index 100% rename from src/api/yahoo/parse.rs rename to crates/idx-core/src/api/yahoo/parse.rs diff --git a/src/api/yahoo/raw_types.rs b/crates/idx-core/src/api/yahoo/raw_types.rs similarity index 100% rename from src/api/yahoo/raw_types.rs rename to crates/idx-core/src/api/yahoo/raw_types.rs diff --git a/src/cache.rs b/crates/idx-core/src/cache.rs similarity index 100% rename from src/cache.rs rename to crates/idx-core/src/cache.rs diff --git a/src/config.rs b/crates/idx-core/src/config.rs similarity index 75% rename from src/config.rs rename to crates/idx-core/src/config.rs index b7b3ba9..6852533 100644 --- a/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -1,14 +1,16 @@ +//! Config file location, provider selection, and `config.toml` read/write. +//! Presentation settings (output format, color) are resolved by frontends. + use std::fs; -use std::io::IsTerminal; use std::path::PathBuf; -use clap::ValueEnum; use directories::ProjectDirs; use serde::Deserialize; -use crate::cli::Cli; use crate::error::IdxError; -use crate::output::{OutputFormat, OutputSetting}; + +/// Values accepted for `general.output`; frontends decide what they mean. +pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"]; #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -17,9 +19,10 @@ pub enum ProviderKind { Msn, } -#[derive(Debug, Clone, Copy, Deserialize, ValueEnum, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "lowercase")] -#[value(rename_all = "lower")] +#[cfg_attr(feature = "clap", value(rename_all = "lower"))] pub enum HistoryProviderKind { Auto, Yahoo, @@ -34,7 +37,7 @@ impl ProviderKind { } } - fn parse(value: &str) -> Result { + pub fn parse(value: &str) -> Result { if value.eq_ignore_ascii_case("yahoo") { Ok(Self::Yahoo) } else if value.eq_ignore_ascii_case("msn") { @@ -56,7 +59,7 @@ impl HistoryProviderKind { } } - fn parse(value: &str) -> Result { + pub fn parse(value: &str) -> Result { if value.eq_ignore_ascii_case("auto") { Ok(Self::Auto) } else if value.eq_ignore_ascii_case("yahoo") { @@ -71,129 +74,7 @@ impl HistoryProviderKind { } } -#[derive(Debug, Clone)] -pub struct IdxConfig { - pub provider: ProviderKind, - pub history_provider: HistoryProviderKind, - pub exchange: String, - pub output: OutputFormat, - pub no_color: bool, - pub quote_ttl: u64, - pub fundamental_ttl: u64, -} - -#[derive(Debug, Deserialize, Default)] -struct FileConfig { - general: Option, - cache: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct FileGeneral { - provider: Option, - history_provider: Option, - exchange: Option, - output: Option, - color: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct FileCache { - quote_ttl: Option, - fundamental_ttl: Option, -} - -impl Default for IdxConfig { - fn default() -> Self { - Self { - provider: ProviderKind::Msn, - history_provider: HistoryProviderKind::Auto, - exchange: "JK".to_string(), - output: OutputSetting::Auto.resolve(stdout_is_terminal()), - no_color: false, - quote_ttl: 300, - fundamental_ttl: 3600, - } - } -} - -impl IdxConfig { - pub fn load_with_cli(cli: &Cli) -> Result { - let mut cfg = Self::load()?; - - if let Ok(provider) = std::env::var("IDX_PROVIDER") { - cfg.provider = ProviderKind::parse(&provider)?; - } - if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") { - cfg.history_provider = HistoryProviderKind::parse(&history_provider)?; - } - if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { - cfg.exchange = exchange; - } - if let Ok(output) = std::env::var("IDX_OUTPUT") { - let setting = OutputSetting::parse(&output).ok_or_else(|| { - IdxError::ConfigError(format!( - "invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'" - )) - })?; - cfg.output = setting.resolve(stdout_is_terminal()); - } - if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { - cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true"); - } - if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? { - cfg.quote_ttl = parsed; - } - if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? { - cfg.fundamental_ttl = parsed; - } - - if let Some(output) = cli.output { - cfg.output = output.resolve(stdout_is_terminal()); - } - cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal(); - - Ok(cfg) - } - - pub fn load() -> Result { - let mut cfg = Self::default(); - let path = config_path()?; - if path.exists() { - let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; - let parsed: FileConfig = - toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; - if let Some(general) = parsed.general { - if let Some(provider) = general.provider { - cfg.provider = provider; - } - if let Some(history_provider) = general.history_provider { - cfg.history_provider = history_provider; - } - if let Some(exchange) = general.exchange { - cfg.exchange = exchange; - } - if let Some(output) = general.output { - cfg.output = output.resolve(stdout_is_terminal()); - } - if let Some(color) = general.color { - cfg.no_color = !color; - } - } - if let Some(cache) = parsed.cache { - if let Some(v) = cache.quote_ttl { - cfg.quote_ttl = v; - } - if let Some(v) = cache.fundamental_ttl { - cfg.fundamental_ttl = v; - } - } - } - Ok(cfg) - } -} - -fn parse_env_ttl(name: &str) -> Result, IdxError> { +pub fn parse_env_ttl(name: &str) -> Result, IdxError> { let Ok(raw) = std::env::var(name) else { return Ok(None); }; @@ -214,10 +95,6 @@ fn parse_env_ttl(name: &str) -> Result, IdxError> { Ok(Some(parsed)) } -pub fn stdout_is_terminal() -> bool { - std::io::stdout().is_terminal() -} - pub fn default_config_toml() -> String { "[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"auto\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() } @@ -294,7 +171,7 @@ fn normalize_config_value(key: &str, value: &str) -> Result { - if OutputSetting::parse(value).is_none() { + if !OUTPUT_VALUES.iter().any(|v| v.eq_ignore_ascii_case(value)) { return Err(IdxError::InvalidInput(format!( "invalid output format '{value}': expected 'auto', 'table', 'json', or 'jsonl'" ))); @@ -381,17 +258,6 @@ pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> { #[cfg(test)] mod tests { - use super::IdxConfig; - - #[test] - fn default_values_are_sane() { - let cfg = IdxConfig::default(); - assert_eq!(cfg.provider, super::ProviderKind::Msn); - assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto); - assert_eq!(cfg.exchange, "JK"); - assert_eq!(cfg.quote_ttl, 300); - } - #[test] fn parses_provider_values() { assert_eq!( diff --git a/src/curl_impersonate.rs b/crates/idx-core/src/curl_impersonate.rs similarity index 100% rename from src/curl_impersonate.rs rename to crates/idx-core/src/curl_impersonate.rs diff --git a/src/error.rs b/crates/idx-core/src/error.rs similarity index 100% rename from src/error.rs rename to crates/idx-core/src/error.rs diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs new file mode 100644 index 0000000..39170cd --- /dev/null +++ b/crates/idx-core/src/lib.rs @@ -0,0 +1,39 @@ +//! idx-core: retrieval and analysis of Indonesian (IDX) market, financial, and +//! investor data, shared by the `idx` CLI and other frontends. +//! See docs/DESIGN.md. + +pub mod analysis; +pub mod api; +pub mod cache; +pub mod config; +pub mod curl_impersonate; +pub mod error; +#[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}"); + } + } +} diff --git a/src/ownership/archive.rs b/crates/idx-core/src/ownership/archive.rs similarity index 100% rename from src/ownership/archive.rs rename to crates/idx-core/src/ownership/archive.rs diff --git a/src/ownership/db.rs b/crates/idx-core/src/ownership/db.rs similarity index 99% rename from src/ownership/db.rs rename to crates/idx-core/src/ownership/db.rs index af4b936..d98dbce 100644 --- a/src/ownership/db.rs +++ b/crates/idx-core/src/ownership/db.rs @@ -5,7 +5,7 @@ use chrono::NaiveDate; use directories::ProjectDirs; use rusqlite::{Connection, params}; -use crate::config::{IdxConfig, get_config_value}; +use crate::config::get_config_value; use crate::error::IdxError; use crate::ownership::search; use crate::ownership::types::{ @@ -128,8 +128,8 @@ pub fn ensure_schema(conn: &Connection) -> Result<(), IdxError> { } /// Open ownership database connection and run idempotent schema migration. -pub fn open_db(config: &IdxConfig) -> Result { - let db_path = db_path(config)?; +pub fn open_db() -> Result { + let db_path = db_path()?; if let Some(parent) = db_path.parent() { fs::create_dir_all(parent).map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -1204,7 +1204,7 @@ pub fn compute_concentration(percentages_bps: &[i64]) -> ConcentrationMetrics { } } -pub fn db_path(_config: &IdxConfig) -> Result { +pub fn db_path() -> Result { if let Some(custom_path) = get_config_value("ownership.db_path")? { let trimmed = custom_path.trim(); if !trimmed.is_empty() { diff --git a/src/ownership/entities.rs b/crates/idx-core/src/ownership/entities.rs similarity index 100% rename from src/ownership/entities.rs rename to crates/idx-core/src/ownership/entities.rs diff --git a/src/ownership/graph.rs b/crates/idx-core/src/ownership/graph.rs similarity index 100% rename from src/ownership/graph.rs rename to crates/idx-core/src/ownership/graph.rs diff --git a/src/ownership/mod.rs b/crates/idx-core/src/ownership/mod.rs similarity index 100% rename from src/ownership/mod.rs rename to crates/idx-core/src/ownership/mod.rs diff --git a/src/ownership/parser.rs b/crates/idx-core/src/ownership/parser.rs similarity index 100% rename from src/ownership/parser.rs rename to crates/idx-core/src/ownership/parser.rs diff --git a/src/ownership/remote.rs b/crates/idx-core/src/ownership/remote.rs similarity index 100% rename from src/ownership/remote.rs rename to crates/idx-core/src/ownership/remote.rs diff --git a/src/ownership/search.rs b/crates/idx-core/src/ownership/search.rs similarity index 100% rename from src/ownership/search.rs rename to crates/idx-core/src/ownership/search.rs diff --git a/src/ownership/snapshot.rs b/crates/idx-core/src/ownership/snapshot.rs similarity index 100% rename from src/ownership/snapshot.rs rename to crates/idx-core/src/ownership/snapshot.rs diff --git a/src/ownership/types.rs b/crates/idx-core/src/ownership/types.rs similarity index 100% rename from src/ownership/types.rs rename to crates/idx-core/src/ownership/types.rs diff --git a/src/ownership/xlsx.rs b/crates/idx-core/src/ownership/xlsx.rs similarity index 100% rename from src/ownership/xlsx.rs rename to crates/idx-core/src/ownership/xlsx.rs diff --git a/tests/fixtures/bing_buyers_bbca.json b/crates/idx-core/tests/fixtures/bing_buyers_bbca.json similarity index 100% rename from tests/fixtures/bing_buyers_bbca.json rename to crates/idx-core/tests/fixtures/bing_buyers_bbca.json diff --git a/tests/fixtures/bing_holders_bbca.json b/crates/idx-core/tests/fixtures/bing_holders_bbca.json similarity index 100% rename from tests/fixtures/bing_holders_bbca.json rename to crates/idx-core/tests/fixtures/bing_holders_bbca.json diff --git a/tests/fixtures/chart_bbca_1d.json b/crates/idx-core/tests/fixtures/chart_bbca_1d.json similarity index 100% rename from tests/fixtures/chart_bbca_1d.json rename to crates/idx-core/tests/fixtures/chart_bbca_1d.json diff --git a/tests/fixtures/chart_bbca_3mo.json b/crates/idx-core/tests/fixtures/chart_bbca_3mo.json similarity index 100% rename from tests/fixtures/chart_bbca_3mo.json rename to crates/idx-core/tests/fixtures/chart_bbca_3mo.json diff --git a/tests/fixtures/chart_bbca_with_gap.json b/crates/idx-core/tests/fixtures/chart_bbca_with_gap.json similarity index 100% rename from tests/fixtures/chart_bbca_with_gap.json rename to crates/idx-core/tests/fixtures/chart_bbca_with_gap.json diff --git a/tests/fixtures/idx_announcement_kepemilikan.json b/crates/idx-core/tests/fixtures/idx_announcement_kepemilikan.json similarity index 100% rename from tests/fixtures/idx_announcement_kepemilikan.json rename to crates/idx-core/tests/fixtures/idx_announcement_kepemilikan.json diff --git a/tests/fixtures/idx_share_ownership_page_excerpt.html b/crates/idx-core/tests/fixtures/idx_share_ownership_page_excerpt.html similarity index 100% rename from tests/fixtures/idx_share_ownership_page_excerpt.html rename to crates/idx-core/tests/fixtures/idx_share_ownership_page_excerpt.html diff --git a/tests/fixtures/ksei_above1_20260831_excerpt.xlsx b/crates/idx-core/tests/fixtures/ksei_above1_20260831_excerpt.xlsx similarity index 100% rename from tests/fixtures/ksei_above1_20260831_excerpt.xlsx rename to crates/idx-core/tests/fixtures/ksei_above1_20260831_excerpt.xlsx diff --git a/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml b/crates/idx-core/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml similarity index 100% rename from tests/fixtures/ksei_above1_spaced_stext_excerpt.xml rename to crates/idx-core/tests/fixtures/ksei_above1_spaced_stext_excerpt.xml diff --git a/tests/fixtures/ksei_above1_stext_excerpt.xml b/crates/idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml similarity index 100% rename from tests/fixtures/ksei_above1_stext_excerpt.xml rename to crates/idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml diff --git a/tests/fixtures/ksei_above1_stext_excerpt_prev.xml b/crates/idx-core/tests/fixtures/ksei_above1_stext_excerpt_prev.xml similarity index 100% rename from tests/fixtures/ksei_above1_stext_excerpt_prev.xml rename to crates/idx-core/tests/fixtures/ksei_above1_stext_excerpt_prev.xml diff --git a/tests/fixtures/ksei_above5_20260923_excerpt.xlsx b/crates/idx-core/tests/fixtures/ksei_above5_20260923_excerpt.xlsx similarity index 100% rename from tests/fixtures/ksei_above5_20260923_excerpt.xlsx rename to crates/idx-core/tests/fixtures/ksei_above5_20260923_excerpt.xlsx diff --git a/tests/fixtures/ksei_above5_stext_excerpt.xml b/crates/idx-core/tests/fixtures/ksei_above5_stext_excerpt.xml similarity index 100% rename from tests/fixtures/ksei_above5_stext_excerpt.xml rename to crates/idx-core/tests/fixtures/ksei_above5_stext_excerpt.xml diff --git a/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml b/crates/idx-core/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml similarity index 100% rename from tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml rename to crates/idx-core/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml diff --git a/tests/fixtures/ksei_balancepos_20260130_excerpt.txt b/crates/idx-core/tests/fixtures/ksei_balancepos_20260130_excerpt.txt similarity index 100% rename from tests/fixtures/ksei_balancepos_20260130_excerpt.txt rename to crates/idx-core/tests/fixtures/ksei_balancepos_20260130_excerpt.txt diff --git a/tests/fixtures/ksei_balancepos_20260227_excerpt.txt b/crates/idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt similarity index 100% rename from tests/fixtures/ksei_balancepos_20260227_excerpt.txt rename to crates/idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt diff --git a/tests/fixtures/ksei_investor_type_stext_excerpt.xml b/crates/idx-core/tests/fixtures/ksei_investor_type_stext_excerpt.xml similarity index 100% rename from tests/fixtures/ksei_investor_type_stext_excerpt.xml rename to crates/idx-core/tests/fixtures/ksei_investor_type_stext_excerpt.xml diff --git a/tests/fixtures/ksei_stext_sample.xml b/crates/idx-core/tests/fixtures/ksei_stext_sample.xml similarity index 100% rename from tests/fixtures/ksei_stext_sample.xml rename to crates/idx-core/tests/fixtures/ksei_stext_sample.xml diff --git a/tests/fixtures/msn_chart_bbca_3m.json b/crates/idx-core/tests/fixtures/msn_chart_bbca_3m.json similarity index 100% rename from tests/fixtures/msn_chart_bbca_3m.json rename to crates/idx-core/tests/fixtures/msn_chart_bbca_3m.json diff --git a/tests/fixtures/msn_chart_bbca_3mo.json b/crates/idx-core/tests/fixtures/msn_chart_bbca_3mo.json similarity index 100% rename from tests/fixtures/msn_chart_bbca_3mo.json rename to crates/idx-core/tests/fixtures/msn_chart_bbca_3mo.json diff --git a/tests/fixtures/msn_earnings_bbca.json b/crates/idx-core/tests/fixtures/msn_earnings_bbca.json similarity index 100% rename from tests/fixtures/msn_earnings_bbca.json rename to crates/idx-core/tests/fixtures/msn_earnings_bbca.json diff --git a/tests/fixtures/msn_financials_bbca.json b/crates/idx-core/tests/fixtures/msn_financials_bbca.json similarity index 100% rename from tests/fixtures/msn_financials_bbca.json rename to crates/idx-core/tests/fixtures/msn_financials_bbca.json diff --git a/tests/fixtures/msn_insights_bbca.json b/crates/idx-core/tests/fixtures/msn_insights_bbca.json similarity index 100% rename from tests/fixtures/msn_insights_bbca.json rename to crates/idx-core/tests/fixtures/msn_insights_bbca.json diff --git a/tests/fixtures/msn_keyratios_bbca.json b/crates/idx-core/tests/fixtures/msn_keyratios_bbca.json similarity index 100% rename from tests/fixtures/msn_keyratios_bbca.json rename to crates/idx-core/tests/fixtures/msn_keyratios_bbca.json diff --git a/tests/fixtures/msn_keyratios_industry_only.json b/crates/idx-core/tests/fixtures/msn_keyratios_industry_only.json similarity index 100% rename from tests/fixtures/msn_keyratios_industry_only.json rename to crates/idx-core/tests/fixtures/msn_keyratios_industry_only.json diff --git a/tests/fixtures/msn_keyratios_infinity.json b/crates/idx-core/tests/fixtures/msn_keyratios_infinity.json similarity index 100% rename from tests/fixtures/msn_keyratios_infinity.json rename to crates/idx-core/tests/fixtures/msn_keyratios_infinity.json diff --git a/tests/fixtures/msn_keyratios_negative_infinity.json b/crates/idx-core/tests/fixtures/msn_keyratios_negative_infinity.json similarity index 100% rename from tests/fixtures/msn_keyratios_negative_infinity.json rename to crates/idx-core/tests/fixtures/msn_keyratios_negative_infinity.json diff --git a/tests/fixtures/msn_news_bbca.json b/crates/idx-core/tests/fixtures/msn_news_bbca.json similarity index 100% rename from tests/fixtures/msn_news_bbca.json rename to crates/idx-core/tests/fixtures/msn_news_bbca.json diff --git a/tests/fixtures/msn_profile_bbca.json b/crates/idx-core/tests/fixtures/msn_profile_bbca.json similarity index 100% rename from tests/fixtures/msn_profile_bbca.json rename to crates/idx-core/tests/fixtures/msn_profile_bbca.json diff --git a/tests/fixtures/msn_quote_bbca.json b/crates/idx-core/tests/fixtures/msn_quote_bbca.json similarity index 100% rename from tests/fixtures/msn_quote_bbca.json rename to crates/idx-core/tests/fixtures/msn_quote_bbca.json diff --git a/tests/fixtures/msn_screener_id_topperfs.json b/crates/idx-core/tests/fixtures/msn_screener_id_topperfs.json similarity index 100% rename from tests/fixtures/msn_screener_id_topperfs.json rename to crates/idx-core/tests/fixtures/msn_screener_id_topperfs.json diff --git a/tests/fixtures/msn_sentiment_bbca.json b/crates/idx-core/tests/fixtures/msn_sentiment_bbca.json similarity index 100% rename from tests/fixtures/msn_sentiment_bbca.json rename to crates/idx-core/tests/fixtures/msn_sentiment_bbca.json diff --git a/tests/fixtures/quotesummary_bbca.json b/crates/idx-core/tests/fixtures/quotesummary_bbca.json similarity index 100% rename from tests/fixtures/quotesummary_bbca.json rename to crates/idx-core/tests/fixtures/quotesummary_bbca.json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 030e959..7f6789e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,10 +1,25 @@ # Architecture +## Crates + +The repo is a Cargo workspace with one shared version: + +- `crates/idx-core` (library): providers (`api/`), `analysis/`, `cache`, config + file handling and provider selection (`config`), `ownership/` (SQLite, parsers, + discovery, snapshot sync), `error`, and `runtime` (quiet/info/warn). It owns the + shared test fixtures in `tests/fixtures/`. +- `crates/idx-cli` (binary `idx`): clap commands (`cli/`), output format and + color resolution (`config.rs`, `IdxConfig`), and rendering (`output/`). Its + `tests/` hold the CLI integration and golden output suites. + +Some fetch/cache orchestration still lives in `crates/idx-cli/src/cli/` until +phase 3 of `docs/DESIGN.md` moves it into idx-core. + ## Domain Map ``` ┌─────────────────────────────────────────────────────────┐ -│ CLI Layer │ +│ CLI Layer (idx-cli) │ │ main.rs → cli/stocks.rs, cli/ownership.rs, cli/... │ │ Clap derive structs, command dispatch, arg validation │ └──────────────────────┬──────────────────────────────────┘ @@ -39,7 +54,7 @@ sentiment, screener - `https://api.msn.com/msn/v0/pages/finance/`: key ratios, insights, news feed -The public API key embedded in MSN Money lives in `src/api/msn/client.rs`. +The public API key embedded in MSN Money lives in `crates/idx-core/src/api/msn/client.rs`. ### Hybrid History Strategy When `history_provider = auto` (default): @@ -89,7 +104,7 @@ Unlike the `stocks` module (live-fetch), ownership is **import-then-query**: - **KSEI** — official ≥1% shareholder registry, published monthly by IDX. Through May 2026 it was a PDF announcement; from the 2026-05-29 report onward it is an XLSX workbook on the IDX "Data Kepemilikan Saham" page - **KSEI archive** — monthly ZIP/TXT balance-position matrix, used as a local fallback/backstop import path - **Bing Finance** — global institutional ownership. The schema, `--source bing` - queries, and `src/api/msn/bing.rs` exist, but `ownership import --fetch-bing` + queries, and `crates/idx-core/src/api/msn/bing.rs` exist, but `ownership import --fetch-bing` is intentionally unsupported, so no ingest path populates Bing rows today ### Discovery diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbe9c3f..56615f8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-1 done. +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-2 done. ## Goal @@ -99,7 +99,7 @@ Key decisions: - **Typed inputs.** Screener filters, regions, statement and earnings filters become enums with `FromStr`, so validation is identical across frontends. -## What Moves Out of `src/cli/` +## What Moves Out of `crates/idx-cli/src/cli/` | From | To | | --- | --- | diff --git a/flake.nix b/flake.nix index 7f5d170..c83465c 100644 --- a/flake.nix +++ b/flake.nix @@ -18,6 +18,7 @@ pkgs = import nixpkgs { inherit system overlays; }; lib = pkgs.lib; cargoManifest = builtins.fromTOML (builtins.readFile ./Cargo.toml); + version = cargoManifest.workspace.package.version; rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" "rust-analyzer" ]; }; @@ -31,12 +32,15 @@ rustFiles = [ ./Cargo.toml ./Cargo.lock - ./src - ./tests/fixtures + ./crates/idx-core/Cargo.toml + ./crates/idx-core/src + ./crates/idx-core/tests/fixtures + ./crates/idx-cli/Cargo.toml + ./crates/idx-cli/src ]; testSrc = lib.fileset.toSource { root = ./.; - fileset = lib.fileset.unions (rustFiles ++ [ ./tests ]); + fileset = lib.fileset.unions (rustFiles ++ [ ./crates/idx-cli/tests ]); }; src = lib.fileset.toSource { root = ./.; @@ -44,8 +48,8 @@ }; commonArgs = { inherit src; - pname = cargoManifest.package.name; - version = cargoManifest.package.version; + pname = "idx-cli"; + inherit version; CARGO_PROFILE = "dev"; strictDeps = true; nativeBuildInputs = with pkgs; [ pkg-config ]; @@ -90,7 +94,7 @@ ]; ownershipPublisher = pkgs.stdenvNoCC.mkDerivation { pname = "idx-ownership-publisher"; - version = cargoManifest.package.version; + inherit version; src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions publisherScripts; @@ -155,7 +159,7 @@ ${ownershipPublisher}/bin/idx-ownership-publish --help touch "$out" ''; - fmt = craneLib.cargoFmt { src = testSrc; }; + fmt = craneLib.cargoFmt { src = testSrc; pname = "idx-cli"; inherit version; }; clippy = craneLib.cargoClippy (commonArgs // { inherit cargoArtifacts; src = testSrc; @@ -172,20 +176,39 @@ # `curl` against a local fixture server. nativeCheckInputs = [ pkgs.curl ]; }); - # `cargo package` verification: the published crate (per the - # `include` list in Cargo.toml) must build on its own. + # `cargo package` verification: each published crate (per the + # `include` lists in crates/*/Cargo.toml) must build on its own. package = craneLib.mkCargoDerivation (commonArgs // { inherit cargoArtifacts; pnameSuffix = "-package"; src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions (rustFiles ++ [ - ./tests + ./crates/idx-cli/tests + ./crates/idx-core/LICENSE + ./crates/idx-cli/LICENSE ./LICENSE ./README.md ]); }; - buildPhaseCargoCommand = "cargo package --locked --offline --allow-dirty"; + buildPhaseCargoCommand = '' + # idx-core: full `cargo package` verification. + cargo package -p idx-core --registry crates-io --locked --offline --allow-dirty + # idx-cli depends on idx-core, which is not on crates.io yet, so + # cargo cannot resolve it from the vendored registry. Package + # idx-cli without lockfile or verification, then build the + # extracted tarball against the packaged idx-core. Real releases + # publish idx-core first and package idx-cli normally. + cargo package -p idx-cli --registry crates-io --no-verify --exclude-lockfile --offline --allow-dirty + verify="$TMPDIR/package-verify" + mkdir -p "$verify" + tar -xzf target/package/idx-core-${version}.crate -C "$verify" + tar -xzf target/package/idx-cli-${version}.crate -C "$verify" + cp Cargo.lock "$verify/idx-cli-${version}/" + (cd "$verify/idx-cli-${version}" && cargo build --offline \ + --config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \ + --target-dir "$verify/target") + ''; doInstallCargoArtifacts = false; installPhaseCommand = "touch $out"; }); @@ -196,7 +219,7 @@ '' # The mock provider reads fixtures relative to the working dir. mkdir -p tests - cp -r ${./tests/fixtures} tests/fixtures + cp -r ${./crates/idx-core/tests/fixtures} tests/fixtures export HOME="$TMPDIR" ${idxPackage}/bin/idx -o json version | jq -e '.version | type == "string"' ${idxPackage}/bin/idx completions bash | bash -n From e78d9fcecd669bff0695b42131cdd5531ce8a0a7 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 09:31:12 +0000 Subject: [PATCH 06/14] refactor(core): compile the mock provider only with a testing feature Phase 3a of docs/DESIGN.md. MockProvider moves to api/mock.rs, and it, the embedded MSN fixture bodies, and the IDX_MOCK_* hooks compile only with idx-core's new `testing` feature. idx-cli enables it as a dev-dependency, so `cargo test` keeps the fixture-backed provider while release binaries no longer embed fixture data or honor IDX_USE_MOCK_PROVIDER. Parser helpers used only by the mock are gated the same way. The packaged-cli flake check no longer relies on the mock; it checks an offline cache miss (JSON CACHEMISS, exit 3) instead. Goldens unchanged; clippy is clean with and without `testing`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- crates/idx-cli/Cargo.toml | 2 + crates/idx-core/Cargo.toml | 2 + crates/idx-core/src/api/mock.rs | 119 +++++++++++++++ crates/idx-core/src/api/mod.rs | 195 ++++++------------------- crates/idx-core/src/api/msn/client.rs | 13 ++ crates/idx-core/src/api/msn/mod.rs | 1 + crates/idx-core/src/api/yahoo/mod.rs | 1 + crates/idx-core/src/api/yahoo/parse.rs | 15 +- docs/CONVENTIONS.md | 2 +- docs/DESIGN.md | 5 +- docs/SMOKE.md | 2 +- flake.nix | 15 +- 13 files changed, 214 insertions(+), 161 deletions(-) create mode 100644 crates/idx-core/src/api/mock.rs diff --git a/AGENTS.md b/AGENTS.md index 122a90a..aedb82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,8 @@ Docs: ## 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 diff --git a/crates/idx-cli/Cargo.toml b/crates/idx-cli/Cargo.toml index 53fa329..debf2a1 100644 --- a/crates/idx-cli/Cargo.toml +++ b/crates/idx-cli/Cargo.toml @@ -36,6 +36,8 @@ sha2 = { workspace = true, optional = true } ureq = { workspace = true, optional = 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 diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml index 70b9b25..1ac22af 100644 --- a/crates/idx-core/Cargo.toml +++ b/crates/idx-core/Cargo.toml @@ -17,6 +17,8 @@ default = ["ownership"] ownership = ["dep:rusqlite", "dep:quick-xml", "dep:sha2", "dep:zip"] # Derives clap::ValueEnum on enums frontends expose as CLI arguments. clap = ["dep:clap"] +# Fixture-backed mock provider (IDX_USE_MOCK_PROVIDER) for tests only. +testing = [] [dependencies] clap = { workspace = true, optional = true } diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs new file mode 100644 index 0000000..9959599 --- /dev/null +++ b/crates/idx-core/src/api/mock.rs @@ -0,0 +1,119 @@ +//! Fixture-backed provider for tests, enabled by the `testing` feature and +//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. + +use super::types::{Bar, Fundamentals, Interval, Period, Quote}; +use super::{FundamentalsProvider, HistoryProvider, QuoteProvider, msn, yahoo}; +use crate::config::ProviderKind; +use crate::error::IdxError; + +pub(crate) fn enabled() -> bool { + std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() +} + +pub struct MockProvider { + quote: Result, + fundamentals: Result, + history: Result, IdxError>, +} + +impl MockProvider { + pub fn from_fixtures(provider: ProviderKind) -> Self { + Self::from_fixtures_with_history_verbose(provider, false) + } + + pub fn from_fixtures_with_history_verbose( + provider: ProviderKind, + history_verbose: bool, + ) -> Self { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Self::with_error(IdxError::ProviderUnavailable); + } + + match provider { + ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose), + ProviderKind::Msn => Self::from_msn_fixtures(), + } + } + + fn from_yahoo_fixtures(history_verbose: bool) -> Self { + let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json") + .unwrap_or_else(|_| "{}".to_string()); + let history_path = std::env::var("IDX_MOCK_YAHOO_HISTORY_FIXTURE") + .unwrap_or_else(|_| "tests/fixtures/chart_bbca_3mo.json".to_string()); + let history_raw = + std::fs::read_to_string(&history_path).unwrap_or_else(|_| "{}".to_string()); + let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json") + .unwrap_or_else(|_| "{}".to_string()); + + let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + let history = + yahoo::parse_history_from_str_with_verbose("BBCA.JK", &history_raw, history_verbose) + .map_err(|e| IdxError::ParseError(e.to_string())); + + Self { + quote, + fundamentals, + history, + } + } + + fn from_msn_fixtures() -> Self { + let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") + .unwrap_or_else(|_| "[]".to_string()); + let fundamentals_path = std::env::var("IDX_MOCK_MSN_KEYRATIOS_FIXTURE") + .unwrap_or_else(|_| "tests/fixtures/msn_keyratios_bbca.json".to_string()); + let fundamentals_raw = + std::fs::read_to_string(&fundamentals_path).unwrap_or_else(|_| "[]".to_string()); + + let quote = msn::parse_quote_from_str("BBCA.JK", "e_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw)) + .map_err(|e| IdxError::ParseError(e.to_string())); + let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json") + .unwrap_or_else(|_| "[]".to_string()); + let history = msn::parse_history_from_str("BBCA.JK", &history_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + + Self { + quote, + fundamentals, + history, + } + } + + pub fn with_error(err: IdxError) -> Self { + Self { + quote: Err(err.clone()), + fundamentals: Err(err.clone()), + history: Err(err), + } + } +} + +impl QuoteProvider for MockProvider { + fn quote(&self, symbol: &str) -> Result { + let mut q = self.quote.clone()?; + q.symbol = symbol.to_string(); + Ok(q) + } +} + +impl FundamentalsProvider for MockProvider { + fn fundamentals(&self, _symbol: &str) -> Result { + self.fundamentals.clone() + } +} + +impl HistoryProvider for MockProvider { + fn history( + &self, + _symbol: &str, + _period: &Period, + _interval: &Interval, + ) -> Result, IdxError> { + self.history.clone() + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 9fad9cb..624942e 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "testing")] +pub mod mock; pub mod msn; pub mod types; pub mod yahoo; @@ -171,11 +173,36 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result } pub fn default_provider(provider: ProviderKind, verbose: bool) -> SelectedProvider { - build_selected_provider( - provider, - verbose, - std::env::var("IDX_USE_MOCK_PROVIDER").is_ok(), - ) + build_selected_provider(provider, verbose, mock_enabled()) +} + +fn mock_enabled() -> bool { + #[cfg(feature = "testing")] + return mock::enabled(); + #[cfg(not(feature = "testing"))] + false +} + +#[cfg(feature = "testing")] +fn mock_market(provider: ProviderKind) -> Option> { + Some(Box::new(mock::MockProvider::from_fixtures(provider))) +} + +#[cfg(not(feature = "testing"))] +fn mock_market(_provider: ProviderKind) -> Option> { + None +} + +#[cfg(feature = "testing")] +fn mock_history(provider: ProviderKind, verbose: bool) -> Option> { + Some(Box::new( + mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), + )) +} + +#[cfg(not(feature = "testing"))] +fn mock_history(_provider: ProviderKind, _verbose: bool) -> Option> { + None } fn build_selected_provider( @@ -183,10 +210,15 @@ fn build_selected_provider( verbose: bool, use_mock: bool, ) -> SelectedProvider { - match (provider, use_mock) { - (ProviderKind::Yahoo, true) => SelectedProvider { + let mock = if use_mock { + mock_market(provider) + } else { + None + }; + match provider { + ProviderKind::Yahoo => SelectedProvider { kind: ProviderKind::Yahoo, - market: Box::new(MockProvider::from_fixtures(ProviderKind::Yahoo)), + market: mock.unwrap_or_else(|| Box::new(yahoo::YahooProvider::new(verbose))), profile: None, financials: None, earnings: None, @@ -195,31 +227,9 @@ fn build_selected_provider( news: None, screener: None, }, - (ProviderKind::Yahoo, false) => SelectedProvider { - kind: ProviderKind::Yahoo, - market: Box::new(yahoo::YahooProvider::new(verbose)), - profile: None, - financials: None, - earnings: None, - sentiment: None, - insights: None, - news: None, - screener: None, - }, - (ProviderKind::Msn, true) => SelectedProvider { + ProviderKind::Msn => SelectedProvider { kind: ProviderKind::Msn, - market: Box::new(MockProvider::from_fixtures(ProviderKind::Msn)), - profile: Some(Box::new(msn::MsnProvider::new(verbose))), - financials: Some(Box::new(msn::MsnProvider::new(verbose))), - earnings: Some(Box::new(msn::MsnProvider::new(verbose))), - sentiment: Some(Box::new(msn::MsnProvider::new(verbose))), - insights: Some(Box::new(msn::MsnProvider::new(verbose))), - news: Some(Box::new(msn::MsnProvider::new(verbose))), - screener: Some(Box::new(msn::MsnProvider::new(verbose))), - }, - (ProviderKind::Msn, false) => SelectedProvider { - kind: ProviderKind::Msn, - market: Box::new(msn::MsnProvider::new(verbose)), + market: mock.unwrap_or_else(|| Box::new(msn::MsnProvider::new(verbose))), profile: Some(Box::new(msn::MsnProvider::new(verbose))), financials: Some(Box::new(msn::MsnProvider::new(verbose))), earnings: Some(Box::new(msn::MsnProvider::new(verbose))), @@ -254,13 +264,10 @@ pub fn history_provider( }, }; - if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { - return Ok(( - resolved, - Box::new(MockProvider::from_fixtures_with_history_verbose( - resolved, verbose, - )), - )); + if mock_enabled() + && let Some(mock) = mock_history(resolved, verbose) + { + return Ok((resolved, mock)); } match resolved { @@ -269,114 +276,6 @@ pub fn history_provider( } } -pub struct MockProvider { - quote: Result, - fundamentals: Result, - history: Result, IdxError>, -} - -impl MockProvider { - pub fn from_fixtures(provider: ProviderKind) -> Self { - Self::from_fixtures_with_history_verbose(provider, false) - } - - pub fn from_fixtures_with_history_verbose( - provider: ProviderKind, - history_verbose: bool, - ) -> Self { - if std::env::var("IDX_MOCK_ERROR").is_ok() { - return Self::with_error(IdxError::ProviderUnavailable); - } - - match provider { - ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose), - ProviderKind::Msn => Self::from_msn_fixtures(), - } - } - - fn from_yahoo_fixtures(history_verbose: bool) -> Self { - let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json") - .unwrap_or_else(|_| "{}".to_string()); - let history_path = std::env::var("IDX_MOCK_YAHOO_HISTORY_FIXTURE") - .unwrap_or_else(|_| "tests/fixtures/chart_bbca_3mo.json".to_string()); - let history_raw = - std::fs::read_to_string(&history_path).unwrap_or_else(|_| "{}".to_string()); - let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json") - .unwrap_or_else(|_| "{}".to_string()); - - let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw) - .map_err(|e| IdxError::ParseError(e.to_string())); - let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) - .map_err(|e| IdxError::ParseError(e.to_string())); - let history = - yahoo::parse_history_from_str_with_verbose("BBCA.JK", &history_raw, history_verbose) - .map_err(|e| IdxError::ParseError(e.to_string())); - - Self { - quote, - fundamentals, - history, - } - } - - fn from_msn_fixtures() -> Self { - let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") - .unwrap_or_else(|_| "[]".to_string()); - let fundamentals_path = std::env::var("IDX_MOCK_MSN_KEYRATIOS_FIXTURE") - .unwrap_or_else(|_| "tests/fixtures/msn_keyratios_bbca.json".to_string()); - let fundamentals_raw = - std::fs::read_to_string(&fundamentals_path).unwrap_or_else(|_| "[]".to_string()); - - let quote = msn::parse_quote_from_str("BBCA.JK", "e_raw) - .map_err(|e| IdxError::ParseError(e.to_string())); - let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw)) - .map_err(|e| IdxError::ParseError(e.to_string())); - let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json") - .unwrap_or_else(|_| "[]".to_string()); - let history = msn::parse_history_from_str("BBCA.JK", &history_raw) - .map_err(|e| IdxError::ParseError(e.to_string())); - - Self { - quote, - fundamentals, - history, - } - } - - pub fn with_error(err: IdxError) -> Self { - Self { - quote: Err(err.clone()), - fundamentals: Err(err.clone()), - history: Err(err), - } - } -} - -impl QuoteProvider for MockProvider { - fn quote(&self, symbol: &str) -> Result { - let mut q = self.quote.clone()?; - q.symbol = symbol.to_string(); - Ok(q) - } -} - -impl FundamentalsProvider for MockProvider { - fn fundamentals(&self, _symbol: &str) -> Result { - self.fundamentals.clone() - } -} - -impl HistoryProvider for MockProvider { - fn history( - &self, - _symbol: &str, - _period: &Period, - _interval: &Interval, - ) -> Result, IdxError> { - self.history.clone() - } -} - #[cfg(test)] mod tests { use super::{ProviderKind, build_selected_provider, resolve_symbol}; @@ -410,6 +309,7 @@ mod tests { assert!(provider.screener_provider("screen").is_ok()); } + #[cfg(feature = "testing")] #[test] fn selected_provider_exposes_expected_capabilities_for_mock_msn() { let provider = build_selected_provider(ProviderKind::Msn, false, true); @@ -438,6 +338,7 @@ mod tests { ); } + #[cfg(feature = "testing")] #[test] fn selected_provider_rejects_msn_only_capabilities_for_mock_yahoo() { let provider = build_selected_provider(ProviderKind::Yahoo, false, true); diff --git a/crates/idx-core/src/api/msn/client.rs b/crates/idx-core/src/api/msn/client.rs index fde0bf5..a62bff0 100644 --- a/crates/idx-core/src/api/msn/client.rs +++ b/crates/idx-core/src/api/msn/client.rs @@ -34,6 +34,17 @@ impl MsnClient { Self { agent } } + #[cfg(not(feature = "testing"))] + fn mock_error() -> Option { + None + } + + #[cfg(not(feature = "testing"))] + fn mock_body(_endpoint: &str) -> Option<&'static str> { + None + } + + #[cfg(feature = "testing")] fn mock_error() -> Option { if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() && std::env::var("IDX_MOCK_ERROR").is_ok() { @@ -43,6 +54,7 @@ impl MsnClient { } } + #[cfg(feature = "testing")] fn mock_body(endpoint: &str) -> Option<&'static str> { if std::env::var("IDX_USE_MOCK_PROVIDER").is_err() { return None; @@ -271,6 +283,7 @@ impl MsnClient { url: &str, request: &ScreenerRequest, ) -> Result { + #[cfg(feature = "testing")] if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { if let Some(err) = Self::mock_error() { return Err(err); diff --git a/crates/idx-core/src/api/msn/mod.rs b/crates/idx-core/src/api/msn/mod.rs index 2278e6a..37bf2a3 100644 --- a/crates/idx-core/src/api/msn/mod.rs +++ b/crates/idx-core/src/api/msn/mod.rs @@ -23,6 +23,7 @@ use map::{ parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment, }; +#[cfg(feature = "testing")] pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; pub struct MsnProvider { diff --git a/crates/idx-core/src/api/yahoo/mod.rs b/crates/idx-core/src/api/yahoo/mod.rs index ca8fd7c..387ae07 100644 --- a/crates/idx-core/src/api/yahoo/mod.rs +++ b/crates/idx-core/src/api/yahoo/mod.rs @@ -11,6 +11,7 @@ use client::YahooClient; use map::{parse_fundamentals, parse_quote}; use parse::parse_history_with_verbose; +#[cfg(feature = "testing")] pub(crate) use parse::{ parse_fundamentals_from_str, parse_history_from_str_with_verbose, parse_quote_from_str, }; diff --git a/crates/idx-core/src/api/yahoo/parse.rs b/crates/idx-core/src/api/yahoo/parse.rs index c5a7008..6f0e80e 100644 --- a/crates/idx-core/src/api/yahoo/parse.rs +++ b/crates/idx-core/src/api/yahoo/parse.rs @@ -1,9 +1,16 @@ -use crate::api::types::{Fundamentals, Ohlc, Quote}; +use crate::api::types::Ohlc; +#[cfg(any(test, feature = "testing"))] +use crate::api::types::{Fundamentals, Quote}; use crate::error::IdxError; -use super::map::{parse_fundamentals, parse_history, parse_quote}; -use super::raw_types::{ChartResponse, QuoteSummaryResponse}; +use super::map::parse_history; +#[cfg(any(test, feature = "testing"))] +use super::map::{parse_fundamentals, parse_quote}; +use super::raw_types::ChartResponse; +#[cfg(any(test, feature = "testing"))] +use super::raw_types::QuoteSummaryResponse; +#[cfg(any(test, feature = "testing"))] pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result { let chart: ChartResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; @@ -15,6 +22,7 @@ pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result_` (e.g., `test_parse_id_number_with_dots`) - **Coverage over counts:** replacing redundant unit assertions with stronger integration scenarios must retain meaningful error, boundary, and data-integrity coverage. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 56615f8..497872f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -94,8 +94,9 @@ Key decisions: - **Explicit configuration.** Core reads no environment variables. `IDX_*` env parsing, `config.toml` loading, and XDG resolution move to a `CoreConfig::load()` helper that frontends call; tests construct `CoreConfig` directly. -- **Mock provider behind a `testing` feature.** Today `MockProvider` ships in the - release binary and reads fixtures relative to the working directory. +- **Mock provider behind a `testing` feature.** Done: `MockProvider` and the + embedded MSN fixtures compile only with idx-core's `testing` feature, which + idx-cli enables as a dev-dependency. - **Typed inputs.** Screener filters, regions, statement and earnings filters become enums with `FromStr`, so validation is identical across frontends. diff --git a/docs/SMOKE.md b/docs/SMOKE.md index b8f97d3..6512288 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -10,7 +10,7 @@ offline with pinned tools: | `fmt`, `clippy` | formatting and lints (`--all-targets`) | | `test` | unit/module tests, `tests/cli.rs` CLI integration tests, and `tests/golden.rs` output parity | | `default`, `package` | release build and `cargo package` verification | -| `packaged-cli` | the wrapped Nix binary starts, emits JSON, and generates completions | +| `packaged-cli` | the wrapped Nix release binary emits JSON, generates completions, and returns a JSON `CACHEMISS` error with exit 3 offline | | `ownership-publisher`, `publisher-test` | packaged publisher entry point and its regression scenarios | | `install-script-sh`, `install-script-dash`, `shellcheck` | installer regressions and shell lint | diff --git a/flake.nix b/flake.nix index c83465c..2ede4f4 100644 --- a/flake.nix +++ b/flake.nix @@ -217,14 +217,17 @@ packaged-cli = pkgs.runCommand "idx-packaged-cli" { nativeBuildInputs = with pkgs; [ bash jq ]; } '' - # The mock provider reads fixtures relative to the working dir. - mkdir -p tests - cp -r ${./crates/idx-core/tests/fixtures} tests/fixtures - export HOME="$TMPDIR" + export HOME="$TMPDIR" XDG_CACHE_HOME="$TMPDIR/cache" XDG_CONFIG_HOME="$TMPDIR/config" ${idxPackage}/bin/idx -o json version | jq -e '.version | type == "string"' ${idxPackage}/bin/idx completions bash | bash -n - IDX_USE_MOCK_PROVIDER=1 ${idxPackage}/bin/idx -o json stocks quote BBCA \ - | jq -e '.[0].price | type == "number"' + # Release builds have no mock provider; an offline cache miss + # exercises the JSON error contract and exit-code classes. + set +e + ${idxPackage}/bin/idx --offline -o json stocks quote BBCA 2>err.json + status=$? + set -e + test "$status" -eq 3 + jq -e '.code == "CACHEMISS"' err.json touch "$out" ''; install-script-sh = installerCheck "sh"; From 86cb1b4b7c1c2b50c9b815d12bb1ddf29ef58f7f Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 09:41:31 +0000 Subject: [PATCH 07/14] refactor(core): move stocks fetching, caching, and analysis into idx-core Phase 3b of docs/DESIGN.md. `stocks` handlers are now argument parsing, one idx-core call, and rendering. - Idx (client.rs) is the entry point: Idx::new(CoreConfig) with market(), financial(), and analysis(). Live lookups return Fetched { data, source, fetched_at, stale } and take a CachePolicy (Default | Offline | Refresh, from the --offline/--no-cache flags). - One cached_fetch replaces the CLI's three cache flows; they differed only in whether a fresh entry is read via get or get_offline when offline, which yields the same data and diagnostics. Report-building errors (technical) still skip the stale-cache fallback. - market.rs: quotes, history (history_source is resolved first so the CLI's fallback note keeps its position), and a typed screener (ScreenFilter/ScreenRegion with the same error messages). - financial.rs: profile, statements, earnings, sentiment, insights, news, with StatementFilter/EarningsFilter. - analysis: TechnicalReport moves to analysis::report; Analysis offers technical, growth, valuation, risk, fundamental, and compare. - CoreConfig::load() owns provider/exchange/TTL settings from config.toml and IDX_*; the CLI's IdxConfig adds output and color. With two different invalid settings at once, the reported one may differ from before; every single-error message is unchanged. - Cache gains fresh/any/offline accessors returning fetch metadata. Unit tests moved with their code (core 157 -> 168, CLI 17 -> 7). All 143 goldens unchanged; live A/B against main: 16/16 stocks identical. Co-Authored-By: Claude Opus 5.5 --- crates/idx-cli/src/cli/stocks.rs | 1192 ++---------------------- crates/idx-cli/src/config.rs | 134 +-- crates/idx-cli/src/main.rs | 11 +- crates/idx-cli/src/output/mod.rs | 33 +- crates/idx-cli/src/output/table.rs | 2 +- crates/idx-core/src/analysis/mod.rs | 137 +++ crates/idx-core/src/analysis/report.rs | 153 +++ crates/idx-core/src/api/mod.rs | 21 +- crates/idx-core/src/cache.rs | 74 +- crates/idx-core/src/client.rs | 177 ++++ crates/idx-core/src/config.rs | 117 ++- crates/idx-core/src/financial.rs | 397 ++++++++ crates/idx-core/src/lib.rs | 5 + crates/idx-core/src/market.rs | 409 ++++++++ docs/ARCHITECTURE.md | 14 +- docs/DESIGN.md | 38 +- 16 files changed, 1613 insertions(+), 1301 deletions(-) create mode 100644 crates/idx-core/src/analysis/report.rs create mode 100644 crates/idx-core/src/client.rs create mode 100644 crates/idx-core/src/financial.rs create mode 100644 crates/idx-core/src/market.rs diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 8aaae85..21f14ab 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -1,55 +1,18 @@ -use std::cmp::Ordering; use std::io::Read; -use clap::{Args, Subcommand, ValueEnum}; -use serde::{Serialize, de::DeserializeOwned}; +use clap::{Args, Subcommand}; -use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind}; +use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, - render_financials, render_fundamental, render_growth, render_history, render_insights, - render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, - render_technical, render_valuation, + render_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::analysis::fundamental::{ - FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, - analyze_growth, analyze_risk, analyze_valuation, -}; -use idx_core::analysis::signals::{self, Signal, TechnicalSignal}; -use idx_core::analysis::technical; -use idx_core::api::types::{ - CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - NewsItem, Ohlc, Period, Quote, SentimentData, -}; -use idx_core::api::{MarketDataProvider, SelectedProvider, history_provider}; -use idx_core::cache::Cache; +use idx_core::api::types::{Interval, Period}; use idx_core::error::IdxError; -use idx_core::runtime; - -struct FundamentalCacheSpec { - bucket: String, - ttl_secs: u64, -} - -struct CacheFetchSpec<'a> { - bucket: &'a str, - key: &'a str, - ttl_secs: u64, - subject: &'a str, -} - -const SCREENER_FILTERS: &[&str] = &[ - "top-performers", - "worst-performers", - "high-dividend", - "low-pe", - "52w-high", - "52w-low", - "high-volume", - "large-cap", -]; - -const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"]; +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. @@ -79,22 +42,11 @@ fn expand_symbols(args: &[String]) -> Result, IdxError> { Ok(symbols) } -fn cache_bucket(provider: ProviderKind, key: &str) -> String { - format!("{}-{key}", provider.as_str()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -enum FinancialStatementKind { - Income, - Balance, - Cashflow, -} - #[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, + statement: Vec, } #[derive(Debug, Clone, Copy, Args, Default)] @@ -113,35 +65,17 @@ pub struct EarningsFilterArgs { quarterly: bool, } -impl EarningsFilterArgs { - fn include_forecast(self) -> bool { - self.forecast || !self.history - } - - fn include_history(self) -> bool { - self.history || !self.forecast - } - - fn includes_period(self, period_type: &str) -> bool { - if !self.annual && !self.quarterly { - return true; - } - - match classify_earnings_period(period_type) { - EarningsPeriodKind::Annual => self.annual || !self.quarterly, - EarningsPeriodKind::Quarterly => self.quarterly || !self.annual, - EarningsPeriodKind::Unknown => false, +impl From for EarningsFilter { + fn from(args: EarningsFilterArgs) -> Self { + Self { + forecast: args.forecast, + history: args.history, + annual: args.annual, + quarterly: args.quarterly, } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EarningsPeriodKind { - Annual, - Quarterly, - Unknown, -} - #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] pub struct StocksCmd { @@ -269,1085 +203,131 @@ pub enum StocksSubcommand { pub fn handle( cmd: &StocksCmd, config: &IdxConfig, - provider: &SelectedProvider, offline: bool, no_cache: bool, - verbose: bool, ) -> Result<(), IdxError> { - if offline && no_cache { - return Err(IdxError::InvalidInput( - "cannot combine --offline with --no-cache".to_string(), - )); - } - - let cache = Cache::new()?; + 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 quote_bucket = cache_bucket(provider.kind(), "quote"); - let mut quotes = Vec::new(); - for sym in expand_symbols(symbols)? { - let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; - if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { - quotes.push(q); - continue; - } - if offline { - let stale = cache - .get_offline("e_bucket, &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?; - quotes.push(stale); - continue; - } - - match provider.market().quote(&resolved) { - Ok(q) => { - if !no_cache { - cache.put("e_bucket, &resolved, &q, config.quote_ttl)?; - } - quotes.push(q); - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale("e_bucket, &resolved)? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - quotes.push(stale); - continue; - } - return Err(err); - } - } - } - render_quotes("es, &config.output, config.no_color) + 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: history_provider_override, + history_provider, } => { - let history_mode = history_provider_override.unwrap_or(config.history_provider); - let (history_source, hist_provider) = - history_provider(provider.kind(), history_mode, verbose)?; - if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != provider.kind() - && !config.output.is_json() - { - runtime::info(format!( - "history provider fallback active ({} -> {})", - provider.kind().as_str(), - history_source.as_str() - )); - } - let history_bucket = format!("{}-history", history_source.as_str()); - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let key = format!("{}-{}", period.as_str(), interval.as_str()); - if !no_cache - && let Some(history) = cache.get::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - { - return render_history(&resolved, &history, &config.output); - } - if offline { - let stale = cache - .get_offline::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - .ok_or_else(|| { - IdxError::CacheMiss(format!("{history_bucket}/{resolved}-{key}")) - })?; - return render_history(&resolved, &stale, &config.output); - } - - match hist_provider.history(&resolved, period, interval) { - Ok(history) => { - if !no_cache { - cache.put( - &history_bucket, - &format!("{resolved}-{key}"), - &history, - config.quote_ttl, - )?; - } - render_history(&resolved, &history, &config.output) - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return render_history(&resolved, &stale, &config.output); - } - Err(err) - } - } + 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: history_provider_override, + history_provider, } => { - let history_mode = history_provider_override.unwrap_or(config.history_provider); - let (history_source, hist_provider) = - history_provider(provider.kind(), history_mode, verbose)?; - if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != provider.kind() - && !config.output.is_json() - { - runtime::info(format!( - "history provider fallback active ({} -> {})", - provider.kind().as_str(), - history_source.as_str() - )); - } - let technical_bucket = format!("{}-technical", history_source.as_str()); - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - if !no_cache - && let Some(report) = cache.get::(&technical_bucket, &resolved)? - { - return render_technical(&report, &config.output, config.no_color); - } - if offline { - let stale = cache - .get_offline::(&technical_bucket, &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?; - return render_technical(&stale, &config.output, config.no_color); - } - - match hist_provider.history(&resolved, &Period::OneYear, &Interval::Day) { - Ok(history) => { - let report = build_technical_report(&resolved, &history)?; - if !no_cache { - cache.put(&technical_bucket, &resolved, &report, config.quote_ttl)?; - } - render_technical(&report, &config.output, config.no_color) - } - Err(err) => { - if !no_cache - && let Some(stale) = - cache.get_stale::(&technical_bucket, &resolved)? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return render_technical(&stale, &config.output, config.no_color); - } - Err(err) - } - } + 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 resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: GrowthReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "growth"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_growth(fundamentals), - )?; - render_growth(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().growth(symbol, policy)?; + render_growth(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Valuation { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: ValuationReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "valuation"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_valuation(fundamentals), - )?; - render_valuation(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().valuation(symbol, policy)?; + render_valuation(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Risk { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: RiskReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "risk"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_risk(fundamentals), - )?; - render_risk(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().risk(symbol, policy)?; + render_risk(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Fundamental { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: FundamentalReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "fundamental"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - analyze_fundamental, - )?; - render_fundamental(&report, &config.output, config.no_color) + let report = idx.analysis().fundamental(symbol, policy)?; + render_fundamental(&report.data, format, no_color) } StocksSubcommand::Profile { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "profile-v2"); - let profile_provider = provider.profile_provider(&resolved)?; - let profile: CompanyProfile = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || profile_provider.profile(&resolved), - )?; - render_profile(&profile, &config.output) + let profile = idx.financial().profile(symbol, policy)?; + render_profile(&profile.data, format) } StocksSubcommand::Financials { symbol, filters } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "financials"); - let financials_provider = provider.financials_provider(&resolved)?; - let mut financials: FinancialStatements = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || financials_provider.financials(&resolved), - )?; - if financials.instrument.symbol.trim().is_empty() - || !financials.instrument.symbol.contains('.') - { - financials.instrument.symbol = resolved.clone(); - } - let filtered = filter_financial_statements(&financials, filters); - render_financials(&filtered, &config.output) + 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 resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "earnings"); - let earnings_provider = provider.earnings_provider(&resolved)?; - let mut earnings: EarningsReport = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || earnings_provider.earnings(&resolved), - )?; - if earnings.symbol.is_empty() { - earnings.symbol = resolved.clone(); - } - let filtered = filter_earnings_report(&earnings, filters); - render_earnings(&filtered, &config.output) + let earnings = + idx.financial() + .earnings(symbol, EarningsFilter::from(*filters), policy)?; + render_earnings(&earnings.data, format) } StocksSubcommand::Sentiment { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "sentiment"); - let sentiment_provider = provider.sentiment_provider(&resolved)?; - let sentiment: SentimentData = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || sentiment_provider.sentiment(&resolved), - )?; - render_sentiment(&sentiment, &config.output) + let sentiment = idx.financial().sentiment(symbol, policy)?; + render_sentiment(&sentiment.data, format) } StocksSubcommand::Insights { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "insights-v2"); - let insights_provider = provider.insights_provider(&resolved)?; - let mut insights: InsightData = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || insights_provider.insights(&resolved), - )?; - if insights.symbol.is_empty() { - insights.symbol = resolved.clone(); - } - render_insights(&insights, &config.output) + let insights = idx.financial().insights(symbol, policy)?; + render_insights(&insights.data, format) } StocksSubcommand::News { symbol, limit } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "news"); - let key = format!("{resolved}-{limit}"); - let news_provider = provider.news_provider(&resolved)?; - let mut news: Vec = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &key, - ttl_secs: config.quote_ttl, - subject: &resolved, - }, - offline, - no_cache, - || news_provider.news(&resolved, *limit), - )?; - for item in &mut news { - if item.symbol.is_empty() { - item.symbol = resolved.clone(); - } - } - render_news(&news, &config.output) + let news = idx.financial().news(symbol, *limit, policy)?; + render_news(&news.data, format) } StocksSubcommand::Screen { filter, region, limit, } => { - let filter_key = screener_filter_key(filter)?; - let region_key = screener_region_key(region)?; - // Filters re-sorted client-side need the whole MSN list: MSN orders - // by 1-year return, so truncating first and then sorting by day - // change made `--limit 3` return different stocks than `--limit 25`. - let needs_full_fetch = matches!( - filter.as_str(), - "high-volume" | "large-cap" | "top-performers" | "worst-performers" - ); - let fetch_limit = (!needs_full_fetch).then_some(*limit); - let bucket = cache_bucket(provider.kind(), "screen"); - let key = match fetch_limit { - Some(limit) => format!("{filter}:{region}:{limit}"), - None => format!("{filter}:{region}:all"), + let query = ScreenQuery { + filter: filter.parse()?, + region: region.parse()?, + limit: *limit, }; - let screener_provider = provider.screener_provider("screen")?; - let mut quotes: Vec = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &key, - ttl_secs: config.quote_ttl, - subject: "screen", - }, - offline, - no_cache, - || screener_provider.screener(filter_key, region_key, fetch_limit), - )?; - sort_screener_quotes(&mut quotes, filter); - quotes.truncate(*limit); - render_screener("es, &config.output, config.no_color) + let quotes = idx.market().screen(&query, policy)?; + render_screener("es.data, format, no_color) } StocksSubcommand::Compare { symbols } => { - let mut reports: Vec = Vec::new(); - let mut failures = Vec::new(); - - for sym in expand_symbols(symbols)? { - let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; - match fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "fundamental"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - analyze_fundamental, - ) { - Ok(report) => reports.push(report), - Err(err) => failures.push((resolved, err)), - } - } - - if reports.is_empty() { - return Err(failures.pop().map(|(_, err)| err).unwrap_or_else(|| { - IdxError::CacheMiss("fundamental/no symbols could be compared".to_string()) - })); - } - + 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 failures { + for (resolved, err) in comparison.failures { runtime::warn(format!( "failed to fetch fundamentals for {resolved}: {err}" )); } - - render_compare(&reports, &config.output, config.no_color) + let reports: Vec<_> = comparison.reports.into_iter().map(|r| r.data).collect(); + render_compare(&reports, format, no_color) } } } -fn fetch_with_cache( - cache: &Cache, - cache_spec: CacheFetchSpec<'_>, - offline: bool, - no_cache: bool, - fetch_fn: F, -) -> Result -where - T: Serialize + DeserializeOwned, - F: FnOnce() -> Result, -{ - if !offline - && !no_cache - && let Some(cached) = cache.get::(cache_spec.bucket, cache_spec.key)? - { - return Ok(cached); - } - - if offline { - if no_cache { - return Err(IdxError::InvalidInput( - "cannot combine --offline with --no-cache".to_string(), - )); - } - - return cache - .get_offline::(cache_spec.bucket, cache_spec.key)? - .ok_or_else(|| { - IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key)) - }); - } - - match fetch_fn() { - Ok(data) => { - if !no_cache { - cache.put( - cache_spec.bucket, - cache_spec.key, - &data, - cache_spec.ttl_secs, - )?; - } - Ok(data) - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale::(cache_spec.bucket, cache_spec.key)? - { - runtime::warn(format!( - "network failed, serving stale cache for {}", - cache_spec.subject - )); - return Ok(stale); - } - Err(err) - } - } +fn resolve(symbol: &str, config: &IdxConfig) -> Result { + idx_core::api::resolve_symbol(symbol, &config.core.exchange) } -fn fetch_fundamental_analysis_report( - cache: &Cache, - provider: &dyn MarketDataProvider, - resolved: &str, - cache_spec: FundamentalCacheSpec, - offline: bool, - no_cache: bool, - analyzer: F, -) -> Result -where - T: Serialize + DeserializeOwned, - F: FnOnce(&str, &Fundamentals) -> T, -{ - if !no_cache && let Some(report) = cache.get::(&cache_spec.bucket, resolved)? { - return Ok(report); - } - - if offline { - return cache - .get_offline::(&cache_spec.bucket, resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket))); - } - - match provider.fundamentals(resolved) { - Ok(fundamentals) => { - let report = analyzer(resolved, &fundamentals); - if !no_cache { - cache.put(&cache_spec.bucket, resolved, &report, cache_spec.ttl_secs)?; - } - Ok(report) - } - Err(err) => { - if !no_cache && let Some(stale) = cache.get_stale::(&cache_spec.bucket, resolved)? { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return Ok(stale); - } - Err(err) - } - } -} - -fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result { - let latest = history - .last() - .ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?; - let closes: Vec = history.iter().map(|item| item.close as f64).collect(); - let volumes: Vec = history.iter().map(|item| item.volume as f64).collect(); - - let sma20 = last_value(&technical::sma(&closes, 20)); - let sma50 = last_value(&technical::sma(&closes, 50)); - let sma200 = last_value(&technical::sma(&closes, 200)); - let rsi14 = last_value(&technical::rsi(&closes, 14)); - let macd = technical::macd(&closes, 12, 26, 9); - let macd_line = last_value(&macd.macd_line); - let signal_line = last_value(&macd.signal_line); - let histogram = last_value(&macd.histogram); - let previous_histogram = previous_value(&macd.histogram); - let average_volume20 = average_last(&volumes, 20); - let volume_ratio20 = technical::volume_ratio(&volumes, 20); - - let rsi_signal = rsi14.map_or(Signal::Neutral, signals::interpret_rsi); - let macd_signal = histogram - .map(|value| signals::interpret_macd(value, previous_histogram)) - .unwrap_or(Signal::Neutral); - let trend_signal = signals::interpret_trend(latest.close as f64, sma50, sma200); - let overall = signals::overall_signal(rsi_signal, macd_signal, trend_signal); - - Ok(TechnicalReport { - symbol: symbol.to_string(), - as_of: latest.date, - current_price: latest.close, - sma20, - sma50, - sma200, - rsi14, - macd: MacdSnapshot { - line: macd_line, - signal: signal_line, - histogram, - }, - volume: VolumeSnapshot { - current: latest.volume, - average20: average_volume20, - ratio20: volume_ratio20, - }, - signals: TechnicalSignal { - rsi: rsi_signal, - macd: macd_signal, - trend: trend_signal, - overall, - }, - }) -} - -fn last_value(values: &[Option]) -> Option { - values.iter().rev().find_map(|value| *value) -} - -fn previous_value(values: &[Option]) -> Option { - let mut seen_latest = false; - for value in values.iter().rev() { - if value.is_some() { - if seen_latest { - return *value; - } - seen_latest = true; - } - } - None -} - -fn average_last(values: &[f64], period: usize) -> Option { - if period == 0 || values.len() < period { - return None; - } - - let start = values.len() - period; - Some(values[start..].iter().sum::() / period as f64) -} - -fn filter_financial_statements( - financials: &FinancialStatements, - filters: &FinancialsFilterArgs, -) -> FinancialStatements { - let includes = |kind: FinancialStatementKind| { - filters.statement.is_empty() || filters.statement.contains(&kind) - }; - - FinancialStatements { - instrument: financials.instrument.clone(), - balance_sheet: includes(FinancialStatementKind::Balance) - .then(|| financials.balance_sheet.clone()) - .flatten(), - cash_flow: includes(FinancialStatementKind::Cashflow) - .then(|| financials.cash_flow.clone()) - .flatten(), - income_statement: includes(FinancialStatementKind::Income) - .then(|| financials.income_statement.clone()) - .flatten(), - } -} - -fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport { - let filter_rows = |rows: &[idx_core::api::types::EarningsData]| { - rows.iter() - .filter(|row| filters.includes_period(&row.period_type)) - .cloned() - .collect() - }; - - EarningsReport { - symbol: report.symbol.clone(), - eps_last_year: report.eps_last_year, - revenue_last_year: report.revenue_last_year, - forecast: if filters.include_forecast() { - filter_rows(&report.forecast) - } else { - Vec::new() - }, - history: if filters.include_history() { - filter_rows(&report.history) - } else { - Vec::new() - }, - } -} - -fn classify_earnings_period(period_type: &str) -> EarningsPeriodKind { - let trimmed = period_type.trim(); - if trimmed.is_empty() { - return EarningsPeriodKind::Unknown; - } - - let upper = trimmed.to_ascii_uppercase(); - if upper.starts_with('Q') { - return EarningsPeriodKind::Quarterly; - } - if upper.starts_with("FY") || (upper.len() == 4 && upper.chars().all(|ch| ch.is_ascii_digit())) - { - return EarningsPeriodKind::Annual; - } - - EarningsPeriodKind::Unknown -} - -fn screener_filter_key(filter: &str) -> Result<&'static str, IdxError> { - match filter { - "top-performers" => Ok("st_list_topperfs"), - "worst-performers" => Ok("st_list_poorperfs"), - "high-dividend" => Ok("st_list_highdividend"), - "low-pe" => Ok("st_list_lowpe"), - "52w-high" => Ok("st_list_52wkhi"), - "52w-low" => Ok("st_list_52wklow"), - "high-volume" => Ok("st_list_topperfs"), - "large-cap" => Ok("st_list_topperfs"), - _ => Err(IdxError::InvalidInput(format!( - "invalid screener filter '{filter}' (expected one of: {})", - SCREENER_FILTERS.join(", ") - ))), - } -} - -fn screener_region_key(region: &str) -> Result<&'static str, IdxError> { - match region { - "id" => Ok("st_reg_id"), - "us" => Ok("st_reg_us"), - "sg" => Ok("st_reg_sg"), - "hk" => Ok("st_reg_hk"), - "jp" => Ok("st_reg_jp"), - _ => Err(IdxError::InvalidInput(format!( - "invalid screener region '{region}' (expected one of: {})", - SCREENER_REGIONS.join(", ") - ))), - } -} - -fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) { - match filter { - "top-performers" => { - quotes.sort_by(|a, b| { - let pa = a.change_pct.unwrap_or(f64::MIN); - let pb = b.change_pct.unwrap_or(f64::MIN); - pb.partial_cmp(&pa).unwrap_or(Ordering::Equal) - }); - } - "worst-performers" => { - quotes.sort_by(|a, b| { - let pa = a.change_pct.unwrap_or(f64::MAX); - let pb = b.change_pct.unwrap_or(f64::MAX); - pa.partial_cmp(&pb).unwrap_or(Ordering::Equal) - }); - } - "52w-high" => { - quotes.sort_by(|a, b| { - let pa = a.week52_position.unwrap_or(f64::MIN); - let pb = b.week52_position.unwrap_or(f64::MIN); - pb.partial_cmp(&pa).unwrap_or(Ordering::Equal) - }); - } - "52w-low" => { - quotes.sort_by(|a, b| { - let pa = a.week52_position.unwrap_or(f64::MAX); - let pb = b.week52_position.unwrap_or(f64::MAX); - pa.partial_cmp(&pb).unwrap_or(Ordering::Equal) - }); - } - "high-volume" => { - quotes.sort_by_key(|quote| std::cmp::Reverse(quote.volume)); - } - "large-cap" => { - quotes.sort_by_key(|quote| std::cmp::Reverse(quote.market_cap.unwrap_or(0))); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use chrono::{Days, NaiveDate}; - - use super::{ - EarningsFilterArgs, EarningsPeriodKind, FinancialStatementKind, FinancialsFilterArgs, - build_technical_report, classify_earnings_period, filter_earnings_report, - filter_financial_statements, - }; - use idx_core::analysis::signals::Signal; - use idx_core::api::types::{ - EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection, - }; - use idx_core::error::IdxError; - - #[test] - fn technical_report_uses_latest_values() { - let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); - let history: Vec = (0..60) - .map(|idx| Ohlc { - date: start - .checked_add_days(Days::new(idx as u64)) - .expect("valid offset"), - open: 100 + idx as i64, - high: 101 + idx as i64, - low: 99 + idx as i64, - close: 100 + idx as i64, - volume: 1_000 + idx as u64 * 10, - }) - .collect(); - - let report = build_technical_report("BBCA.JK", &history).expect("report should build"); - - assert_eq!(report.symbol, "BBCA.JK"); - assert_eq!(report.current_price, 159); - assert!(report.sma20.is_some()); - assert!(report.sma50.is_some()); - assert_eq!(report.sma200, None); - assert!(report.rsi14.is_some()); - assert!(report.volume.ratio20.is_some()); - assert_eq!(report.signals.trend, Signal::Neutral); - } - - #[test] - fn sort_screener_top_performers_descending() { - use idx_core::api::types::Quote; - - let mut quotes = vec![ - Quote { - symbol: "A".into(), - price: 100, - change: Some(1), - change_pct: Some(1.0), - volume: Some(100), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "B".into(), - price: 200, - change: Some(10), - change_pct: Some(5.0), - volume: Some(200), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "C".into(), - price: 150, - change: Some(5), - change_pct: Some(3.0), - volume: Some(150), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - ]; - - super::sort_screener_quotes(&mut quotes, "top-performers"); - let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); - assert_eq!(pcts, vec![5.0, 3.0, 1.0]); - } - - #[test] - fn sort_screener_worst_performers_ascending() { - use idx_core::api::types::Quote; - - let mut quotes = vec![ - Quote { - symbol: "A".into(), - price: 100, - change: Some(1), - change_pct: Some(1.0), - volume: Some(100), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "B".into(), - price: 200, - change: Some(-10), - change_pct: Some(-5.0), - volume: Some(200), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "C".into(), - price: 150, - change: Some(-3), - change_pct: Some(-2.0), - volume: Some(150), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - ]; - - super::sort_screener_quotes(&mut quotes, "worst-performers"); - let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); - assert_eq!(pcts, vec![-5.0, -2.0, 1.0]); - } - - #[test] - fn sort_screener_puts_unknown_change_last_both_ways() { - let mut quotes = vec![ - sample_quote("U", None), - sample_quote("A", Some(1.0)), - sample_quote("B", Some(-5.0)), - ]; - super::sort_screener_quotes(&mut quotes, "worst-performers"); - assert_eq!(quotes.last().unwrap().symbol, "U"); - super::sort_screener_quotes(&mut quotes, "top-performers"); - assert_eq!(quotes.last().unwrap().symbol, "U"); - } - - fn sample_quote(symbol: &str, change_pct: Option) -> idx_core::api::types::Quote { - idx_core::api::types::Quote { - symbol: symbol.into(), - price: 100, - change: None, - change_pct, - volume: None, - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - } - } - - #[test] - fn invalid_screener_filter_returns_error() { - let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail"); - assert!(matches!(err, IdxError::InvalidInput(_))); - assert!(err.to_string().contains("invalid screener filter")); - } - - #[test] - fn invalid_screener_region_returns_error() { - let err = super::screener_region_key("eu").expect_err("invalid region should fail"); - assert!(matches!(err, IdxError::InvalidInput(_))); - assert!(err.to_string().contains("invalid screener region")); - } - - #[test] - fn filters_financial_statements_to_requested_sections() { - let sample = FinancialStatements { - instrument: InstrumentInfo { - id: "123".into(), - symbol: "BBCA.JK".into(), - name: "BCA".into(), - }, - balance_sheet: Some(StatementSection { - values: BTreeMap::from([("assets".into(), 1.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - cash_flow: Some(StatementSection { - values: BTreeMap::from([("cash".into(), 2.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - income_statement: Some(StatementSection { - values: BTreeMap::from([("income".into(), 3.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - }; - - let filtered = filter_financial_statements( - &sample, - &FinancialsFilterArgs { - statement: vec![FinancialStatementKind::Cashflow], - }, - ); - - assert!(filtered.cash_flow.is_some()); - assert!(filtered.income_statement.is_none()); - assert!(filtered.balance_sheet.is_none()); - assert_eq!(filtered.instrument.symbol, "BBCA.JK"); - } - - #[test] - fn filters_earnings_by_scope_and_period() { - let report = EarningsReport { - symbol: "BBCA.JK".into(), - eps_last_year: 1_200.0, - revenue_last_year: 100_000_000_000.0, - forecast: vec![ - EarningsData { - eps_actual: None, - eps_forecast: Some(1_300.0), - eps_surprise: None, - eps_surprise_pct: None, - revenue_actual: None, - revenue_forecast: Some(110_000_000_000.0), - revenue_surprise: None, - earning_release_date: Some("2026-03-15".into()), - period_type: "2026".into(), - }, - EarningsData { - eps_actual: None, - eps_forecast: Some(330.0), - eps_surprise: None, - eps_surprise_pct: None, - revenue_actual: None, - revenue_forecast: Some(28_000_000_000.0), - revenue_surprise: None, - earning_release_date: Some("2026-04-20".into()), - period_type: "Q12026".into(), - }, - ], - history: vec![EarningsData { - eps_actual: Some(1_250.0), - eps_forecast: None, - eps_surprise: Some(20.0), - eps_surprise_pct: Some(1.6), - revenue_actual: Some(105_000_000_000.0), - revenue_forecast: None, - revenue_surprise: Some(500_000_000.0), - earning_release_date: Some("2025-03-15".into()), - period_type: "2025".into(), - }], - }; - - let filtered = filter_earnings_report( - &report, - &EarningsFilterArgs { - forecast: true, - history: false, - annual: true, - quarterly: false, - }, - ); - - assert_eq!(filtered.symbol, "BBCA.JK"); - assert!(filtered.history.is_empty()); - assert_eq!(filtered.forecast.len(), 1); - assert_eq!(filtered.forecast[0].period_type, "2026"); - } - - #[test] - fn classifies_earnings_periods() { - assert_eq!(classify_earnings_period("2025"), EarningsPeriodKind::Annual); - assert_eq!( - classify_earnings_period("FY2026"), - EarningsPeriodKind::Annual - ); - assert_eq!( - classify_earnings_period("Q12026"), - EarningsPeriodKind::Quarterly - ); - assert_eq!(classify_earnings_period(""), EarningsPeriodKind::Unknown); +/// 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() + )); } } diff --git a/crates/idx-cli/src/config.rs b/crates/idx-cli/src/config.rs index c41c1d1..ba85b4c 100644 --- a/crates/idx-cli/src/config.rs +++ b/crates/idx-cli/src/config.rs @@ -9,125 +9,69 @@ 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 provider: ProviderKind, - pub history_provider: HistoryProviderKind, - pub exchange: String, + pub core: CoreConfig, pub output: OutputFormat, pub no_color: bool, - pub quote_ttl: u64, - pub fundamental_ttl: u64, } #[derive(Debug, Deserialize, Default)] struct FileConfig { general: Option, - cache: Option, } #[derive(Debug, Deserialize, Default)] struct FileGeneral { - provider: Option, - history_provider: Option, - exchange: Option, output: Option, color: Option, } -#[derive(Debug, Deserialize, Default)] -struct FileCache { - quote_ttl: Option, - fundamental_ttl: Option, -} - -impl Default for IdxConfig { - fn default() -> Self { - Self { - provider: ProviderKind::Msn, - history_provider: HistoryProviderKind::Auto, - exchange: "JK".to_string(), - output: OutputSetting::Auto.resolve(stdout_is_terminal()), - no_color: false, - quote_ttl: 300, - fundamental_ttl: 3600, - } - } -} - impl IdxConfig { pub fn load_with_cli(cli: &Cli) -> Result { - let mut cfg = Self::load()?; + let mut core = CoreConfig::load()?; + core.verbose = cli.verbose > 0; + let mut output = OutputSetting::Auto.resolve(stdout_is_terminal()); + let mut no_color = false; - if let Ok(provider) = std::env::var("IDX_PROVIDER") { - cfg.provider = ProviderKind::parse(&provider)?; - } - if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") { - cfg.history_provider = HistoryProviderKind::parse(&history_provider)?; - } - if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { - cfg.exchange = exchange; - } - if let Ok(output) = std::env::var("IDX_OUTPUT") { - let setting = OutputSetting::parse(&output).ok_or_else(|| { - IdxError::ConfigError(format!( - "invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'" - )) - })?; - cfg.output = setting.resolve(stdout_is_terminal()); - } - if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { - cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true"); - } - if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? { - cfg.quote_ttl = parsed; - } - if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? { - cfg.fundamental_ttl = parsed; - } - - if let Some(output) = cli.output { - cfg.output = output.resolve(stdout_is_terminal()); - } - cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal(); - - Ok(cfg) - } - - pub fn load() -> Result { - let mut cfg = Self::default(); let path = config_path()?; if path.exists() { let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; let parsed: FileConfig = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; if let Some(general) = parsed.general { - if let Some(provider) = general.provider { - cfg.provider = provider; - } - if let Some(history_provider) = general.history_provider { - cfg.history_provider = history_provider; - } - if let Some(exchange) = general.exchange { - cfg.exchange = exchange; - } - if let Some(output) = general.output { - cfg.output = output.resolve(stdout_is_terminal()); + if let Some(setting) = general.output { + output = setting.resolve(stdout_is_terminal()); } if let Some(color) = general.color { - cfg.no_color = !color; - } - } - if let Some(cache) = parsed.cache { - if let Some(v) = cache.quote_ttl { - cfg.quote_ttl = v; - } - if let Some(v) = cache.fundamental_ttl { - cfg.fundamental_ttl = v; + no_color = !color; } } } - Ok(cfg) + + 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, + }) } } @@ -135,20 +79,6 @@ pub fn stdout_is_terminal() -> bool { std::io::stdout().is_terminal() } -#[cfg(test)] -mod tests { - use super::IdxConfig; - - #[test] - fn default_values_are_sane() { - let cfg = IdxConfig::default(); - assert_eq!(cfg.provider, super::ProviderKind::Msn); - assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto); - assert_eq!(cfg.exchange, "JK"); - assert_eq!(cfg.quote_ttl, 300); - } -} - #[cfg(test)] mod output_values_tests { use clap::ValueEnum; diff --git a/crates/idx-cli/src/main.rs b/crates/idx-cli/src/main.rs index 4c75dd0..91f75a1 100644 --- a/crates/idx-cli/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -11,7 +11,6 @@ use clap_complete::{generate, shells}; use crate::cli::{Cli, Commands}; use crate::config::IdxConfig; use crate::output::{OutputFormat, OutputSetting, emit_error}; -use idx_core::api::default_provider; use idx_core::error::IdxError; fn main() { @@ -53,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); } diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 5c43bda..212a104 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -4,14 +4,13 @@ pub mod table; use std::io::IsTerminal; -use chrono::NaiveDate; use clap::ValueEnum; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; -use idx_core::analysis::signals::TechnicalSignal; +use idx_core::analysis::report::TechnicalReport; use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, @@ -62,34 +61,6 @@ impl OutputFormat { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TechnicalReport { - pub symbol: String, - pub as_of: NaiveDate, - pub current_price: i64, - pub sma20: Option, - pub sma50: Option, - pub sma200: Option, - pub rsi14: Option, - pub macd: MacdSnapshot, - pub volume: VolumeSnapshot, - pub signals: TechnicalSignal, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MacdSnapshot { - pub line: Option, - pub signal: Option, - pub histogram: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VolumeSnapshot { - pub current: u64, - pub average20: Option, - pub ratio20: Option, -} - pub fn render_quotes( quotes: &[Quote], format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index f17f8d5..cfa3a2e 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1,10 +1,10 @@ use crate::output::style::Paint; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; -use crate::output::TechnicalReport; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; +use idx_core::analysis::report::TechnicalReport; use idx_core::analysis::signals::Signal; use idx_core::api::types::{ CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 612ff07..87c3aea 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -1,3 +1,140 @@ pub mod fundamental; +pub mod report; pub mod signals; pub mod technical; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::api::history_provider; +use crate::api::types::{Fundamentals, Interval, Period}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use crate::market::HistorySource; +use fundamental::{ + FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, + analyze_growth, analyze_risk, analyze_valuation, +}; +use report::{TechnicalReport, build_technical_report}; + +/// Analysis reports. Reports are cached as computed, not their inputs. +pub struct Analysis<'a> { + pub(crate) idx: &'a Idx, +} + +/// Comparison results; symbols that failed are listed with their error. +pub struct Comparison { + pub reports: Vec>, + pub failures: Vec<(String, IdxError)>, +} + +impl Analysis<'_> { + /// Technical analysis over one year of daily history from `source` + /// (see `Market::history_source`). + pub fn technical( + &self, + symbol: &str, + source: HistorySource, + policy: CachePolicy, + ) -> Result, IdxError> { + let idx = self.idx; + let (provider_kind, provider) = + history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?; + let bucket = format!("{}-technical", provider_kind.as_str()); + let resolved = idx.resolve(symbol)?; + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: provider_kind.as_str(), + }, + policy, + || provider.history(&resolved, &Period::OneYear, &Interval::Day), + |history| build_technical_report(&resolved, &history), + ) + } + + pub fn growth( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + self.fundamental_report(symbol, "growth", policy, |_, f| analyze_growth(f)) + } + + pub fn valuation( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + self.fundamental_report(symbol, "valuation", policy, |_, f| analyze_valuation(f)) + } + + pub fn risk(&self, symbol: &str, policy: CachePolicy) -> Result, IdxError> { + self.fundamental_report(symbol, "risk", policy, |_, f| analyze_risk(f)) + } + + pub fn fundamental( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + self.fundamental_report(symbol, "fundamental", policy, analyze_fundamental) + } + + /// Fundamental reports for each symbol. Fails only if every symbol fails + /// (with the last error) or a symbol cannot be resolved. + pub fn compare>( + &self, + symbols: &[S], + policy: CachePolicy, + ) -> Result { + let mut reports = Vec::new(); + let mut failures = Vec::new(); + for symbol in symbols { + let resolved = self.idx.resolve(symbol.as_ref())?; + match self.fundamental(&resolved, policy) { + Ok(report) => reports.push(report), + Err(err) => failures.push((resolved, err)), + } + } + + if reports.is_empty() { + return Err(failures.pop().map(|(_, err)| err).unwrap_or_else(|| { + IdxError::CacheMiss("fundamental/no symbols could be compared".to_string()) + })); + } + Ok(Comparison { reports, failures }) + } + + fn fundamental_report( + &self, + symbol: &str, + bucket: &str, + policy: CachePolicy, + analyzer: impl FnOnce(&str, &Fundamentals) -> T, + ) -> Result, IdxError> + where + T: Serialize + DeserializeOwned, + { + let idx = self.idx; + let resolved = idx.resolve(symbol)?; + let bucket = idx.bucket(bucket); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.fundamental_ttl, + subject: &resolved, + source: idx.provider.kind().as_str(), + }, + policy, + || idx.provider.market().fundamentals(&resolved), + |fundamentals| Ok(analyzer(&resolved, &fundamentals)), + ) + } +} diff --git a/crates/idx-core/src/analysis/report.rs b/crates/idx-core/src/analysis/report.rs new file mode 100644 index 0000000..951f79c --- /dev/null +++ b/crates/idx-core/src/analysis/report.rs @@ -0,0 +1,153 @@ +//! Technical analysis report built from daily history. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use super::signals::{self, Signal, TechnicalSignal}; +use super::technical; +use crate::api::types::Ohlc; +use crate::error::IdxError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TechnicalReport { + pub symbol: String, + pub as_of: NaiveDate, + pub current_price: i64, + pub sma20: Option, + pub sma50: Option, + pub sma200: Option, + pub rsi14: Option, + pub macd: MacdSnapshot, + pub volume: VolumeSnapshot, + pub signals: TechnicalSignal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MacdSnapshot { + pub line: Option, + pub signal: Option, + pub histogram: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeSnapshot { + pub current: u64, + pub average20: Option, + pub ratio20: Option, +} + +pub fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result { + let latest = history + .last() + .ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?; + let closes: Vec = history.iter().map(|item| item.close as f64).collect(); + let volumes: Vec = history.iter().map(|item| item.volume as f64).collect(); + + let sma20 = last_value(&technical::sma(&closes, 20)); + let sma50 = last_value(&technical::sma(&closes, 50)); + let sma200 = last_value(&technical::sma(&closes, 200)); + let rsi14 = last_value(&technical::rsi(&closes, 14)); + let macd = technical::macd(&closes, 12, 26, 9); + let macd_line = last_value(&macd.macd_line); + let signal_line = last_value(&macd.signal_line); + let histogram = last_value(&macd.histogram); + let previous_histogram = previous_value(&macd.histogram); + let average_volume20 = average_last(&volumes, 20); + let volume_ratio20 = technical::volume_ratio(&volumes, 20); + + let rsi_signal = rsi14.map_or(Signal::Neutral, signals::interpret_rsi); + let macd_signal = histogram + .map(|value| signals::interpret_macd(value, previous_histogram)) + .unwrap_or(Signal::Neutral); + let trend_signal = signals::interpret_trend(latest.close as f64, sma50, sma200); + let overall = signals::overall_signal(rsi_signal, macd_signal, trend_signal); + + Ok(TechnicalReport { + symbol: symbol.to_string(), + as_of: latest.date, + current_price: latest.close, + sma20, + sma50, + sma200, + rsi14, + macd: MacdSnapshot { + line: macd_line, + signal: signal_line, + histogram, + }, + volume: VolumeSnapshot { + current: latest.volume, + average20: average_volume20, + ratio20: volume_ratio20, + }, + signals: TechnicalSignal { + rsi: rsi_signal, + macd: macd_signal, + trend: trend_signal, + overall, + }, + }) +} + +fn last_value(values: &[Option]) -> Option { + values.iter().rev().find_map(|value| *value) +} + +fn previous_value(values: &[Option]) -> Option { + let mut seen_latest = false; + for value in values.iter().rev() { + if value.is_some() { + if seen_latest { + return *value; + } + seen_latest = true; + } + } + None +} + +fn average_last(values: &[f64], period: usize) -> Option { + if period == 0 || values.len() < period { + return None; + } + + let start = values.len() - period; + Some(values[start..].iter().sum::() / period as f64) +} + +#[cfg(test)] +mod tests { + use chrono::{Days, NaiveDate}; + + use super::build_technical_report; + use crate::analysis::signals::Signal; + use crate::api::types::Ohlc; + + #[test] + fn technical_report_uses_latest_values() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let history: Vec = (0..60) + .map(|idx| Ohlc { + date: start + .checked_add_days(Days::new(idx as u64)) + .expect("valid offset"), + open: 100 + idx as i64, + high: 101 + idx as i64, + low: 99 + idx as i64, + close: 100 + idx as i64, + volume: 1_000 + idx as u64 * 10, + }) + .collect(); + + let report = build_technical_report("BBCA.JK", &history).expect("report should build"); + + assert_eq!(report.symbol, "BBCA.JK"); + assert_eq!(report.current_price, 159); + assert!(report.sma20.is_some()); + assert!(report.sma50.is_some()); + assert_eq!(report.sma200, None); + assert!(report.rsi14.is_some()); + assert!(report.volume.ratio20.is_some()); + assert_eq!(report.signals.trend, Signal::Neutral); + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 624942e..258b122 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -245,6 +245,18 @@ fn msn_capability_error(subject: &str) -> IdxError { IdxError::Unsupported(format!("{subject}: command requires --provider msn")) } +/// The provider that serves history for `provider` under `history_mode`. +pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind { + match history_mode { + HistoryProviderKind::Yahoo => ProviderKind::Yahoo, + HistoryProviderKind::Msn => ProviderKind::Msn, + HistoryProviderKind::Auto => match provider { + ProviderKind::Yahoo => ProviderKind::Yahoo, + ProviderKind::Msn => ProviderKind::Yahoo, + }, + } +} + /// Resolves a history provider based on the selected market data provider and /// history provider strategy. /// @@ -255,14 +267,7 @@ pub fn history_provider( history_mode: HistoryProviderKind, verbose: bool, ) -> Result<(ProviderKind, Box), IdxError> { - let resolved = match history_mode { - HistoryProviderKind::Yahoo => ProviderKind::Yahoo, - HistoryProviderKind::Msn => ProviderKind::Msn, - HistoryProviderKind::Auto => match provider { - ProviderKind::Yahoo => ProviderKind::Yahoo, - ProviderKind::Msn => ProviderKind::Yahoo, - }, - }; + let resolved = history_source(provider, history_mode); if mock_enabled() && let Some(mock) = mock_history(resolved, verbose) diff --git a/crates/idx-core/src/cache.rs b/crates/idx-core/src/cache.rs index 0d9ff43..3740eed 100644 --- a/crates/idx-core/src/cache.rs +++ b/crates/idx-core/src/cache.rs @@ -25,6 +25,14 @@ struct CacheEntry { data: T, } +/// A cache hit with its metadata. +#[derive(Debug)] +pub struct Cached { + pub data: T, + pub fetched_at: DateTime, + pub expired: bool, +} + #[derive(Debug)] pub struct CacheInfo { pub path: PathBuf, @@ -39,8 +47,7 @@ impl Cache { Ok(Self { root: cache_dir()? }) } - #[cfg(test)] - pub fn with_root(root: PathBuf) -> Self { + pub fn at(root: PathBuf) -> Self { Self { root } } @@ -49,13 +56,7 @@ impl Cache { data_type: &str, symbol: &str, ) -> Result, IdxError> { - let Some(entry): Option> = self.read_entry(data_type, symbol)? else { - return Ok(None); - }; - if is_expired(entry.fetched_at, entry.ttl_secs) { - return Ok(None); - } - Ok(Some(entry.data)) + Ok(self.fresh(data_type, symbol)?.map(|hit| hit.data)) } pub fn get_stale( @@ -63,26 +64,57 @@ impl Cache { data_type: &str, symbol: &str, ) -> Result, IdxError> { - Ok(self.read_entry::(data_type, symbol)?.map(|e| e.data)) + Ok(self.any(data_type, symbol)?.map(|hit| hit.data)) } - /// Like [`Cache::get_stale`], but warns when the entry served is past its TTL. - /// Used by `--offline`, which otherwise serves arbitrarily old data silently. pub fn get_offline( &self, data_type: &str, symbol: &str, ) -> Result, IdxError> { - let Some(entry) = self.read_entry::(data_type, symbol)? else { - return Ok(None); - }; - if is_expired(entry.fetched_at, entry.ttl_secs) { + Ok(self.offline(data_type, symbol)?.map(|hit| hit.data)) + } + + /// An unexpired entry. + pub fn fresh( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + Ok(self.any(data_type, symbol)?.filter(|hit| !hit.expired)) + } + + /// Any entry, expired or not. + pub fn any( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + Ok(self + .read_entry::(data_type, symbol)? + .map(|entry| Cached { + expired: is_expired(entry.fetched_at, entry.ttl_secs), + fetched_at: entry.fetched_at, + data: entry.data, + })) + } + + /// Any entry, warning on stderr when serving an expired one offline. + pub fn offline( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + let hit = self.any::(data_type, symbol)?; + if let Some(hit) = &hit + && hit.expired + { runtime::warn(format!( "offline: serving expired cache for {symbol} (fetched {})", - entry.fetched_at.to_rfc3339() + hit.fetched_at.to_rfc3339() )); } - Ok(Some(entry.data)) + Ok(hit) } pub fn put( @@ -264,7 +296,7 @@ mod tests { #[test] fn write_read_expire_and_stale() { let root = tmp(); - let cache = Cache::with_root(root.clone()); + let cache = Cache::at(root.clone()); cache .put("quote", "BBCA.JK", &T { v: 7 }, 300) @@ -295,7 +327,7 @@ mod tests { #[test] fn corrupted_cache_entry_returns_none_and_deletes_file() { let root = tmp(); - let cache = Cache::with_root(root.clone()); + let cache = Cache::at(root.clone()); // Write invalid JSON to a cache file let path = root.join("quote/CORRUPT.JK.json"); @@ -319,7 +351,7 @@ mod tests { #[test] fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() { let root = tmp(); - let cache = Cache::with_root(root.clone()); + let cache = Cache::at(root.clone()); // Write invalid JSON to a cache file let path = root.join("quote/STALE_CORRUPT.JK.json"); diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs new file mode 100644 index 0000000..b73f49c --- /dev/null +++ b/crates/idx-core/src/client.rs @@ -0,0 +1,177 @@ +//! The `Idx` entry point and the cache policy shared by live-data lookups. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::api::{SelectedProvider, default_provider}; +use crate::cache::Cache; +use crate::config::CoreConfig; +use crate::error::IdxError; +use crate::runtime; + +/// How a live-data lookup may use the on-disk cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CachePolicy { + /// Serve unexpired cache, otherwise fetch and store; on a fetch failure, + /// fall back to an expired entry. + #[default] + Default, + /// Never touch the network; serve any cached entry, expired or not. + Offline, + /// Always fetch; neither read nor write the cache. + Refresh, +} + +impl CachePolicy { + /// Maps the CLI's `--offline` / `--no-cache` flags, which are exclusive. + pub fn from_flags(offline: bool, no_cache: bool) -> Result { + match (offline, no_cache) { + (true, true) => Err(IdxError::InvalidInput( + "cannot combine --offline with --no-cache".to_string(), + )), + (true, false) => Ok(Self::Offline), + (false, true) => Ok(Self::Refresh), + (false, false) => Ok(Self::Default), + } + } +} + +/// Live data with where it came from and how fresh it is. +#[derive(Debug, Clone, Serialize)] +pub struct Fetched { + pub data: T, + /// Provider that produced the data (`msn` or `yahoo`). + pub source: &'static str, + pub fetched_at: DateTime, + /// True when an expired cache entry was served (offline, or after a + /// failed fetch). + pub stale: bool, +} + +impl Fetched { + pub fn map(self, f: impl FnOnce(T) -> U) -> Fetched { + Fetched { + data: f(self.data), + source: self.source, + fetched_at: self.fetched_at, + stale: self.stale, + } + } +} + +/// Entry point for market, financial, and analysis data. +pub struct Idx { + pub(crate) config: CoreConfig, + pub(crate) provider: SelectedProvider, + pub(crate) cache: Cache, +} + +impl Idx { + pub fn new(config: CoreConfig) -> Result { + let cache = Cache::at(config.cache_dir()?); + let provider = default_provider(config.provider, config.verbose); + Ok(Self { + config, + provider, + cache, + }) + } + + pub fn config(&self) -> &CoreConfig { + &self.config + } + + pub fn market(&self) -> crate::market::Market<'_> { + crate::market::Market { idx: self } + } + + pub fn financial(&self) -> crate::financial::Financial<'_> { + crate::financial::Financial { idx: self } + } + + pub fn analysis(&self) -> crate::analysis::Analysis<'_> { + crate::analysis::Analysis { idx: self } + } + + pub(crate) fn resolve(&self, symbol: &str) -> Result { + crate::api::resolve_symbol(symbol, &self.config.exchange) + } + + pub(crate) fn bucket(&self, key: &str) -> String { + format!("{}-{key}", self.provider.kind().as_str()) + } +} + +pub(crate) struct CacheSpec<'a> { + pub bucket: &'a str, + pub key: &'a str, + pub ttl_secs: u64, + /// Named in the stale-fallback warning. + pub subject: &'a str, + pub source: &'static str, +} + +/// Fetches through the cache. `finish` turns the raw fetch into the cached +/// value; its errors propagate without a stale-cache fallback. +pub(crate) fn cached_fetch( + cache: &Cache, + spec: CacheSpec<'_>, + policy: CachePolicy, + fetch: impl FnOnce() -> Result, + finish: impl FnOnce(R) -> Result, +) -> Result, IdxError> +where + T: Serialize + DeserializeOwned, +{ + let hit = |hit: crate::cache::Cached, stale: bool| Fetched { + data: hit.data, + source: spec.source, + fetched_at: hit.fetched_at, + stale, + }; + + match policy { + CachePolicy::Default => { + if let Some(fresh) = cache.fresh::(spec.bucket, spec.key)? { + return Ok(hit(fresh, false)); + } + } + CachePolicy::Offline => { + return cache + .offline::(spec.bucket, spec.key)? + .map(|cached| { + let stale = cached.expired; + hit(cached, stale) + }) + .ok_or_else(|| IdxError::CacheMiss(format!("{}/{}", spec.bucket, spec.key))); + } + CachePolicy::Refresh => {} + } + + let use_cache = policy == CachePolicy::Default; + match fetch() { + Ok(raw) => { + let data = finish(raw)?; + if use_cache { + cache.put(spec.bucket, spec.key, &data, spec.ttl_secs)?; + } + Ok(Fetched { + data, + source: spec.source, + fetched_at: Utc::now(), + stale: false, + }) + } + Err(err) => { + if use_cache && let Some(stale) = cache.any::(spec.bucket, spec.key)? { + runtime::warn(format!( + "network failed, serving stale cache for {}", + spec.subject + )); + return Ok(hit(stale, true)); + } + Err(err) + } + } +} diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index 6852533..4f28071 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -74,7 +74,113 @@ impl HistoryProviderKind { } } -pub fn parse_env_ttl(name: &str) -> Result, IdxError> { +/// Settings for data access, shared by every frontend. Presentation settings +/// (output format, color) are not part of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreConfig { + pub provider: ProviderKind, + pub history_provider: HistoryProviderKind, + pub exchange: String, + pub quote_ttl: u64, + pub fundamental_ttl: u64, + /// Cache root; `None` resolves `$XDG_CACHE_HOME/idx` or the platform default. + pub cache_dir: Option, + /// Provider diagnostics on stderr. + pub verbose: bool, +} + +impl Default for CoreConfig { + fn default() -> Self { + Self { + provider: ProviderKind::Msn, + history_provider: HistoryProviderKind::Auto, + exchange: "JK".to_string(), + quote_ttl: 300, + fundamental_ttl: 3600, + cache_dir: None, + verbose: false, + } + } +} + +#[derive(Debug, Deserialize, Default)] +struct FileConfig { + general: Option, + cache: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileGeneral { + provider: Option, + history_provider: Option, + exchange: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileCache { + quote_ttl: Option, + fundamental_ttl: Option, +} + +impl CoreConfig { + /// Defaults, then `config.toml`, then `IDX_PROVIDER`, `IDX_HISTORY_PROVIDER`, + /// `IDX_EXCHANGE`, `IDX_CACHE_QUOTE_TTL`, and `IDX_CACHE_FUNDAMENTAL_TTL`. + /// This is the only place idx-core reads those settings from the environment. + pub fn load() -> Result { + let mut cfg = Self::default(); + let path = config_path()?; + if path.exists() { + let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; + let parsed: FileConfig = + toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; + if let Some(general) = parsed.general { + if let Some(provider) = general.provider { + cfg.provider = provider; + } + if let Some(history_provider) = general.history_provider { + cfg.history_provider = history_provider; + } + if let Some(exchange) = general.exchange { + cfg.exchange = exchange; + } + } + if let Some(cache) = parsed.cache { + if let Some(v) = cache.quote_ttl { + cfg.quote_ttl = v; + } + if let Some(v) = cache.fundamental_ttl { + cfg.fundamental_ttl = v; + } + } + } + + if let Ok(provider) = std::env::var("IDX_PROVIDER") { + cfg.provider = ProviderKind::parse(&provider)?; + } + if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") { + cfg.history_provider = HistoryProviderKind::parse(&history_provider)?; + } + if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { + cfg.exchange = exchange; + } + if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? { + cfg.quote_ttl = parsed; + } + if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? { + cfg.fundamental_ttl = parsed; + } + Ok(cfg) + } + + pub fn cache_dir(&self) -> Result { + match &self.cache_dir { + Some(dir) => Ok(dir.clone()), + None => crate::cache::cache_dir(), + } + } +} + +fn parse_env_ttl(name: &str) -> Result, IdxError> { let Ok(raw) = std::env::var(name) else { return Ok(None); }; @@ -258,6 +364,15 @@ pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> { #[cfg(test)] mod tests { + #[test] + fn default_values_are_sane() { + let cfg = super::CoreConfig::default(); + assert_eq!(cfg.provider, super::ProviderKind::Msn); + assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto); + assert_eq!(cfg.exchange, "JK"); + assert_eq!(cfg.quote_ttl, 300); + } + #[test] fn parses_provider_values() { assert_eq!( diff --git a/crates/idx-core/src/financial.rs b/crates/idx-core/src/financial.rs new file mode 100644 index 0000000..19c2fb4 --- /dev/null +++ b/crates/idx-core/src/financial.rs @@ -0,0 +1,397 @@ +//! Company financial data: profile, statements, earnings, sentiment, +//! insights, and news. These require the MSN provider. + +use crate::api::types::{ + CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, + SentimentData, +}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; + +pub struct Financial<'a> { + pub(crate) idx: &'a Idx, +} + +impl Financial<'_> { + pub fn profile( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let provider = self.idx.provider.profile_provider(&resolved)?; + self.fetch( + "profile-v2", + &resolved, + &resolved, + self.ttl(), + policy, + || provider.profile(&resolved), + ) + } + + pub fn statements( + &self, + symbol: &str, + filter: &StatementFilter, + policy: CachePolicy, + ) -> Result, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let provider = self.idx.provider.financials_provider(&resolved)?; + let fetched = self.fetch( + "financials", + &resolved, + &resolved, + self.ttl(), + policy, + || provider.financials(&resolved), + )?; + Ok(fetched.map(|mut financials| { + if financials.instrument.symbol.trim().is_empty() + || !financials.instrument.symbol.contains('.') + { + financials.instrument.symbol = resolved.clone(); + } + filter.apply(&financials) + })) + } + + pub fn earnings( + &self, + symbol: &str, + filter: EarningsFilter, + policy: CachePolicy, + ) -> Result, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let provider = self.idx.provider.earnings_provider(&resolved)?; + let fetched = self.fetch("earnings", &resolved, &resolved, self.ttl(), policy, || { + provider.earnings(&resolved) + })?; + Ok(fetched.map(|mut earnings| { + if earnings.symbol.is_empty() { + earnings.symbol = resolved.clone(); + } + filter.apply(&earnings) + })) + } + + pub fn sentiment( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let provider = self.idx.provider.sentiment_provider(&resolved)?; + self.fetch( + "sentiment", + &resolved, + &resolved, + self.ttl(), + policy, + || provider.sentiment(&resolved), + ) + } + + pub fn insights( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let provider = self.idx.provider.insights_provider(&resolved)?; + let fetched = self.fetch( + "insights-v2", + &resolved, + &resolved, + self.ttl(), + policy, + || provider.insights(&resolved), + )?; + Ok(fetched.map(|mut insights| { + if insights.symbol.is_empty() { + insights.symbol = resolved.clone(); + } + insights + })) + } + + pub fn news( + &self, + symbol: &str, + limit: usize, + policy: CachePolicy, + ) -> Result>, IdxError> { + let resolved = self.idx.resolve(symbol)?; + let key = format!("{resolved}-{limit}"); + let provider = self.idx.provider.news_provider(&resolved)?; + let ttl = self.idx.config.quote_ttl; + let fetched = self.fetch("news", &key, &resolved, ttl, policy, || { + provider.news(&resolved, limit) + })?; + Ok(fetched.map(|mut news| { + for item in &mut news { + if item.symbol.is_empty() { + item.symbol = resolved.clone(); + } + } + news + })) + } + + fn ttl(&self) -> u64 { + self.idx.config.fundamental_ttl + } + + fn fetch( + &self, + bucket: &str, + key: &str, + subject: &str, + ttl_secs: u64, + policy: CachePolicy, + fetch: impl FnOnce() -> Result, + ) -> Result, IdxError> + where + T: serde::Serialize + serde::de::DeserializeOwned, + { + let bucket = self.idx.bucket(bucket); + cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: &bucket, + key, + ttl_secs, + subject, + source: self.idx.provider.kind().as_str(), + }, + policy, + fetch, + Ok, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum StatementKind { + Income, + Balance, + Cashflow, +} + +/// Statements to keep; empty keeps all. Excluded sections become `None`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StatementFilter { + pub statements: Vec, +} + +impl StatementFilter { + pub fn all() -> Self { + Self::default() + } + + fn includes(&self, kind: StatementKind) -> bool { + self.statements.is_empty() || self.statements.contains(&kind) + } + + pub fn apply(&self, financials: &FinancialStatements) -> FinancialStatements { + FinancialStatements { + instrument: financials.instrument.clone(), + balance_sheet: self + .includes(StatementKind::Balance) + .then(|| financials.balance_sheet.clone()) + .flatten(), + cash_flow: self + .includes(StatementKind::Cashflow) + .then(|| financials.cash_flow.clone()) + .flatten(), + income_statement: self + .includes(StatementKind::Income) + .then(|| financials.income_statement.clone()) + .flatten(), + } + } +} + +/// Earnings scope. With neither `forecast` nor `history` set both are kept; +/// with neither `annual` nor `quarterly` set every period is kept. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct EarningsFilter { + pub forecast: bool, + pub history: bool, + pub annual: bool, + pub quarterly: bool, +} + +impl EarningsFilter { + fn include_forecast(self) -> bool { + self.forecast || !self.history + } + + fn include_history(self) -> bool { + self.history || !self.forecast + } + + fn includes_period(self, period_type: &str) -> bool { + if !self.annual && !self.quarterly { + return true; + } + + match classify_earnings_period(period_type) { + EarningsPeriodKind::Annual => self.annual || !self.quarterly, + EarningsPeriodKind::Quarterly => self.quarterly || !self.annual, + EarningsPeriodKind::Unknown => false, + } + } + + pub fn apply(self, report: &EarningsReport) -> EarningsReport { + let filter_rows = |rows: &[EarningsData]| { + rows.iter() + .filter(|row| self.includes_period(&row.period_type)) + .cloned() + .collect() + }; + + EarningsReport { + symbol: report.symbol.clone(), + eps_last_year: report.eps_last_year, + revenue_last_year: report.revenue_last_year, + forecast: if self.include_forecast() { + filter_rows(&report.forecast) + } else { + Vec::new() + }, + history: if self.include_history() { + filter_rows(&report.history) + } else { + Vec::new() + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EarningsPeriodKind { + Annual, + Quarterly, + Unknown, +} + +fn classify_earnings_period(period_type: &str) -> EarningsPeriodKind { + let trimmed = period_type.trim(); + if trimmed.is_empty() { + return EarningsPeriodKind::Unknown; + } + + let upper = trimmed.to_ascii_uppercase(); + if upper.starts_with('Q') { + return EarningsPeriodKind::Quarterly; + } + if upper.starts_with("FY") || (upper.len() == 4 && upper.chars().all(|ch| ch.is_ascii_digit())) + { + return EarningsPeriodKind::Annual; + } + + EarningsPeriodKind::Unknown +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{ + EarningsFilter, EarningsPeriodKind, StatementFilter, StatementKind, + classify_earnings_period, + }; + use crate::api::types::{ + EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, StatementSection, + }; + + fn section(key: &str, value: f64) -> Option { + Some(StatementSection { + values: BTreeMap::from([(key.into(), value)]), + currency: "IDR".into(), + report_date: "2026-01-01".into(), + end_date: "2025-12-31".into(), + }) + } + + #[test] + fn filters_financial_statements_to_requested_sections() { + let sample = FinancialStatements { + instrument: InstrumentInfo { + id: "123".into(), + symbol: "BBCA.JK".into(), + name: "BCA".into(), + }, + balance_sheet: section("assets", 1.0), + cash_flow: section("cash", 2.0), + income_statement: section("income", 3.0), + }; + + let filtered = StatementFilter { + statements: vec![StatementKind::Cashflow], + } + .apply(&sample); + + assert!(filtered.cash_flow.is_some()); + assert!(filtered.income_statement.is_none()); + assert!(filtered.balance_sheet.is_none()); + assert_eq!(filtered.instrument.symbol, "BBCA.JK"); + } + + fn earnings_row(period_type: &str, eps_forecast: Option) -> EarningsData { + EarningsData { + eps_actual: None, + eps_forecast, + eps_surprise: None, + eps_surprise_pct: None, + revenue_actual: None, + revenue_forecast: None, + revenue_surprise: None, + earning_release_date: None, + period_type: period_type.into(), + } + } + + #[test] + fn filters_earnings_by_scope_and_period() { + let report = EarningsReport { + symbol: "BBCA.JK".into(), + eps_last_year: 1_200.0, + revenue_last_year: 100_000_000_000.0, + forecast: vec![ + earnings_row("2026", Some(1_300.0)), + earnings_row("Q12026", Some(330.0)), + ], + history: vec![earnings_row("2025", None)], + }; + + let filtered = EarningsFilter { + forecast: true, + history: false, + annual: true, + quarterly: false, + } + .apply(&report); + + assert_eq!(filtered.symbol, "BBCA.JK"); + assert!(filtered.history.is_empty()); + assert_eq!(filtered.forecast.len(), 1); + assert_eq!(filtered.forecast[0].period_type, "2026"); + } + + #[test] + fn classifies_earnings_periods() { + assert_eq!(classify_earnings_period("2025"), EarningsPeriodKind::Annual); + assert_eq!( + classify_earnings_period("FY2026"), + EarningsPeriodKind::Annual + ); + assert_eq!( + classify_earnings_period("Q12026"), + EarningsPeriodKind::Quarterly + ); + assert_eq!(classify_earnings_period(""), EarningsPeriodKind::Unknown); + } +} diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 39170cd..703911e 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -5,12 +5,17 @@ pub mod analysis; pub mod api; pub mod cache; +mod client; pub mod config; pub mod curl_impersonate; pub mod error; +pub mod financial; +pub mod market; #[cfg(feature = "ownership")] pub mod ownership; +pub use client::{CachePolicy, Fetched, Idx}; + pub mod runtime { use std::fmt::Display; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs new file mode 100644 index 0000000..2955f9c --- /dev/null +++ b/crates/idx-core/src/market.rs @@ -0,0 +1,409 @@ +//! Market data: quotes, price history, and the screener. + +use std::cmp::Ordering; +use std::fmt; +use std::str::FromStr; + +use crate::api::types::{Interval, Ohlc, Period, Quote}; +use crate::api::{history_provider, history_source}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::config::{HistoryProviderKind, ProviderKind}; +use crate::error::IdxError; + +pub struct Market<'a> { + pub(crate) idx: &'a Idx, +} + +/// Which provider serves history, and whether that differs from the selected +/// market provider because `auto` fell back (MSN history is price-only). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistorySource { + pub provider: ProviderKind, + pub mode: HistoryProviderKind, + pub is_fallback: bool, +} + +impl Market<'_> { + /// Quotes in input order; stops at the first symbol that fails. + pub fn quotes>( + &self, + symbols: &[S], + policy: CachePolicy, + ) -> Result>, IdxError> { + let idx = self.idx; + let bucket = idx.bucket("quote"); + let mut quotes = Vec::with_capacity(symbols.len()); + for symbol in symbols { + let resolved = idx.resolve(symbol.as_ref())?; + quotes.push(cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: idx.provider.kind().as_str(), + }, + policy, + || idx.provider.market().quote(&resolved), + Ok, + )?); + } + Ok(quotes) + } + + /// Resolves the history source for an optional `--history-provider` + /// override, without fetching. + pub fn history_source(&self, mode: Option) -> HistorySource { + let mode = mode.unwrap_or(self.idx.config.history_provider); + let selected = self.idx.provider.kind(); + let provider = history_source(selected, mode); + HistorySource { + provider, + mode, + is_fallback: mode == HistoryProviderKind::Auto && provider != selected, + } + } + + pub fn history( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + source: HistorySource, + policy: CachePolicy, + ) -> Result>, IdxError> { + let idx = self.idx; + let (provider_kind, provider) = + history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?; + let bucket = format!("{}-history", provider_kind.as_str()); + let resolved = idx.resolve(symbol)?; + let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &key, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: provider_kind.as_str(), + }, + policy, + || provider.history(&resolved, period, interval), + Ok, + ) + } + + /// Screener results, ranked locally and truncated to `query.limit`. + pub fn screen( + &self, + query: &ScreenQuery, + policy: CachePolicy, + ) -> Result>, IdxError> { + let idx = self.idx; + let ScreenQuery { + filter, + region, + limit, + } = *query; + // Filters re-sorted client-side need the whole MSN list: MSN orders + // by 1-year return, so truncating first and then sorting by day + // change made `--limit 3` return different stocks than `--limit 25`. + let fetch_limit = (!filter.needs_full_list()).then_some(limit); + let bucket = idx.bucket("screen"); + let key = match fetch_limit { + Some(limit) => format!("{filter}:{region}:{limit}"), + None => format!("{filter}:{region}:all"), + }; + let screener = idx.provider.screener_provider("screen")?; + let mut fetched = cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &key, + ttl_secs: idx.config.quote_ttl, + subject: "screen", + source: idx.provider.kind().as_str(), + }, + policy, + || screener.screener(filter.msn_key(), region.msn_key(), fetch_limit), + Ok, + )?; + sort_screener_quotes(&mut fetched.data, filter); + fetched.data.truncate(limit); + Ok(fetched) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScreenQuery { + pub filter: ScreenFilter, + pub region: ScreenRegion, + pub limit: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenFilter { + TopPerformers, + WorstPerformers, + HighDividend, + LowPe, + Week52High, + Week52Low, + HighVolume, + LargeCap, +} + +impl ScreenFilter { + pub const ALL: [Self; 8] = [ + Self::TopPerformers, + Self::WorstPerformers, + Self::HighDividend, + Self::LowPe, + Self::Week52High, + Self::Week52Low, + Self::HighVolume, + Self::LargeCap, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::TopPerformers => "top-performers", + Self::WorstPerformers => "worst-performers", + Self::HighDividend => "high-dividend", + Self::LowPe => "low-pe", + Self::Week52High => "52w-high", + Self::Week52Low => "52w-low", + Self::HighVolume => "high-volume", + Self::LargeCap => "large-cap", + } + } + + fn msn_key(self) -> &'static str { + match self { + Self::TopPerformers | Self::HighVolume | Self::LargeCap => "st_list_topperfs", + Self::WorstPerformers => "st_list_poorperfs", + Self::HighDividend => "st_list_highdividend", + Self::LowPe => "st_list_lowpe", + Self::Week52High => "st_list_52wkhi", + Self::Week52Low => "st_list_52wklow", + } + } + + /// Filters ranked locally need every candidate before `limit` applies. + fn needs_full_list(self) -> bool { + matches!( + self, + Self::HighVolume | Self::LargeCap | Self::TopPerformers | Self::WorstPerformers + ) + } +} + +impl fmt::Display for ScreenFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ScreenFilter { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|filter| filter.as_str() == value) + .ok_or_else(|| { + let names: Vec<_> = Self::ALL.iter().map(|f| f.as_str()).collect(); + IdxError::InvalidInput(format!( + "invalid screener filter '{value}' (expected one of: {})", + names.join(", ") + )) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenRegion { + Id, + Us, + Sg, + Hk, + Jp, +} + +impl ScreenRegion { + pub const ALL: [Self; 5] = [Self::Id, Self::Us, Self::Sg, Self::Hk, Self::Jp]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Id => "id", + Self::Us => "us", + Self::Sg => "sg", + Self::Hk => "hk", + Self::Jp => "jp", + } + } + + fn msn_key(self) -> &'static str { + match self { + Self::Id => "st_reg_id", + Self::Us => "st_reg_us", + Self::Sg => "st_reg_sg", + Self::Hk => "st_reg_hk", + Self::Jp => "st_reg_jp", + } + } +} + +impl fmt::Display for ScreenRegion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ScreenRegion { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|region| region.as_str() == value) + .ok_or_else(|| { + let names: Vec<_> = Self::ALL.iter().map(|r| r.as_str()).collect(); + IdxError::InvalidInput(format!( + "invalid screener region '{value}' (expected one of: {})", + names.join(", ") + )) + }) + } +} + +fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) { + match filter { + ScreenFilter::TopPerformers => { + quotes.sort_by(|a, b| { + let pa = a.change_pct.unwrap_or(f64::MIN); + let pb = b.change_pct.unwrap_or(f64::MIN); + pb.partial_cmp(&pa).unwrap_or(Ordering::Equal) + }); + } + ScreenFilter::WorstPerformers => { + quotes.sort_by(|a, b| { + let pa = a.change_pct.unwrap_or(f64::MAX); + let pb = b.change_pct.unwrap_or(f64::MAX); + pa.partial_cmp(&pb).unwrap_or(Ordering::Equal) + }); + } + ScreenFilter::Week52High => { + quotes.sort_by(|a, b| { + let pa = a.week52_position.unwrap_or(f64::MIN); + let pb = b.week52_position.unwrap_or(f64::MIN); + pb.partial_cmp(&pa).unwrap_or(Ordering::Equal) + }); + } + ScreenFilter::Week52Low => { + quotes.sort_by(|a, b| { + let pa = a.week52_position.unwrap_or(f64::MAX); + let pb = b.week52_position.unwrap_or(f64::MAX); + pa.partial_cmp(&pb).unwrap_or(Ordering::Equal) + }); + } + ScreenFilter::HighVolume => { + quotes.sort_by_key(|quote| std::cmp::Reverse(quote.volume)); + } + ScreenFilter::LargeCap => { + quotes.sort_by_key(|quote| std::cmp::Reverse(quote.market_cap.unwrap_or(0))); + } + ScreenFilter::HighDividend | ScreenFilter::LowPe => {} + } +} + +#[cfg(test)] +mod tests { + use super::{ScreenFilter, ScreenRegion, sort_screener_quotes}; + use crate::api::types::Quote; + use crate::error::IdxError; + + fn sample_quote(symbol: &str, change_pct: Option) -> Quote { + Quote { + symbol: symbol.into(), + price: 100, + change: None, + change_pct, + volume: None, + market_cap: None, + week52_high: None, + week52_low: None, + week52_position: None, + range_signal: None, + prev_close: None, + avg_volume: None, + as_of: None, + } + } + + #[test] + fn sort_screener_top_performers_descending() { + let mut quotes = vec![ + sample_quote("A", Some(1.0)), + sample_quote("B", Some(5.0)), + sample_quote("C", Some(3.0)), + ]; + sort_screener_quotes(&mut quotes, ScreenFilter::TopPerformers); + let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); + assert_eq!(pcts, vec![5.0, 3.0, 1.0]); + } + + #[test] + fn sort_screener_worst_performers_ascending() { + let mut quotes = vec![ + sample_quote("A", Some(1.0)), + sample_quote("B", Some(-5.0)), + sample_quote("C", Some(-2.0)), + ]; + sort_screener_quotes(&mut quotes, ScreenFilter::WorstPerformers); + let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); + assert_eq!(pcts, vec![-5.0, -2.0, 1.0]); + } + + #[test] + fn sort_screener_puts_unknown_change_last_both_ways() { + let mut quotes = vec![ + sample_quote("U", None), + sample_quote("A", Some(1.0)), + sample_quote("B", Some(-5.0)), + ]; + sort_screener_quotes(&mut quotes, ScreenFilter::WorstPerformers); + assert_eq!(quotes.last().unwrap().symbol, "U"); + sort_screener_quotes(&mut quotes, ScreenFilter::TopPerformers); + assert_eq!(quotes.last().unwrap().symbol, "U"); + } + + #[test] + fn invalid_screener_filter_returns_error() { + let err = "bogus" + .parse::() + .expect_err("invalid filter should fail"); + assert!(matches!(err, IdxError::InvalidInput(_))); + assert!(err.to_string().contains("invalid screener filter")); + } + + #[test] + fn invalid_screener_region_returns_error() { + let err = "eu" + .parse::() + .expect_err("invalid region should fail"); + assert!(matches!(err, IdxError::InvalidInput(_))); + assert!(err.to_string().contains("invalid screener region")); + } + + #[test] + fn screen_names_round_trip() { + for filter in ScreenFilter::ALL { + assert_eq!(filter.as_str().parse::().unwrap(), filter); + } + for region in ScreenRegion::ALL { + assert_eq!(region.as_str().parse::().unwrap(), region); + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f6789e..38e2dd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,16 +4,18 @@ The repo is a Cargo workspace with one shared version: -- `crates/idx-core` (library): providers (`api/`), `analysis/`, `cache`, config - file handling and provider selection (`config`), `ownership/` (SQLite, parsers, - discovery, snapshot sync), `error`, and `runtime` (quiet/info/warn). It owns the - shared test fixtures in `tests/fixtures/`. +- `crates/idx-core` (library): the `Idx` entry point with `market()`, + `financial()`, and `analysis()` (cache policy and `Fetched` in `client.rs`), + providers (`api/`), `analysis/`, `cache`, `config` (`CoreConfig`), + `ownership/` (SQLite, parsers, discovery, snapshot sync), `error`, and + `runtime` (quiet/info/warn). It owns the shared test fixtures in + `tests/fixtures/`; the fixture-backed mock provider needs the `testing` feature. - `crates/idx-cli` (binary `idx`): clap commands (`cli/`), output format and color resolution (`config.rs`, `IdxConfig`), and rendering (`output/`). Its `tests/` hold the CLI integration and golden output suites. -Some fetch/cache orchestration still lives in `crates/idx-cli/src/cli/` until -phase 3 of `docs/DESIGN.md` moves it into idx-core. +Ownership import/sync orchestration still lives in +`crates/idx-cli/src/cli/ownership.rs` until phase 3c of `docs/DESIGN.md`. ## Domain Map diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 497872f..b728fec 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -59,21 +59,25 @@ A single entry point holds configuration, providers, cache, and the DB path. Domain methods hang off it. ```rust -let idx = Idx::builder() - .config(CoreConfig::load()?) // provider, TTLs, paths; no output settings - .build()?; +let idx = Idx::new(CoreConfig::load()?)?; // provider, TTLs, cache dir; no output settings +let policy = CachePolicy::Default; // market -let quotes: Fetched> = idx.market().quotes(&["BBCA", "BBRI"], CachePolicy::Default)?; -let candles: Fetched> = idx.market().history("BBCA", Period::M3, Interval::D1, CachePolicy::Default)?; -let screen: Fetched> = idx.market().screen(ScreenQuery { filter, region, limit })?; +let quotes: Vec> = idx.market().quotes(&["BBCA", "BBRI"], policy)?; +let source = idx.market().history_source(None); // which provider serves history +let candles: Fetched> = + idx.market().history("BBCA", &Period::ThreeMonths, &Interval::Day, source, policy)?; +let screen: Fetched> = idx.market().screen(&ScreenQuery { filter, region, limit }, policy)?; // financial -let report: Fetched = idx.financial().statements("BBCA", StatementFilter::all())?; -let earnings: Fetched = idx.financial().earnings("BBCA", EarningsFilter::default())?; +let report: Fetched = + idx.financial().statements("BBCA", &StatementFilter::all(), policy)?; +let earnings: Fetched = + idx.financial().earnings("BBCA", EarningsFilter::default(), policy)?; // analysis (fetches its own inputs through the same cache) -let tech: Fetched = idx.analysis().technical("BBCA")?; +let tech: Fetched = idx.analysis().technical("BBCA", source, policy)?; +let compared: Comparison = idx.analysis().compare(&["BBCA", "BBRI"], policy)?; // investor (local DB) let holders: TickerOwnership = idx.investor().ticker("BBCA")?; @@ -86,14 +90,18 @@ Key decisions: - **Blocking API.** `ureq` and `rusqlite` are blocking, and so is the CLI. Async frontends (MCP, HTTP) call core through `spawn_blocking`. Revisit only if an HTTP API needs high concurrency. -- **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`. - This replaces today's stderr-only stale-cache warning, which an agent or API - client cannot see. +- **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`, + so agents and API clients can see freshness. idx-core still emits the + stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI + stderr stays identical. - **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / `--no-cache` flag handling now spread through `cli/stocks.rs`. -- **Explicit configuration.** Core reads no environment variables. `IDX_*` env - parsing, `config.toml` loading, and XDG resolution move to a `CoreConfig::load()` - helper that frontends call; tests construct `CoreConfig` directly. +- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads + provider, exchange, and TTL settings from `config.toml` and `IDX_*`; other + frontends and tests construct `CoreConfig` directly (`cache_dir` overrides the + XDG default). Remaining environment hooks: XDG paths for the config file and + ownership DB, `IDX_CURL_IMPERSONATE_BIN`, the IDX discovery URL overrides, and + the `testing`-only mock variables. - **Mock provider behind a `testing` feature.** Done: `MockProvider` and the embedded MSN fixtures compile only with idx-core's `testing` feature, which idx-cli enables as a dev-dependency. From 1d8b615857462e9ec285a2d6e3a50f42e5307039 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 09:52:17 +0000 Subject: [PATCH 08/14] refactor(core): move ownership queries, import, and sync into idx-core Phase 3c of docs/DESIGN.md. `ownership` handlers are now argument parsing, one idx-core call, and rendering. - investor.rs: Investor (also Idx::investor()) with releases, ticker, entity, search, cross_holders, concentration, flow, changes, graph, unresolved, map_alias, merge_entities, discover, sync, and import. Import orchestration (input resolution, download, SHA-256 dedup, PDF/XLSX/archive parsing, entity resolution, release write) moves from the CLI unchanged. - Typed inputs: ImportSource, HolderSource, ConcentrationRank, and parse_report_family, with the same error messages. - CoreConfig gains an ownership_db override; db::open_db_at opens an explicit path. idx-cli no longer depends on rusqlite, sha2, or ureq at runtime. - Behavior note: an invalid `ticker --source` or `concentration --by` is now rejected before the database file is opened (and created); the error and exit code are unchanged. - Fix: building idx-cli without the ownership feature no longer warns about the ownership-only `blue` style. All 143 goldens unchanged; 170 core + 7 CLI unit tests and 62 CLI tests pass; clippy clean for default, release, testing, and no-default feature sets. Live A/B against main: 16/16 stocks identical; ownership discover got IDX 403 on both (exit 4 vs 1 is the phase 1 change). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + Cargo.lock | 1 - crates/idx-cli/Cargo.toml | 6 +- crates/idx-cli/src/cli/ownership.rs | 587 +++++--------------------- crates/idx-cli/src/output/style.rs | 2 + crates/idx-core/src/client.rs | 5 + crates/idx-core/src/config.rs | 4 + crates/idx-core/src/investor.rs | 633 ++++++++++++++++++++++++++++ crates/idx-core/src/lib.rs | 2 + crates/idx-core/src/ownership/db.rs | 9 +- docs/ARCHITECTURE.md | 7 +- docs/DESIGN.md | 6 +- 12 files changed, 774 insertions(+), 490 deletions(-) create mode 100644 crates/idx-core/src/investor.rs diff --git a/AGENTS.md b/AGENTS.md index aedb82f..b8a03c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ ## Navigation ```text crates/idx-core/ library: all data access and analysis +├── src/client.rs Idx entry point, CachePolicy, Fetched +├── 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, diff --git a/Cargo.lock b/Cargo.lock index f8f727a..35f189f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -726,7 +726,6 @@ dependencies = [ "serde_json", "sha2", "toml", - "ureq", "zip", ] diff --git a/crates/idx-cli/Cargo.toml b/crates/idx-cli/Cargo.toml index debf2a1..ddb3186 100644 --- a/crates/idx-cli/Cargo.toml +++ b/crates/idx-cli/Cargo.toml @@ -18,8 +18,7 @@ path = "src/main.rs" [features] default = ["ownership"] -# rusqlite/sha2/ureq back ownership import code that phase 3 moves into idx-core. -ownership = ["idx-core/ownership", "dep:rusqlite", "dep:sha2", "dep:ureq"] +ownership = ["idx-core/ownership"] [dependencies] idx-core = { workspace = true, features = ["clap"] } @@ -31,9 +30,6 @@ comfy-table = "7" owo-colors = "4" toml.workspace = true chrono.workspace = true -rusqlite = { workspace = true, optional = true } -sha2 = { workspace = true, optional = true } -ureq = { workspace = true, optional = true } [dev-dependencies] # Tests drive the binary with the mock provider; release builds exclude it. diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index f5d33b5..7836c5b 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -1,22 +1,20 @@ -use std::collections::HashSet; -use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use crate::output::style::Paint; -use chrono::Utc; use clap::{Args, Subcommand}; use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL}; use serde::Serialize; -use sha2::{Digest, Sha256}; use crate::config::IdxConfig; use crate::output::json; +use crate::output::style::Paint; use crate::output::table::format_idr; use idx_core::error::IdxError; -use idx_core::ownership::types::{ - ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource, +use idx_core::investor::{ + ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family, }; -use idx_core::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( @@ -196,27 +194,27 @@ 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()?; - 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 config.output.is_json() { return json::print_json(&result); @@ -258,15 +256,19 @@ 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 config.output.is_json() { return json::print_json(&reports); @@ -300,41 +302,13 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr Ok(()) } -fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - 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(), - )); - } - - 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 = 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}" - ))); - } +fn handle_ticker( + investor: &Investor, + args: &TickerArgs, + config: &IdxConfig, +) -> Result<(), IdxError> { + let source: HolderSource = args.source.parse()?; + let data = investor.ticker(&args.symbol, source)?; if config.output.is_json() { return json::print_json(&data); @@ -392,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()?; - let q = format!("%{}%", args.name.trim()); - - 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)?; +fn handle_entity( + investor: &Investor, + args: &EntityArgs, + config: &IdxConfig, +) -> Result<(), IdxError> { + let data = investor.entity(&args.name)?; 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) @@ -435,9 +406,12 @@ fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> Ok(()) } -fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - 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 config.output.is_json() { return json::print_json(&entities); @@ -470,9 +444,12 @@ 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()?; - 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 config.output.is_json() { return json::print_json(&rows); @@ -497,16 +474,13 @@ fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<() Ok(()) } -fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - 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(), - )); - } - - let rows = db::query_concentration(&conn, &by, args.top)?; +fn handle_concentration( + investor: &Investor, + args: &ConcentrationArgs, + config: &IdxConfig, +) -> Result<(), IdxError> { + let by: ConcentrationRank = args.by.parse()?; + let rows = investor.concentration(by, args.top)?; if config.output.is_json() { return json::print_json(&rows); @@ -542,13 +516,9 @@ fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result< Ok(()) } -fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; +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 config.output.is_json() { return json::print_json( @@ -596,9 +566,8 @@ fn print_flow_section(title: &str, rows: &[HolderRow]) { println!("{table}"); } -fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - 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 config.output.is_json() { #[derive(Serialize)] @@ -627,9 +596,12 @@ fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> { } } -fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - 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)?; if config.output.is_json() { return json::print_json(&changes); @@ -673,12 +645,14 @@ fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError Ok(()) } -fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - +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 config.output.is_json() { return json::print_json(&rows); @@ -718,7 +692,7 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError Ok(()) } ResolveCommand::Map { alias, entity } => { - db::manual_map(&conn, alias, entity)?; + investor.map_alias(alias, entity)?; if config.output.is_json() { return json::print_json(&serde_json::json!({ "action": "mapped", @@ -730,29 +704,10 @@ 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()))?; - - db::merge_entities(&conn, *keep, *merge)?; + 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; if config.output.is_json() { return json::print_json(&serde_json::json!({ @@ -773,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(), @@ -797,124 +756,34 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> )); } - let Some(import_input) = resolve_import_input(args)? else { - return Ok(()); - }; - - let conn = db::open_db()?; - - 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 config.output.is_json() { - return json::print_json(&result); - } - - 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(), + 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)?; if config.output.is_json() { return json::print_json(&result); } - println!( - "Imported {} rows for {} tickers (as of {}).", - result.inserted_rows, - result.ticker_count, - as_of_date.format("%Y-%m-%d") - ); + match result.as_of_date.as_deref() { + None => println!( + "Release already imported (sha256: {}). Use --force to re-import.", + result.sha256 + ), + Some(as_of_date) => println!( + "Imported {} rows for {} tickers (as of {}).", + result.inserted_rows, result.ticker_count, as_of_date + ), + } Ok(()) } -fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> { - let conn = db::open_db()?; - let releases = db::query_releases(&conn)?; +fn handle_releases(investor: &Investor, config: &IdxConfig) -> Result<(), IdxError> { + let releases = investor.releases()?; if config.output.is_json() { return json::print_json(&releases); @@ -945,238 +814,6 @@ fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> { Ok(()) } -fn parse_discovery_family(raw: &str) -> Result, 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, - format: ImportInputFormat, -} - -#[derive(Debug, Serialize)] -struct OwnershipImportResult { - action: &'static str, - inserted_rows: usize, - ticker_count: usize, - as_of_date: Option, - sha256: String, - source_url: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ImportInputFormat { - Pdf, - Archive, - Xlsx, -} - -fn resolve_import_input(args: &ImportArgs) -> Result, 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 { - 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 { - let extension = match format { - ImportInputFormat::Xlsx => ".xlsx", - _ => ".pdf", - }; - let raw_dir = idx_core::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 { - 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 { - 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) } diff --git a/crates/idx-cli/src/output/style.rs b/crates/idx-cli/src/output/style.rs index ee72e50..cf6359c 100644 --- a/crates/idx-cli/src/output/style.rs +++ b/crates/idx-cli/src/output/style.rs @@ -33,6 +33,8 @@ pub trait Paint: Display + Sized { 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()) } diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index b73f49c..c92d58d 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -94,6 +94,11 @@ impl Idx { crate::analysis::Analysis { idx: self } } + #[cfg(feature = "ownership")] + pub fn investor(&self) -> crate::investor::Investor { + crate::investor::Investor::new(&self.config) + } + pub(crate) fn resolve(&self, symbol: &str) -> Result { crate::api::resolve_symbol(symbol, &self.config.exchange) } diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index 4f28071..a76c104 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -85,6 +85,9 @@ pub struct CoreConfig { pub fundamental_ttl: u64, /// Cache root; `None` resolves `$XDG_CACHE_HOME/idx` or the platform default. pub cache_dir: Option, + /// Ownership database; `None` uses `ownership.db_path` from `config.toml`, + /// else `$XDG_DATA_HOME/idx/ownership.db`. + pub ownership_db: Option, /// Provider diagnostics on stderr. pub verbose: bool, } @@ -98,6 +101,7 @@ impl Default for CoreConfig { quote_ttl: 300, fundamental_ttl: 3600, cache_dir: None, + ownership_db: None, verbose: false, } } diff --git a/crates/idx-core/src/investor.rs b/crates/idx-core/src/investor.rs new file mode 100644 index 0000000..eaef76b --- /dev/null +++ b/crates/idx-core/src/investor.rs @@ -0,0 +1,633 @@ +//! Investor (ownership) data: queries over the local SQLite database, plus +//! the import, sync, and discovery that populate it. + +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use chrono::Utc; +use rusqlite::Connection; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::config::CoreConfig; +use crate::error::IdxError; +use crate::ownership::remote::{self, DiscoveredOwnershipPdf, OwnershipReportFamily}; +use crate::ownership::snapshot::{self, OwnershipSyncResult}; +use crate::ownership::types::{ + ChangeRow, ConcentrationMetrics, CrossHolderRow, Entity, EntityHoldings, GraphEdge, GraphNode, + InstitutionalFlow, KseiHolding, OwnershipRelease, OwnershipSource, TickerOwnership, + UnresolvedRow, +}; +use crate::ownership::{archive, db, entities, graph, parser, search, xlsx}; + +/// Ownership database access. Opening is lazy: each call opens (and, if +/// needed, creates and migrates) the database at the configured path. +pub struct Investor { + db_path: Option, + cache_dir: Option, +} + +impl Investor { + pub fn new(config: &CoreConfig) -> Self { + Self { + db_path: config.ownership_db.clone(), + cache_dir: config.cache_dir.clone(), + } + } + + /// The database path: `CoreConfig::ownership_db`, else `ownership.db_path` + /// from `config.toml`, else `$XDG_DATA_HOME/idx/ownership.db`. + pub fn db_path(&self) -> Result { + match &self.db_path { + Some(path) => Ok(path.clone()), + None => db::db_path(), + } + } + + fn connect(&self) -> Result { + db::open_db_at(&self.db_path()?) + } + + pub fn releases(&self) -> Result, IdxError> { + db::query_releases(&self.connect()?) + } + + /// Holders of `symbol` in the latest release, optionally limited to one + /// source (ranks and concentration are recomputed for that source). + pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result { + let conn = self.connect()?; + let symbol = symbol.trim().to_uppercase(); + 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 != HolderSource::All { + data.holders.retain(|h| match source { + HolderSource::Ksei => matches!(h.source, OwnershipSource::Ksei), + HolderSource::Bing => matches!(h.source, OwnershipSource::Bing), + HolderSource::All => true, + }); + for (i, row) in data.holders.iter_mut().enumerate() { + row.rank = i + 1; + } + let percentages: Vec = 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}" + ))); + } + Ok(data) + } + + /// Holdings of the entity whose name best matches `name` (shortest + /// canonical name containing it). + pub fn entity(&self, name: &str) -> Result { + let conn = self.connect()?; + let pattern = format!("%{}%", name.trim()); + let entity_id = conn + .query_row( + "SELECT id FROM entities WHERE canonical_name LIKE ?1 COLLATE NOCASE ORDER BY LENGTH(canonical_name) ASC LIMIT 1", + rusqlite::params![pattern], + |row| row.get::<_, i64>(0), + ) + .map_err(|_| IdxError::ParseError(format!("entity not found: {name}")))?; + db::query_entity_holdings(&conn, entity_id) + } + + pub fn search(&self, query: &str, limit: usize) -> Result, IdxError> { + search::fts_search(&self.connect()?, query, limit) + } + + pub fn cross_holders( + &self, + min_tickers: usize, + top: usize, + ) -> Result, IdxError> { + db::query_cross_holders(&self.connect()?, min_tickers, top) + } + + pub fn concentration( + &self, + by: ConcentrationRank, + top: usize, + ) -> Result, IdxError> { + db::query_concentration(&self.connect()?, by.as_str(), top) + } + + /// Institutional (Bing) flow; `None` when no flow data exists. + pub fn flow(&self, symbol: &str) -> Result, IdxError> { + let conn = self.connect()?; + let symbol = symbol.trim().to_uppercase(); + let ticker_id = db::get_ticker_id(&conn, &symbol)? + .ok_or_else(|| IdxError::SymbolNotFound(symbol.clone()))?; + db::query_bing_flow(&conn, ticker_id) + } + + pub fn changes(&self, from: &str, to: &str) -> Result, IdxError> { + db::query_changes(&self.connect()?, from, to) + } + + pub fn graph( + &self, + root: &str, + depth: usize, + ) -> Result<(Vec, Vec), IdxError> { + graph::query_ownership_graph(&self.connect()?, root, depth) + } + + pub fn unresolved(&self, limit: usize) -> Result, IdxError> { + db::list_unresolved(&self.connect()?, limit) + } + + pub fn map_alias(&self, alias: &str, entity: &str) -> Result<(), IdxError> { + db::manual_map(&self.connect()?, alias, entity) + } + + /// Merges entity `merge` into `keep`, reporting how many rows moved. + pub fn merge_entities(&self, keep: i64, merge: i64) -> Result { + let conn = self.connect()?; + let count = |table: &str| -> Result { + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE entity_id = ?1"), + rusqlite::params![merge], + |row| row.get(0), + ) + .map_err(|e| IdxError::DatabaseError(e.to_string())) + }; + let alias_updates = count("entity_aliases")?; + let ksei_holdings_updates = count("ksei_holdings")?; + let bing_holdings_updates = count("bing_holdings")?; + db::merge_entities(&conn, keep, merge)?; + Ok(MergeResult { + keep, + merge, + alias_updates, + ksei_holdings_updates, + bing_holdings_updates, + }) + } + + /// Lists IDX ownership reports (newest first), live from IDX. + pub fn discover( + &self, + family: Option, + limit: usize, + ) -> Result, IdxError> { + remote::discover_idx_ownership_reports(family, limit) + } + + /// Installs or refreshes the maintained snapshot. `manifest` overrides + /// the configured manifest location. + pub fn sync( + &self, + manifest: Option<&str>, + force: bool, + ) -> Result { + let manifest_source = snapshot::resolve_manifest_source(manifest)?; + let db_path = self.db_path()?; + snapshot::sync_snapshot(&manifest_source, &db_path, force) + } + + /// Imports one report. A release already imported (same SHA-256) is + /// skipped unless `force`, which replaces it. + pub fn import(&self, source: &ImportSource, force: bool) -> Result { + let input = self.resolve_import_input(source)?; + let conn = self.connect()?; + + let sha256 = sha256_file(&input.path)?; + if !force && db::release_exists(&conn, &sha256)? { + return Ok(ImportResult { + action: "skipped_existing", + inserted_rows: 0, + ticker_count: 0, + as_of_date: None, + sha256, + source_url: input.source_url, + }); + } + + let drafts = match input.format { + ImportFormat::Pdf => { + let raw_rows = parser::parse_ksei_pdf(&input.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 + } + ImportFormat::Archive => archive::parse_balancepos_file(&input.path)?, + ImportFormat::Xlsx => xlsx::parse_above1_xlsx_file(&input.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: 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, force)?; + + Ok(ImportResult { + action: if 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, + source_url: release.source_url, + }) + } + + fn resolve_import_input(&self, source: &ImportSource) -> Result { + match source { + ImportSource::File(path) => { + if !path.exists() { + return Err(IdxError::Io(format!( + "input ownership file not found: {}", + path.display() + ))); + } + Ok(ImportInput { + path: path.clone(), + source_url: None, + format: detect_local_import_format(path)?, + }) + } + ImportSource::Url(url) => { + let trimmed = url.trim(); + let format = validate_import_url(trimmed)?; + let target = self.download_path(trimmed, format)?; + match format { + ImportFormat::Xlsx => download_xlsx(trimmed, &target)?, + _ => download_pdf(trimmed, &target)?, + } + Ok(ImportInput { + path: target, + source_url: Some(trimmed.to_string()), + format, + }) + } + } + } + + fn download_path(&self, url: &str, format: ImportFormat) -> Result { + let extension = match format { + ImportFormat::Xlsx => ".xlsx", + _ => ".pdf", + }; + let cache_dir = match &self.cache_dir { + Some(dir) => dir.clone(), + None => crate::cache::cache_dir()?, + }; + let raw_dir = 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)) + } +} + +/// Where an import reads its report from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportSource { + /// Local `.xlsx`, `.pdf`, `.zip`, or `.txt`. + File(PathBuf), + /// Direct XLSX or PDF URL; IDX URLs use browser impersonation. + Url(String), +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImportResult { + /// `imported`, `reimported`, or `skipped_existing`. + pub action: &'static str, + pub inserted_rows: usize, + pub ticker_count: usize, + pub as_of_date: Option, + pub sha256: String, + pub source_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct MergeResult { + pub keep: i64, + pub merge: i64, + pub alias_updates: i64, + pub ksei_holdings_updates: i64, + pub bing_holdings_updates: i64, +} + +/// Holder sources for `Investor::ticker`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HolderSource { + All, + Ksei, + Bing, +} + +impl fmt::Display for HolderSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::All => "all", + Self::Ksei => "ksei", + Self::Bing => "bing", + }) + } +} + +impl FromStr for HolderSource { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "all" => Ok(Self::All), + "ksei" => Ok(Self::Ksei), + "bing" => Ok(Self::Bing), + _ => Err(IdxError::ParseError( + "invalid --source, expected: all|ksei|bing".to_string(), + )), + } + } +} + +/// Ranking metric for `Investor::concentration`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConcentrationRank { + Top1, + Top3, + Hhi, +} + +impl ConcentrationRank { + fn as_str(self) -> &'static str { + match self { + Self::Top1 => "top1", + Self::Top3 => "top3", + Self::Hhi => "hhi", + } + } +} + +impl FromStr for ConcentrationRank { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "top1" => Ok(Self::Top1), + "top3" => Ok(Self::Top3), + "hhi" => Ok(Self::Hhi), + _ => Err(IdxError::ParseError( + "invalid --by, expected: top1|top3|hhi".to_string(), + )), + } + } +} + +/// `all` (None) or one report family, with the aliases the CLI accepts. +pub fn parse_report_family(raw: &str) -> Result, IdxError> { + match raw.trim().to_ascii_lowercase().as_str() { + "all" => Ok(None), + "above1" | "above-1" | "above_1" => Ok(Some(OwnershipReportFamily::AboveOnePercent)), + "above5" | "above-5" | "above_5" => Ok(Some(OwnershipReportFamily::AboveFivePercent)), + "investor-type" | "investor_type" | "investortype" => { + Ok(Some(OwnershipReportFamily::InvestorTypeBreakdown)) + } + _ => Err(IdxError::ParseError( + "invalid --family, expected: all|above1|above5|investor-type".to_string(), + )), + } +} + +struct ImportInput { + path: PathBuf, + source_url: Option, + format: ImportFormat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ImportFormat { + Pdf, + Archive, + Xlsx, +} + +fn detect_local_import_format(path: &Path) -> Result { + match path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("pdf") => Ok(ImportFormat::Pdf), + Some("xlsx") if xlsx::supports_xlsx_file(path) => Ok(ImportFormat::Xlsx), + Some("zip") | Some("txt") if archive::supports_local_archive_file(path) => { + Ok(ImportFormat::Archive) + } + _ => Err(IdxError::InvalidInput(format!( + "unsupported local ownership file {}; expected a .xlsx, .pdf, .zip, or .txt input", + path.display() + ))), + } +} + +fn validate_import_url(url: &str) -> Result { + 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(ImportFormat::Pdf); + } + if normalized.ends_with(".xlsx") { + return Ok(ImportFormat::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(), + )) +} + +/// 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_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() + )) + }) +} + +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 { + 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()) +} + +#[cfg(test)] +mod tests { + use super::{ConcentrationRank, HolderSource, parse_report_family}; + use crate::ownership::remote::OwnershipReportFamily; + + #[test] + fn parses_holder_sources_and_ranks() { + assert_eq!( + " KSEI ".parse::().unwrap(), + HolderSource::Ksei + ); + assert!("x".parse::().is_err()); + assert_eq!( + "HHI".parse::().unwrap(), + ConcentrationRank::Hhi + ); + assert!("top2".parse::().is_err()); + } + + #[test] + fn parses_report_family_aliases() { + assert_eq!(parse_report_family("all").unwrap(), None); + assert_eq!( + parse_report_family("above_1").unwrap(), + Some(OwnershipReportFamily::AboveOnePercent) + ); + assert!(parse_report_family("above10").is_err()); + } +} diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 703911e..bcc2e52 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -10,6 +10,8 @@ pub mod config; pub mod curl_impersonate; pub mod error; pub mod financial; +#[cfg(feature = "ownership")] +pub mod investor; pub mod market; #[cfg(feature = "ownership")] pub mod ownership; diff --git a/crates/idx-core/src/ownership/db.rs b/crates/idx-core/src/ownership/db.rs index d98dbce..f298383 100644 --- a/crates/idx-core/src/ownership/db.rs +++ b/crates/idx-core/src/ownership/db.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use chrono::NaiveDate; use directories::ProjectDirs; @@ -129,13 +129,16 @@ pub fn ensure_schema(conn: &Connection) -> Result<(), IdxError> { /// Open ownership database connection and run idempotent schema migration. pub fn open_db() -> Result { - let db_path = db_path()?; + open_db_at(&db_path()?) +} +/// Open the ownership database at `db_path`, creating it and migrating the schema. +pub fn open_db_at(db_path: &Path) -> Result { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent).map_err(|e| IdxError::DatabaseError(e.to_string()))?; } - let conn = Connection::open(&db_path).map_err(|e| IdxError::DatabaseError(e.to_string()))?; + let conn = Connection::open(db_path).map_err(|e| IdxError::DatabaseError(e.to_string()))?; ensure_schema(&conn)?; Ok(conn) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38e2dd1..d5c10ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,8 @@ The repo is a Cargo workspace with one shared version: - `crates/idx-core` (library): the `Idx` entry point with `market()`, - `financial()`, and `analysis()` (cache policy and `Fetched` in `client.rs`), + `financial()`, `analysis()`, and `investor()` (cache policy and `Fetched` in + `client.rs`; ownership queries, import, sync, and discovery in `investor.rs`), providers (`api/`), `analysis/`, `cache`, `config` (`CoreConfig`), `ownership/` (SQLite, parsers, discovery, snapshot sync), `error`, and `runtime` (quiet/info/warn). It owns the shared test fixtures in @@ -14,8 +15,8 @@ The repo is a Cargo workspace with one shared version: color resolution (`config.rs`, `IdxConfig`), and rendering (`output/`). Its `tests/` hold the CLI integration and golden output suites. -Ownership import/sync orchestration still lives in -`crates/idx-cli/src/cli/ownership.rs` until phase 3c of `docs/DESIGN.md`. +`crates/idx-cli/src/cli/` holds no fetching, caching, or database logic: each +command parses arguments, makes one idx-core call, and renders the result. ## Domain Map diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b728fec..c726772 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-2 done. +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-3 done. ## Goal @@ -119,8 +119,8 @@ Key decisions: | ownership command orchestration (`cli/ownership.rs`) | `investor` | | `IdxConfig.output`, `no_color` | stay in `idx-cli` | -After the move, `cli/stocks.rs` and `cli/ownership.rs` should be argument -parsing plus a call into core plus rendering. +Done in phase 3: `cli/stocks.rs` and `cli/ownership.rs` are argument parsing, +one idx-core call, and rendering. ## Error Model From 1cf60d9dd776c0658df823cdd52529cef72df2a6 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 11:25:12 +0000 Subject: [PATCH 09/14] fix(ownership): read boolean XLSX cells as TRUE/FALSE The IDX above-1% workbook stores the share code TRUE (TRINITI DINAMIK) as a boolean cell holding 1, so its holders were imported under ticker "1". Map t="b" cells to what Excel displays. Co-Authored-By: Claude Opus 5.5 --- crates/idx-core/src/ownership/xlsx.rs | 45 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/idx-core/src/ownership/xlsx.rs b/crates/idx-core/src/ownership/xlsx.rs index d12be20..90f7348 100644 --- a/crates/idx-core/src/ownership/xlsx.rs +++ b/crates/idx-core/src/ownership/xlsx.rs @@ -214,18 +214,26 @@ fn parse_sheet_rows(xml: &str, shared_strings: &[String]) -> Result().map_err(|e| { - xml_error("worksheet", format!("bad shared string index: {e}")) - })?; - shared_strings.get(index).cloned().ok_or_else(|| { - xml_error( - "worksheet", - format!("shared string {index} out of range"), - ) - })? - } else { - value.clone() + let text = match cell_type.as_str() { + "s" => { + let index = value.trim().parse::().map_err(|e| { + xml_error("worksheet", format!("bad shared string index: {e}")) + })?; + shared_strings.get(index).cloned().ok_or_else(|| { + xml_error( + "worksheet", + format!("shared string {index} out of range"), + ) + })? + } + // Excel stores the share code TRUE (TRINITI DINAMIK) + // as a boolean cell holding 1. + "b" => match value.trim() { + "1" => "TRUE".to_string(), + "0" => "FALSE".to_string(), + _ => value.clone(), + }, + _ => value.clone(), }; current_row.cells.insert(column, text); } @@ -501,6 +509,19 @@ mod tests { assert!(drafts[0].locality.is_some()); } + #[test] + fn boolean_cells_read_as_excel_displays_them() { + let xml = r#" + 46295 + 1 + 0 + "#; + let rows = parse_sheet_rows(xml, &[]).expect("sheet parses"); + assert_eq!(rows[0].cells["A"], "46295"); + assert_eq!(rows[0].cells["B"], "TRUE"); + assert_eq!(rows[0].cells["C"], "FALSE"); + } + #[test] fn row_errors_report_excel_row_numbers() { let missing_investor = sheet_row( From 5b8a26499dd62c9eb44463d4f1303b8b2af2977c Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 11:32:11 +0000 Subject: [PATCH 10/14] fix(output): align tables whose cells carry color codes comfy-table counted ANSI escapes as cell width, so colored signal and change cells pushed the right border out of line on a terminal. Its custom_styling feature measures the visible width instead. Piped output has no color and is unchanged. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 71 +++++++++++++++++++++++++++++++++++++++ crates/idx-cli/Cargo.toml | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 35f189f..a0474da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -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" @@ -448,6 +487,12 @@ dependencies = [ "litrs", ] +[[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" @@ -845,6 +890,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" @@ -855,6 +906,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" @@ -1520,6 +1581,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" diff --git a/crates/idx-cli/Cargo.toml b/crates/idx-cli/Cargo.toml index ddb3186..c21f048 100644 --- a/crates/idx-cli/Cargo.toml +++ b/crates/idx-cli/Cargo.toml @@ -26,7 +26,7 @@ clap.workspace = true clap_complete = "4" serde.workspace = true serde_json.workspace = true -comfy-table = "7" +comfy-table = { version = "7", features = ["custom_styling"] } owo-colors = "4" toml.workspace = true chrono.workspace = true From cda12fe2db6ad0becd926ad300723e0dda7deaba Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 18:19:04 +0000 Subject: [PATCH 11/14] feat(mcp): add idx-mcp, a stdio MCP server over idx-core Phase 4 of docs/DESIGN.md. Eleven grouped tools (quotes, history, screen, company, analysis, compare, shareholders, investor holdings, search, ownership changes, status); ownership_sync is listed only with --allow-sync. Live results keep the Fetched envelope, errors use the CLI's {error, code, message} body, and investor tools on an empty database point at the sync path. idx-core gains Period/Interval FromStr, Investor::status, a ticker filter on changes, and Display for ErrorCode (now used by the CLI too). CLI output and goldens are unchanged. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- Cargo.lock | 387 +++++++++++++++++++++++++-- Cargo.toml | 2 +- README.md | 23 +- ROADMAP.md | 7 +- crates/idx-cli/src/cli/ownership.rs | 2 +- crates/idx-cli/src/output/mod.rs | 2 +- crates/idx-core/src/api/types.rs | 47 ++++ crates/idx-core/src/error.rs | 7 + crates/idx-core/src/investor.rs | 38 ++- crates/idx-mcp/Cargo.toml | 31 +++ crates/idx-mcp/LICENSE | 1 + crates/idx-mcp/src/main.rs | 51 ++++ crates/idx-mcp/src/params.rs | 177 +++++++++++++ crates/idx-mcp/src/server.rs | 391 ++++++++++++++++++++++++++++ crates/idx-mcp/tests/mcp.rs | 337 ++++++++++++++++++++++++ docs/DESIGN.md | 32 ++- flake.nix | 44 +++- 18 files changed, 1546 insertions(+), 38 deletions(-) create mode 100644 crates/idx-mcp/Cargo.toml create mode 120000 crates/idx-mcp/LICENSE create mode 100644 crates/idx-mcp/src/main.rs create mode 100644 crates/idx-mcp/src/params.rs create mode 100644 crates/idx-mcp/src/server.rs create mode 100644 crates/idx-mcp/tests/mcp.rs diff --git a/AGENTS.md b/AGENTS.md index b8a03c3..e3851bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # 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 @@ -19,6 +20,8 @@ crates/idx-cli/ binary `idx` ├── 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 ``` diff --git a/Cargo.lock b/Cargo.lock index a0474da..e88b191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,7 +257,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -410,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" @@ -427,7 +461,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -475,7 +509,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -487,6 +521,12 @@ 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" @@ -561,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" @@ -582,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" @@ -733,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" @@ -793,6 +938,19 @@ dependencies = [ "zip", ] +[[package]] +name = "idx-mcp" +version = "0.2.4" +dependencies = [ + "clap", + "idx-core", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -801,6 +959,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -984,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" @@ -1068,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" @@ -1083,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" @@ -1125,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" @@ -1211,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" @@ -1244,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]] @@ -1292,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" @@ -1327,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" @@ -1335,7 +1622,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1370,7 +1657,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1381,7 +1668,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1425,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" @@ -1466,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" @@ -1565,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", ] @@ -1638,7 +1993,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1703,7 +2058,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1714,7 +2069,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1923,7 +2278,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1944,7 +2299,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1964,7 +2319,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2004,7 +2359,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 68c9bd6..03b6714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/idx-core", "crates/idx-cli"] +members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"] [workspace.package] version = "0.2.4" diff --git a/README.md b/README.md index ce7de8d..8877a0c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ 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 low-pe | jq -r '.[].symbol' | idx stocks compare - +idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compare - ``` - stdout carries only data; warnings and errors go to stderr @@ -157,6 +157,27 @@ idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare - 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. + +Until it is released, build it from this repo: + +```bash +cargo install --path crates/idx-mcp # or: nix build .#idx-mcp +claude mcp add idx -- idx-mcp # Claude Code; other clients run `idx-mcp` +``` + +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 diff --git a/ROADMAP.md b/ROADMAP.md index 8813789..7380e4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,12 +15,11 @@ 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`) +- Publish `idx-mcp` (crates.io, npm, release assets); see `docs/DESIGN.md` phase 5 - JSON Schema export for command output - Composite `analyze` command (quote, fundamentals, ownership, sentiment) @@ -29,5 +28,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. diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index 7836c5b..d9b4261 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -601,7 +601,7 @@ fn handle_changes( args: &ChangesArgs, config: &IdxConfig, ) -> Result<(), IdxError> { - let changes = investor.changes(&args.from, &args.to)?; + let changes = investor.changes(&args.from, &args.to, None)?; if config.output.is_json() { return json::print_json(&changes); diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 212a104..2f1b8e4 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -215,7 +215,7 @@ pub fn emit_error(err: &IdxError, format: &OutputFormat) { } let payload = serde_json::json!({ "error": true, - "code": format!("{:?}", err.code()).to_uppercase(), + "code": err.code().to_string(), "message": err.to_string() }); let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal(); diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index 130046d..bf2552c 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -294,6 +294,17 @@ pub enum Period { } impl Period { + pub const ALL: [Self; 8] = [ + Self::OneDay, + Self::FiveDays, + Self::OneMonth, + Self::ThreeMonths, + Self::SixMonths, + Self::OneYear, + Self::TwoYears, + Self::FiveYears, + ]; + pub fn as_str(&self) -> &'static str { match self { Self::OneDay => "1d", @@ -320,6 +331,8 @@ pub enum Interval { } impl Interval { + pub const ALL: [Self; 3] = [Self::Day, Self::Week, Self::Month]; + pub fn as_str(&self) -> &'static str { match self { Self::Day => "1d", @@ -328,3 +341,37 @@ impl Interval { } } } + +impl std::str::FromStr for Period { + type Err = crate::error::IdxError; + + fn from_str(value: &str) -> Result { + parse_named(value, "period", &Self::ALL, Self::as_str) + } +} + +impl std::str::FromStr for Interval { + type Err = crate::error::IdxError; + + fn from_str(value: &str) -> Result { + parse_named(value, "interval", &Self::ALL, Self::as_str) + } +} + +fn parse_named( + value: &str, + what: &str, + all: &[T], + name: fn(&T) -> &'static str, +) -> Result { + all.iter() + .find(|item| name(item) == value) + .cloned() + .ok_or_else(|| { + let names: Vec<_> = all.iter().map(name).collect(); + crate::error::IdxError::InvalidInput(format!( + "invalid {what} '{value}' (expected one of: {})", + names.join(", ") + )) + }) +} diff --git a/crates/idx-core/src/error.rs b/crates/idx-core/src/error.rs index 7df9dc2..d991c6b 100644 --- a/crates/idx-core/src/error.rs +++ b/crates/idx-core/src/error.rs @@ -55,6 +55,13 @@ pub enum ErrorCode { NoMarketData, } +/// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`. +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}").to_uppercase()) + } +} + impl IdxError { pub fn code(&self) -> ErrorCode { match self { diff --git a/crates/idx-core/src/investor.rs b/crates/idx-core/src/investor.rs index eaef76b..c8285dc 100644 --- a/crates/idx-core/src/investor.rs +++ b/crates/idx-core/src/investor.rs @@ -7,7 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; -use chrono::Utc; +use chrono::{NaiveDate, Utc}; use rusqlite::Connection; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -55,6 +55,18 @@ impl Investor { db::query_releases(&self.connect()?) } + /// Whether the database has data, and which releases it holds. + pub fn status(&self) -> Result { + let db_path = self.db_path()?; + let releases = db::query_releases(&db::open_db_at(&db_path)?)?; + Ok(OwnershipStatus { + db_path: db_path.display().to_string(), + populated: !releases.is_empty(), + latest_as_of_date: releases.iter().map(|r| r.as_of_date).max(), + releases, + }) + } + /// Holders of `symbol` in the latest release, optionally limited to one /// source (ranks and concentration are recomputed for that source). pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result { @@ -132,8 +144,19 @@ impl Investor { db::query_bing_flow(&conn, ticker_id) } - pub fn changes(&self, from: &str, to: &str) -> Result, IdxError> { - db::query_changes(&self.connect()?, from, to) + /// Holder changes between two release dates, optionally for one ticker. + pub fn changes( + &self, + from: &str, + to: &str, + ticker: Option<&str>, + ) -> Result, IdxError> { + let mut rows = db::query_changes(&self.connect()?, from, to)?; + if let Some(ticker) = ticker { + let ticker = ticker.trim().to_uppercase(); + rows.retain(|row| row.ticker_code == ticker); + } + Ok(rows) } pub fn graph( @@ -380,6 +403,15 @@ pub struct MergeResult { pub bing_holdings_updates: i64, } +#[derive(Debug, Clone, Serialize)] +pub struct OwnershipStatus { + pub db_path: String, + /// False until `sync` or `import` has loaded at least one release. + pub populated: bool, + pub latest_as_of_date: Option, + pub releases: Vec, +} + /// Holder sources for `Investor::ticker`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HolderSource { diff --git a/crates/idx-mcp/Cargo.toml b/crates/idx-mcp/Cargo.toml new file mode 100644 index 0000000..c055a91 --- /dev/null +++ b/crates/idx-mcp/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "idx-mcp" +description = "MCP server for Indonesian stock market (IDX) market, financial, and ownership data" +version.workspace = true +edition.workspace = true +# rmcp requires 1.88. +rust-version = "1.88" +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme = "../../README.md" +keywords = ["idx", "stocks", "indonesia", "mcp", "finance"] +categories = ["finance"] +include = ["/Cargo.toml", "/LICENSE", "/src/**"] + +[[bin]] +name = "idx-mcp" +path = "src/main.rs" + +[dependencies] +idx-core.workspace = true +clap.workspace = true +rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } +schemars = "1" +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } + +[dev-dependencies] +# Tests drive the binary with the mock provider; release builds exclude it. +idx-core = { workspace = true, features = ["testing"] } diff --git a/crates/idx-mcp/LICENSE b/crates/idx-mcp/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/idx-mcp/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/idx-mcp/src/main.rs b/crates/idx-mcp/src/main.rs new file mode 100644 index 0000000..19b2c10 --- /dev/null +++ b/crates/idx-mcp/src/main.rs @@ -0,0 +1,51 @@ +//! `idx-mcp`: an MCP server (stdio) over idx-core. See docs/DESIGN.md. + +mod params; +mod server; + +use std::process::ExitCode; + +use clap::Parser; +use idx_core::config::CoreConfig; +use rmcp::ServiceExt; + +#[derive(Debug, Parser)] +#[command( + name = "idx-mcp", + version, + about = "MCP server (stdio) for Indonesian stock market (IDX) data" +)] +struct Args { + /// Expose the ownership_sync tool, which downloads the KSEI ownership + /// snapshot (several MB) into the local database. + #[arg(long)] + allow_sync: bool, +} + +#[tokio::main] +async fn main() -> ExitCode { + let args = Args::parse(); + let config = match CoreConfig::load() { + Ok(config) => config, + Err(err) => { + eprintln!("error: {err}"); + return ExitCode::from(err.exit_code() as u8); + } + }; + + let server = server::IdxServer::new(config, args.allow_sync); + let running = match server.serve(rmcp::transport::stdio()).await { + Ok(running) => running, + Err(err) => { + eprintln!("error: {err}"); + return ExitCode::FAILURE; + } + }; + match running.waiting().await { + Ok(_) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs new file mode 100644 index 0000000..74e3fee --- /dev/null +++ b/crates/idx-mcp/src/params.rs @@ -0,0 +1,177 @@ +//! Tool inputs. String enums are validated by idx-core's `FromStr`, so the +//! accepted values and error messages match the CLI; the schemas list them. + +use idx_core::api::types::{Interval, Period}; +use idx_core::market::{ScreenFilter, ScreenRegion}; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SymbolsParams { + /// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. + pub symbols: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct HistoryParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Lookback window (default "3mo"). + #[serde(default)] + #[schemars(schema_with = "period_schema")] + pub period: Option, + /// Candle size (default "1d"). + #[serde(default)] + #[schemars(schema_with = "interval_schema")] + pub interval: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ScreenParams { + /// Screen to run (default "top-performers"). + #[serde(default)] + #[schemars(schema_with = "filter_schema")] + pub filter: Option, + /// Market region (default "id"). + #[serde(default)] + #[schemars(schema_with = "region_schema")] + pub region: Option, + /// Maximum results (default 50). + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum CompanySection { + Profile, + Financials, + Earnings, + Sentiment, + Insights, + News, +} + +impl CompanySection { + pub const ALL: [Self; 6] = [ + Self::Profile, + Self::Financials, + Self::Earnings, + Self::Sentiment, + Self::Insights, + Self::News, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Profile => "profile", + Self::Financials => "financials", + Self::Earnings => "earnings", + Self::Sentiment => "sentiment", + Self::Insights => "insights", + Self::News => "news", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CompanyParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Sections to fetch (default: all). + #[serde(default)] + pub sections: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AnalysisKind { + Technical, + Growth, + Valuation, + Risk, +} + +impl AnalysisKind { + pub const ALL: [Self; 4] = [Self::Technical, Self::Growth, Self::Valuation, Self::Risk]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Technical => "technical", + Self::Growth => "growth", + Self::Valuation => "valuation", + Self::Risk => "risk", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AnalyzeParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Analyses to run (default: all). + #[serde(default)] + pub kinds: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShareholdersParams { + /// IDX ticker, e.g. "BBCA". + pub ticker: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EntityParams { + /// Investor name or part of it, e.g. "DWIMURIA"; the closest match is used. + pub entity: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchParams { + /// Full-text query over investor names. + pub query: String, + /// Maximum results (default 20). + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChangesParams { + /// Earlier release date, YYYY-MM-DD (see ownership_status). + pub from: String, + /// Later release date, YYYY-MM-DD. + pub to: String, + /// Only this ticker's changes. + #[serde(default)] + pub ticker: Option, + /// Maximum rows returned (default 200); `total` reports the full count. + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SyncParams { + /// Re-download even when the local snapshot is current. + #[serde(default)] + pub force: bool, +} + +fn string_enum(values: Vec<&'static str>) -> Schema { + json_schema!({ "type": "string", "enum": values }) +} + +fn period_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(Period::ALL.iter().map(Period::as_str).collect()) +} + +fn interval_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(Interval::ALL.iter().map(Interval::as_str).collect()) +} + +fn filter_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(ScreenFilter::ALL.map(ScreenFilter::as_str).to_vec()) +} + +fn region_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec()) +} diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs new file mode 100644 index 0000000..e9412d2 --- /dev/null +++ b/crates/idx-mcp/src/server.rs @@ -0,0 +1,391 @@ +//! MCP tools over idx-core. Each call runs on a blocking thread with a fresh +//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk +//! cache and ownership database. + +use idx_core::analysis::fundamental::FundamentalReport; +use idx_core::config::CoreConfig; +use idx_core::error::IdxError; +use idx_core::financial::{EarningsFilter, StatementFilter}; +use idx_core::investor::{HolderSource, Investor}; +use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; +use idx_core::{CachePolicy, Fetched, Idx}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig}; +use rmcp::{ErrorData, ServerHandler, tool, tool_handler, tool_router}; +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use crate::params::{ + AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams, + HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, +}; + +const SYNC_TOOL: &str = "ownership_sync"; +const NEWS_LIMIT: usize = 10; +const SCREEN_LIMIT: usize = 50; +const SEARCH_LIMIT: usize = 20; +const CHANGES_LIMIT: usize = 200; + +const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \ +tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \ +stale=true means an expired cache entry was served. Ownership tools read a local KSEI \ +database of holders above 1%: call ownership_status first to see which release dates \ +exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \ +stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT."; + +type ToolResult = Result; + +#[derive(Clone)] +pub struct IdxServer { + config: CoreConfig, + allow_sync: bool, + tool_router: ToolRouter, +} + +impl IdxServer { + pub fn new(config: CoreConfig, allow_sync: bool) -> Self { + let mut tool_router = Self::tool_router(); + if !allow_sync { + tool_router.remove_route(SYNC_TOOL); + } + Self { + config, + allow_sync, + tool_router, + } + } + + /// Runs `f` against live data on a blocking thread. + async fn live(&self, f: F) -> ToolResult + where + T: Serialize + Send + 'static, + F: FnOnce(&Idx) -> Result + Send + 'static, + { + let config = self.config.clone(); + blocking(move || f(&Idx::new(config)?)).await + } + + /// Runs `f` against the ownership database, failing with a hint to sync + /// when it is empty. + async fn local(&self, f: F) -> ToolResult + where + T: Serialize + Send + 'static, + F: FnOnce(&Investor) -> Result + Send + 'static, + { + let investor = Investor::new(&self.config); + let hint = if self.allow_sync { + "call ownership_sync first" + } else { + "run `idx ownership sync`, or restart idx-mcp with --allow-sync" + }; + blocking(move || { + if !investor.status()?.populated { + return Err(IdxError::NoMarketData(format!( + "ownership database is empty; {hint}" + ))); + } + f(&investor) + }) + .await + } +} + +#[tool_router] +impl IdxServer { + #[tool( + description = "Latest quotes (price, change, volume, 52-week range, market cap) \ +for one or more IDX tickers." + )] + async fn get_quotes(&self, params: Parameters) -> ToolResult { + let SymbolsParams { symbols } = params.0; + self.live(move |idx| { + let quotes = idx.market().quotes(&symbols, CachePolicy::Default)?; + Ok(json!({ "quotes": quotes })) + }) + .await + } + + #[tool(description = "OHLCV price history for one IDX ticker.")] + async fn get_price_history(&self, params: Parameters) -> ToolResult { + let HistoryParams { + symbol, + period, + interval, + } = params.0; + self.live(move |idx| { + let period = period.as_deref().unwrap_or("3mo").parse()?; + let interval = interval.as_deref().unwrap_or("1d").parse()?; + let source = idx.market().history_source(None); + idx.market() + .history(&symbol, &period, &interval, source, CachePolicy::Default) + }) + .await + } + + #[tool( + description = "Run a stock screen (top/worst performers, high dividend, low P/E, \ +52-week highs/lows, high volume, large cap)." + )] + async fn screen_stocks(&self, params: Parameters) -> ToolResult { + let ScreenParams { + filter, + region, + limit, + } = params.0; + self.live(move |idx| { + let query = ScreenQuery { + filter: filter + .as_deref() + .unwrap_or("top-performers") + .parse::()?, + region: region.as_deref().unwrap_or("id").parse::()?, + limit: limit.unwrap_or(SCREEN_LIMIT), + }; + idx.market().screen(&query, CachePolicy::Default) + }) + .await + } + + #[tool( + description = "Company data for one IDX ticker: profile, financial statements, \ +earnings, analyst sentiment, insights, and news. Sections that fail are listed under \ +`errors`; the call fails only if every section fails." + )] + async fn get_company(&self, params: Parameters) -> ToolResult { + let CompanyParams { symbol, sections } = params.0; + let sections = sections.unwrap_or_else(|| CompanySection::ALL.to_vec()); + self.live(move |idx| { + let financial = idx.financial(); + let policy = CachePolicy::Default; + let mut report = SectionReport::new(&symbol); + for section in sections { + let name = section.as_str(); + match section { + CompanySection::Profile => report.add(name, financial.profile(&symbol, policy)), + CompanySection::Financials => report.add( + name, + financial.statements(&symbol, &StatementFilter::all(), policy), + ), + CompanySection::Earnings => report.add( + name, + financial.earnings(&symbol, EarningsFilter::default(), policy), + ), + CompanySection::Sentiment => { + report.add(name, financial.sentiment(&symbol, policy)) + } + CompanySection::Insights => { + report.add(name, financial.insights(&symbol, policy)) + } + CompanySection::News => { + report.add(name, financial.news(&symbol, NEWS_LIMIT, policy)) + } + } + } + report.finish() + }) + .await + } + + #[tool( + description = "Analyze one IDX ticker: technical signals (trend, RSI, MACD, \ +moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail \ +are listed under `errors`; the call fails only if every analysis fails." + )] + async fn analyze_stock(&self, params: Parameters) -> ToolResult { + let AnalyzeParams { symbol, kinds } = params.0; + let kinds = kinds.unwrap_or_else(|| AnalysisKind::ALL.to_vec()); + self.live(move |idx| { + let analysis = idx.analysis(); + let policy = CachePolicy::Default; + let mut report = SectionReport::new(&symbol); + for kind in kinds { + let name = kind.as_str(); + match kind { + AnalysisKind::Technical => { + let source = idx.market().history_source(None); + report.add(name, analysis.technical(&symbol, source, policy)) + } + AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)), + AnalysisKind::Valuation => { + report.add(name, analysis.valuation(&symbol, policy)) + } + AnalysisKind::Risk => report.add(name, analysis.risk(&symbol, policy)), + } + } + report.finish() + }) + .await + } + + #[tool( + description = "Compare fundamental reports (growth, valuation, risk) across IDX \ +tickers. Tickers that fail are listed under `failures`." + )] + async fn compare_stocks(&self, params: Parameters) -> ToolResult { + let SymbolsParams { symbols } = params.0; + self.live(move |idx| { + let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; + let failures: Vec = comparison + .failures + .iter() + .map(|(symbol, err)| { + let mut body = error_body(err); + body.insert("symbol".into(), json!(symbol)); + Value::Object(body) + }) + .collect(); + let reports: &[Fetched] = &comparison.reports; + Ok(json!({ "reports": reports, "failures": failures })) + }) + .await + } + + #[tool( + description = "Shareholders above 1% of an IDX ticker from the latest KSEI release, \ +with concentration metrics (top1, top3, HHI, free float)." + )] + async fn get_shareholders(&self, params: Parameters) -> ToolResult { + let ShareholdersParams { ticker } = params.0; + self.local(move |investor| investor.ticker(&ticker, HolderSource::All)) + .await + } + + #[tool(description = "Every IDX holding (above 1%) of one investor, matched by name.")] + async fn get_investor_holdings(&self, params: Parameters) -> ToolResult { + let EntityParams { entity } = params.0; + self.local(move |investor| investor.entity(&entity)).await + } + + #[tool(description = "Search investor names in the KSEI ownership database.")] + async fn search_investors(&self, params: Parameters) -> ToolResult { + let SearchParams { query, limit } = params.0; + self.local(move |investor| { + let entities = investor.search(&query, limit.unwrap_or(SEARCH_LIMIT))?; + Ok(json!({ "entities": entities })) + }) + .await + } + + #[tool( + description = "Holder changes (new, exit, increase, decrease) between two KSEI \ +release dates, optionally for one ticker." + )] + async fn get_ownership_changes(&self, params: Parameters) -> ToolResult { + let ChangesParams { + from, + to, + ticker, + limit, + } = params.0; + self.local(move |investor| { + let mut changes = investor.changes(&from, &to, ticker.as_deref())?; + let total = changes.len(); + changes.truncate(limit.unwrap_or(CHANGES_LIMIT)); + Ok(json!({ "from": from, "to": to, "total": total, "changes": changes })) + }) + .await + } + + #[tool( + description = "Ownership database status: whether it has data, and the imported \ +KSEI release dates." + )] + async fn ownership_status(&self) -> ToolResult { + let investor = Investor::new(&self.config); + blocking(move || investor.status()).await + } + + #[tool( + description = "Download or refresh the KSEI ownership snapshot (several MB) into \ +the local database." + )] + async fn ownership_sync(&self, params: Parameters) -> ToolResult { + let SyncParams { force } = params.0; + let investor = Investor::new(&self.config); + blocking(move || investor.sync(None, force)).await + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for IdxServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("idx-mcp", env!("CARGO_PKG_VERSION"))) + .with_instructions(INSTRUCTIONS) + } +} + +/// Results keyed by section, with failed sections under `errors`. +struct SectionReport { + symbol: String, + sections: Map, + errors: Map, + last_error: Option, +} + +impl SectionReport { + fn new(symbol: &str) -> Self { + Self { + symbol: symbol.to_string(), + sections: Map::new(), + errors: Map::new(), + last_error: None, + } + } + + fn add(&mut self, name: &str, result: Result) { + match result.and_then(to_json) { + Ok(value) => { + self.sections.insert(name.to_string(), value); + } + Err(err) => { + self.errors + .insert(name.to_string(), Value::Object(error_body(&err))); + self.last_error = Some(err); + } + } + } + + fn finish(self) -> Result { + if self.sections.is_empty() + && let Some(err) = self.last_error + { + return Err(err); + } + let mut out = Map::new(); + out.insert("symbol".into(), json!(self.symbol)); + out.extend(self.sections); + if !self.errors.is_empty() { + out.insert("errors".into(), Value::Object(self.errors)); + } + Ok(Value::Object(out)) + } +} + +async fn blocking(f: F) -> ToolResult +where + T: Serialize + Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + let result = tokio::task::spawn_blocking(move || f().and_then(to_json)) + .await + .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; + Ok(match result { + Ok(value) => CallToolResult::structured(value), + Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))), + }) +} + +fn to_json(value: T) -> Result { + serde_json::to_value(value).map_err(|err| IdxError::ParseError(err.to_string())) +} + +/// The CLI's JSON error shape: `{error, code, message}`. +fn error_body(err: &IdxError) -> Map { + let mut body = Map::new(); + body.insert("error".into(), json!(true)); + body.insert("code".into(), json!(err.code().to_string())); + body.insert("message".into(), json!(err.to_string())); + body +} diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs new file mode 100644 index 0000000..8698570 --- /dev/null +++ b/crates/idx-mcp/tests/mcp.rs @@ -0,0 +1,337 @@ +//! Drives the idx-mcp binary over stdio JSON-RPC with the mock provider and a +//! fixture-built ownership database. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use idx_core::config::CoreConfig; +use idx_core::investor::{ImportSource, Investor}; +use serde_json::{Value, json}; + +const TOOLS: [&str; 11] = [ + "analyze_stock", + "compare_stocks", + "get_company", + "get_investor_holdings", + "get_ownership_changes", + "get_price_history", + "get_quotes", + "get_shareholders", + "ownership_status", + "screen_stocks", + "search_investors", +]; + +struct Client { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, + _home: tempdir::Dir, +} + +impl Client { + fn start(args: &[&str], seed: bool) -> Self { + let home = tempdir::Dir::new(); + if seed { + seed_ownership_db(&home.path().join("data/idx/ownership.db")); + } + let mut child = Command::new(env!("CARGO_BIN_EXE_idx-mcp")) + .args(args) + // The mock provider reads `tests/fixtures/...` relative to here. + .current_dir(core_dir()) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("XDG_CONFIG_HOME", home.path().join("config")) + .env("XDG_CACHE_HOME", home.path().join("cache")) + .env("XDG_DATA_HOME", home.path().join("data")) + .env("IDX_USE_MOCK_PROVIDER", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn idx-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut client = Self { + child, + stdin, + stdout, + next_id: 1, + _home: home, + }; + let init = client.request( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"} + }), + ); + assert_eq!(init["serverInfo"]["name"], "idx-mcp"); + client.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + client + } + + fn send(&mut self, message: Value) { + writeln!(self.stdin, "{message}").expect("write request"); + self.stdin.flush().expect("flush request"); + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read response"); + assert!(read > 0, "idx-mcp closed stdout"); + let message: Value = serde_json::from_str(&line).expect("JSON-RPC line"); + if message["id"] == id { + assert!(message.get("error").is_none(), "{method}: {message}"); + return message["result"].clone(); + } + } + } + + fn tool_names(&mut self) -> Vec { + let result = self.request("tools/list", json!({})); + let mut names: Vec = result["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_string()) + .collect(); + names.sort(); + names + } + + /// Calls a tool, returning `(is_error, structured_content)`. + fn call(&mut self, name: &str, arguments: Value) -> (bool, Value) { + let result = self.request("tools/call", json!({"name": name, "arguments": arguments})); + let text = result["content"][0]["text"].as_str().unwrap(); + let structured = result["structuredContent"].clone(); + assert_eq!(serde_json::from_str::(text).unwrap(), structured); + (result["isError"] == true, structured) + } + + fn ok(&mut self, name: &str, arguments: Value) -> Value { + let (is_error, body) = self.call(name, arguments); + assert!(!is_error, "{name} failed: {body}"); + body + } +} + +impl Drop for Client { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// The above-1% XLSX (2026-08-31) and two KSEI archive months (2026-01-30, +/// 2026-02-27), as in the CLI golden tests. +fn seed_ownership_db(db: &Path) { + let fixtures = core_dir().join("tests/fixtures"); + let staging = db.parent().unwrap().join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + // The importer derives the release from the IDX file name. + let xlsx = staging.join("peng-2026-08-00017-satu-persen.xlsx"); + std::fs::copy(fixtures.join("ksei_above1_20260831_excerpt.xlsx"), &xlsx).unwrap(); + + let investor = Investor::new(&CoreConfig { + ownership_db: Some(db.to_path_buf()), + ..CoreConfig::default() + }); + for file in [ + xlsx, + fixtures.join("ksei_balancepos_20260130_excerpt.txt"), + fixtures.join("ksei_balancepos_20260227_excerpt.txt"), + ] { + investor + .import(&ImportSource::File(file), false) + .expect("seed import"); + } +} + +fn core_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../idx-core") + .canonicalize() + .expect("idx-core crate dir") +} + +fn assert_fetched(value: &Value) { + assert!(value.get("data").is_some(), "{value}"); + assert_eq!(value["stale"], false, "{value}"); + assert!(value["source"].is_string(), "{value}"); + assert!(value["fetched_at"].is_string(), "{value}"); +} + +#[test] +fn lists_grouped_tools_and_hides_sync_unless_allowed() { + let mut client = Client::start(&[], false); + assert_eq!(client.tool_names(), TOOLS); + + let mut client = Client::start(&["--allow-sync"], false); + let mut expected: Vec<&str> = TOOLS.to_vec(); + expected.push("ownership_sync"); + expected.sort(); + assert_eq!(client.tool_names(), expected); +} + +#[test] +fn live_tools_wrap_data_with_freshness() { + let mut client = Client::start(&[], false); + + let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]})); + assert_fetched("es["quotes"][0]); + assert_eq!(quotes["quotes"][0]["data"]["symbol"], "BBCA.JK"); + + let history = client.ok("get_price_history", json!({"symbol": "BBCA"})); + assert_fetched(&history); + assert!(!history["data"].as_array().unwrap().is_empty()); + + let screen = client.ok("screen_stocks", json!({"limit": 1})); + assert_fetched(&screen); + assert_eq!(screen["data"].as_array().unwrap().len(), 1); + + let company = client.ok("get_company", json!({"symbol": "BBCA"})); + assert_eq!(company["symbol"], "BBCA"); + for section in [ + "profile", + "financials", + "earnings", + "sentiment", + "insights", + "news", + ] { + assert_fetched(&company[section]); + } + assert!(company.get("errors").is_none(), "{company}"); + + let only_profile = client.ok( + "get_company", + json!({"symbol": "BBCA", "sections": ["profile"]}), + ); + assert!(only_profile.get("financials").is_none()); + + let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"})); + for kind in ["technical", "growth", "valuation", "risk"] { + assert_fetched(&analysis[kind]); + } + + let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); + assert_eq!(compared["reports"].as_array().unwrap().len(), 2); + assert_eq!(compared["failures"], json!([])); +} + +#[test] +fn errors_carry_cli_codes() { + let mut client = Client::start(&[], false); + + let (is_error, body) = client.call( + "get_price_history", + json!({"symbol": "BBCA", "period": "7y"}), + ); + assert!(is_error); + assert_eq!(body["code"], "INVALIDINPUT"); + assert_eq!(body["error"], true); + + let (is_error, body) = client.call("screen_stocks", json!({"filter": "cheap"})); + assert!(is_error); + assert_eq!(body["code"], "INVALIDINPUT"); + + // Empty ownership database: investor tools point at the sync path. + let status = client.ok("ownership_status", json!({})); + assert_eq!(status["populated"], false); + let (is_error, body) = client.call("get_shareholders", json!({"ticker": "BBCA"})); + assert!(is_error); + assert_eq!(body["code"], "NOMARKETDATA"); + assert!(body["message"].as_str().unwrap().contains("--allow-sync")); +} + +#[test] +fn ownership_tools_read_the_local_database() { + let mut client = Client::start(&[], true); + + let status = client.ok("ownership_status", json!({})); + assert_eq!(status["populated"], true); + assert_eq!(status["latest_as_of_date"], "2026-08-31"); + assert_eq!(status["releases"].as_array().unwrap().len(), 3); + + let holders = client.ok("get_shareholders", json!({"ticker": "bbca"})); + assert_eq!(holders["ticker"]["code"], "BBCA"); + assert!(!holders["holders"].as_array().unwrap().is_empty()); + + let (is_error, body) = client.call("get_shareholders", json!({"ticker": "ZZZZ"})); + assert!(is_error, "{body}"); + + let holdings = client.ok("get_investor_holdings", json!({"entity": "DWIMURIA"})); + assert!( + !holdings["holdings"].as_array().unwrap().is_empty(), + "{holdings}" + ); + + let found = client.ok("search_investors", json!({"query": "DWIMURIA"})); + assert!(!found["entities"].as_array().unwrap().is_empty(), "{found}"); + + let all = client.ok( + "get_ownership_changes", + json!({"from": "2026-01-30", "to": "2026-02-27"}), + ); + let total = all["total"].as_u64().unwrap(); + assert!(total > 1, "{all}"); + let ticker = all["changes"][0]["ticker_code"] + .as_str() + .unwrap() + .to_string(); + + let limited = client.ok( + "get_ownership_changes", + json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 1}), + ); + assert_eq!(limited["total"], total); + assert_eq!(limited["changes"].as_array().unwrap().len(), 1); + + let one = client.ok( + "get_ownership_changes", + json!({"from": "2026-01-30", "to": "2026-02-27", "ticker": ticker.to_lowercase()}), + ); + let rows = one["changes"].as_array().unwrap(); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|row| row["ticker_code"] == ticker.as_str())); +} + +/// A temporary directory removed on drop (no extra dependency). +mod tempdir { + use super::*; + + pub struct Dir(PathBuf); + + impl Dir { + pub fn new() -> Self { + use std::sync::atomic::{AtomicUsize, Ordering}; + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "idx-mcp-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c726772..fe57bcb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-3 done. +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-4 done. ## Goal @@ -228,9 +228,25 @@ keeps the tool list short for agents: | `get_ownership_changes` | `from`, `to`, `ticker?` | `investor().changes` | | `ownership_status` | none | `investor().status` | -Ownership bootstrap: if the local DB is empty, investor tools return an error -telling the agent to call `ownership_sync`. That tool is opt-in through a -server flag, because it downloads several MB. +Ownership bootstrap: if the local DB is empty, investor tools return a +`NOMARKETDATA` error telling the agent to call `ownership_sync`. That tool is +listed only when the server runs with `--allow-sync`, because it downloads +several MB. + +As built (phase 4): + +- Live tools return `Fetched` objects; `get_quotes` wraps its list as + `{quotes}`. `get_company` and `analyze_stock` return one key per section + (default: all) and list failed sections under `errors`, failing only if every + section fails. +- `get_ownership_changes` returns at most `limit` rows (default 200) with the + full count in `total`. +- Each call runs on a blocking thread with a fresh `Idx`, so tools share the + CLI's config, cache, and database. With a shared cache, tool data equals + the CLI's JSON value for value (checked live for every tool); object keys + come out sorted. +- `crates/idx-mcp/tests/mcp.rs` drives the binary over stdio JSON-RPC with + the mock provider; `nix flake check` also smoke-tests the packaged server. ## Frontend: idx-api (later) @@ -324,9 +340,5 @@ every phase passes both parity gates. 1. Are the exit-code classes right, and should `compare` with partial results still exit `0`? -2. MCP tool granularity: is the grouped table above right, or should it be one - tool per CLI command? -3. Should `ownership_sync` be available to agents by default? -4. Which source should back IHSG index, breadth, and sector data, and should it - land before or after idx-mcp? -5. Is idx-api self-hosted only? +2. Which source should back IHSG index, breadth, and sector data? +3. Is idx-api self-hosted only? diff --git a/flake.nix b/flake.nix index 2ede4f4..9c53422 100644 --- a/flake.nix +++ b/flake.nix @@ -37,10 +37,12 @@ ./crates/idx-core/tests/fixtures ./crates/idx-cli/Cargo.toml ./crates/idx-cli/src + ./crates/idx-mcp/Cargo.toml + ./crates/idx-mcp/src ]; testSrc = lib.fileset.toSource { root = ./.; - fileset = lib.fileset.unions (rustFiles ++ [ ./crates/idx-cli/tests ]); + fileset = lib.fileset.unions (rustFiles ++ [ ./crates/idx-cli/tests ./crates/idx-mcp/tests ]); }; src = lib.fileset.toSource { root = ./.; @@ -65,6 +67,7 @@ idxPackage = craneLib.buildPackage (commonArgs // { cargoArtifacts = releaseCargoArtifacts; CARGO_PROFILE = "release"; + cargoExtraArgs = "--locked -p idx-cli"; doCheck = false; nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; postInstall = '' @@ -72,6 +75,19 @@ --prefix PATH : "${lib.makeBinPath runtimeDeps}" ''; }); + mcpPackage = craneLib.buildPackage (commonArgs // { + pname = "idx-mcp"; + cargoArtifacts = releaseCargoArtifacts; + CARGO_PROFILE = "release"; + cargoExtraArgs = "--locked -p idx-mcp"; + doCheck = false; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; + postInstall = '' + wrapProgram "$out/bin/idx-mcp" \ + --prefix PATH : "${lib.makeBinPath runtimeDeps}" + ''; + meta.mainProgram = "idx-mcp"; + }); # Self-contained ownership snapshot publisher for scheduled hosts # (e.g. a NixOS systemd timer): no checkout, `nix develop` or cargo # build at run time. Wraps the repo scripts with a pinned `idx`. @@ -147,6 +163,7 @@ in { packages.default = idxPackage; + packages.idx-mcp = mcpPackage; packages.ownership-publisher = ownershipPublisher; apps.default = { type = "app"; @@ -155,6 +172,7 @@ }; checks = { default = idxPackage; + idx-mcp = mcpPackage; ownership-publisher = pkgs.runCommand "idx-ownership-publisher-smoke" { } '' ${ownershipPublisher}/bin/idx-ownership-publish --help touch "$out" @@ -187,6 +205,8 @@ ./crates/idx-cli/tests ./crates/idx-core/LICENSE ./crates/idx-cli/LICENSE + ./crates/idx-mcp/tests + ./crates/idx-mcp/LICENSE ./LICENSE ./README.md ]); @@ -208,6 +228,13 @@ (cd "$verify/idx-cli-${version}" && cargo build --offline \ --config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \ --target-dir "$verify/target") + # idx-mcp: same as idx-cli. + cargo package -p idx-mcp --registry crates-io --no-verify --exclude-lockfile --offline --allow-dirty + tar -xzf target/package/idx-mcp-${version}.crate -C "$verify" + cp Cargo.lock "$verify/idx-mcp-${version}/" + (cd "$verify/idx-mcp-${version}" && cargo build --offline \ + --config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \ + --target-dir "$verify/target") ''; doInstallCargoArtifacts = false; installPhaseCommand = "touch $out"; @@ -230,6 +257,21 @@ jq -e '.code == "CACHEMISS"' err.json touch "$out" ''; + # The packaged server answers initialize and lists its tools over + # stdio; tool behavior is covered by crates/idx-mcp/tests. + packaged-mcp = pkgs.runCommand "idx-packaged-mcp" + { nativeBuildInputs = with pkgs; [ coreutils jq ]; } + '' + export HOME="$TMPDIR" XDG_CACHE_HOME="$TMPDIR/cache" XDG_CONFIG_HOME="$TMPDIR/config" XDG_DATA_HOME="$TMPDIR/data" + { + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' + echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + sleep 2 + } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 11' out.jsonl + touch "$out" + ''; install-script-sh = installerCheck "sh"; install-script-dash = installerCheck "dash"; shellcheck = pkgs.runCommand "idx-shellcheck" { From ec17a1bd059daa29b38b82318954bbbae266aee1 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 18:52:13 +0000 Subject: [PATCH 12/14] fix(ownership): update syncs when a same-date snapshot is rebuilt A snapshot republished for the same as-of date (the TRUE ticker fix) kept the date, latest release, and release count, so sync saw nothing to do and existing installs kept the bad data without --force. The manifest gains an optional snapshot.replaces_sqlite_sha256 listing earlier builds for that date. Queries never write to a synced file, so a local database byte-identical to a listed build is replaced; one that matches nothing (local imports or edits) still needs --force. The publisher fills the list on a same-date rebuild. Older clients ignore the field. Co-Authored-By: Claude Opus 5.5 --- crates/idx-core/src/ownership/snapshot.rs | 145 +++++++++++++++++++++ docs/OWNERSHIP_PUBLISH.md | 4 +- docs/OWNERSHIP_SYNC.md | 9 +- scripts/publish-ownership-snapshot-test.sh | 16 +++ scripts/publish-ownership-snapshot.sh | 19 +++ 5 files changed, 191 insertions(+), 2 deletions(-) diff --git a/crates/idx-core/src/ownership/snapshot.rs b/crates/idx-core/src/ownership/snapshot.rs index ad229ed..d23af4d 100644 --- a/crates/idx-core/src/ownership/snapshot.rs +++ b/crates/idx-core/src/ownership/snapshot.rs @@ -58,6 +58,12 @@ pub struct OwnershipSnapshotArtifact { pub latest_release_sha256: String, pub latest_row_count: usize, pub ticker_count: usize, + /// `sqlite_sha256` of earlier builds published for the same + /// `latest_as_of_date` that this snapshot replaces (e.g. after a parser + /// fix). A local database still byte-identical to one of them is updated + /// without `--force`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaces_sqlite_sha256: Vec, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -85,6 +91,9 @@ pub struct OwnershipSyncResult { #[derive(Debug, Clone)] struct LocalSnapshotState { + /// SHA-256 of the database file. Queries leave a synced file + /// byte-identical, so this identifies an unmodified published snapshot. + file_sha256: String, latest_release: Option, release_count: usize, } @@ -307,6 +316,9 @@ fn validate_manifest(manifest: &OwnershipSnapshotManifest) -> Result<(), IdxErro &snapshot.latest_release_sha256, "snapshot.latest_release_sha256", )?; + for sha256 in &snapshot.replaces_sqlite_sha256 { + validate_sha256_hex(sha256, "snapshot.replaces_sqlite_sha256")?; + } Ok(()) } @@ -326,6 +338,12 @@ fn inspect_local_db(db_path: &Path) -> Result, IdxErr return Ok(None); } + // Hash before opening: schema setup may write to an empty database. + let bytes = fs::read(db_path) + .map_err(|e| IdxError::Io(format!("failed to read {}: {e}", db_path.display())))?; + let file_sha256 = sha256_hex(&bytes); + drop(bytes); + let conn = Connection::open(db_path).map_err(|e| { IdxError::DatabaseError(format!("failed to open {}: {e}", db_path.display())) })?; @@ -333,6 +351,7 @@ fn inspect_local_db(db_path: &Path) -> Result, IdxErr let releases = db::query_releases(&conn)?; Ok(Some(LocalSnapshotState { + file_sha256, latest_release: releases.first().cloned(), release_count: releases.len(), })) @@ -395,6 +414,33 @@ fn build_sync_decision( }; } + if local.file_sha256 == snapshot.sqlite_sha256 { + return SyncDecision { + action: OwnershipSyncAction::NoChange, + should_download: false, + reason: format!( + "ownership snapshot already current at {} with {} release(s)", + snapshot.latest_as_of_date.format("%Y-%m-%d"), + snapshot.release_count + ), + }; + } + + if snapshot + .replaces_sqlite_sha256 + .iter() + .any(|sha256| sha256.eq_ignore_ascii_case(&local.file_sha256)) + { + return SyncDecision { + action: OwnershipSyncAction::Updated, + should_download: true, + reason: format!( + "published snapshot for {} was rebuilt; replacing the earlier build", + snapshot.latest_as_of_date.format("%Y-%m-%d") + ), + }; + } + if latest.sha256 == snapshot.latest_release_sha256 { if latest.row_count != snapshot.latest_row_count { return SyncDecision { @@ -758,8 +804,11 @@ mod tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), latest_row_count: 7261, ticker_count: 955, + replaces_sqlite_sha256: Vec::new(), }; let local = super::LocalSnapshotState { + file_sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + .to_string(), latest_release: Some(OwnershipRelease { id: 1, source_url: None, @@ -777,6 +826,99 @@ mod tests { assert!(decision.reason.contains("missing historical releases")); } + fn same_date_snapshot(replaces: Vec) -> OwnershipSnapshotArtifact { + OwnershipSnapshotArtifact { + kind: "sqlite".to_string(), + compression: "none".to_string(), + version: "2026-09-30".to_string(), + download_url: "/tmp/ownership.sqlite".to_string(), + sqlite_sha256: "a".repeat(64), + size_bytes: 123, + release_count: 3, + latest_as_of_date: NaiveDate::from_ymd_opt(2026, 9, 30).unwrap(), + latest_release_sha256: "b".repeat(64), + latest_row_count: 7154, + ticker_count: 961, + replaces_sqlite_sha256: replaces, + } + } + + /// A local database at the snapshot's date, release, and history. + fn same_release_local( + snapshot: &OwnershipSnapshotArtifact, + file_sha256: &str, + ) -> super::LocalSnapshotState { + super::LocalSnapshotState { + file_sha256: file_sha256.to_string(), + latest_release: Some(OwnershipRelease { + id: 3, + source_url: None, + sha256: snapshot.latest_release_sha256.clone(), + as_of_date: snapshot.latest_as_of_date, + row_count: snapshot.latest_row_count, + imported_at: 0, + }), + release_count: snapshot.release_count, + } + } + + #[test] + fn build_sync_decision_replaces_an_unmodified_earlier_build() { + let earlier = "e".repeat(64); + let snapshot = same_date_snapshot(vec![earlier.clone()]); + + let decision = build_sync_decision( + Some(&same_release_local(&snapshot, &earlier)), + &snapshot, + false, + ); + assert_eq!(decision.action, OwnershipSyncAction::Updated); + assert!(decision.should_download); + assert!( + decision.reason.contains("was rebuilt"), + "{}", + decision.reason + ); + + let current = same_release_local(&snapshot, &snapshot.sqlite_sha256); + let decision = build_sync_decision(Some(¤t), &snapshot, false); + assert_eq!(decision.action, OwnershipSyncAction::NoChange); + assert!(!decision.should_download); + } + + #[test] + fn build_sync_decision_keeps_a_locally_modified_database() { + // Same release metadata but a file matching no published build (local + // imports or entity edits): unchanged behavior, never replaced silently. + let snapshot = same_date_snapshot(vec!["e".repeat(64)]); + let local = same_release_local(&snapshot, &"f".repeat(64)); + + let decision = build_sync_decision(Some(&local), &snapshot, false); + assert_eq!(decision.action, OwnershipSyncAction::NoChange); + assert!(!decision.should_download); + } + + #[test] + fn manifest_rejects_malformed_replaced_sha256() { + let mut manifest = OwnershipSnapshotManifest { + schema_version: SNAPSHOT_MANIFEST_SCHEMA_VERSION, + generated_at: "2026-10-14T00:00:00Z".to_string(), + source: None, + snapshot: same_date_snapshot(vec!["not-a-sha".to_string()]), + }; + let err = parse_manifest(&serde_json::to_string(&manifest).unwrap()) + .expect_err("malformed replaced sha256"); + assert!(err.to_string().contains("replaces_sqlite_sha256"), "{err}"); + + manifest.snapshot.replaces_sqlite_sha256.clear(); + let raw = serde_json::to_string(&manifest).unwrap(); + assert!( + !raw.contains("replaces_sqlite_sha256"), + "omitted when empty: {raw}" + ); + parse_manifest(&raw).expect("manifest without replaced builds"); + } + #[test] fn manifest_round_trip_parses_valid_payload() { let manifest = OwnershipSnapshotManifest { @@ -805,6 +947,9 @@ mod tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), latest_row_count: 7261, ticker_count: 955, + replaces_sqlite_sha256: vec![ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + ], }, }; diff --git a/docs/OWNERSHIP_PUBLISH.md b/docs/OWNERSHIP_PUBLISH.md index 0b82831..81a8929 100644 --- a/docs/OWNERSHIP_PUBLISH.md +++ b/docs/OWNERSHIP_PUBLISH.md @@ -79,7 +79,9 @@ The publisher treats the published snapshot as current only when the manifest's `download_url` names this repo and tag and the referenced asset matches its SHA-256 and size. A missing or corrupt asset, a mismatched URL, an unreadable manifest, or any GitHub auth/network error means "publish", never "up to date". -`--force` uploads regardless. +`--force` uploads regardless. A forced rebuild for the already-published as-of +date adds the replaced build's `sqlite_sha256` to `replaces_sqlite_sha256` +(keeping the last 20), so clients still holding it update on their next sync. ## Run Result diff --git a/docs/OWNERSHIP_SYNC.md b/docs/OWNERSHIP_SYNC.md index bfdd045..e589ce4 100644 --- a/docs/OWNERSHIP_SYNC.md +++ b/docs/OWNERSHIP_SYNC.md @@ -48,7 +48,8 @@ Current schema version: `1` "latest_as_of_date": "2026-02-27", "latest_release_sha256": "", "latest_row_count": 7261, - "ticker_count": 955 + "ticker_count": 955, + "replaces_sqlite_sha256": [""] } } ``` @@ -62,6 +63,9 @@ Semantics: follow `download_url` and validate it with `sqlite_sha256` and `size_bytes`. - `sqlite_sha256` and `size_bytes` are validated before install. - `latest_*` and `release_count` are validated against the downloaded SQLite contents before replacement. +- `replaces_sqlite_sha256` (optional, omitted when empty) lists earlier builds + published for the same `latest_as_of_date` that this snapshot supersedes, for + example after a parser fix. Clients up to v0.2.x ignore it. ## Replacement Rules @@ -70,6 +74,9 @@ Without `--force`, sync behaves conservatively: - no local DB: install snapshot - empty local DB: replace with snapshot - local latest release older than snapshot: replace with snapshot +- local DB file is byte-identical to `sqlite_sha256`: no-op +- local DB file is byte-identical to a build in `replaces_sqlite_sha256`: replace + (queries never write to a synced file, so this means it was not modified) - local latest release/date/count matches snapshot: no-op - local latest release matches but has fewer releases than snapshot: replace to fill missing history - local DB newer than snapshot: no-op and keep local data diff --git a/scripts/publish-ownership-snapshot-test.sh b/scripts/publish-ownership-snapshot-test.sh index 3226c7e..796b564 100755 --- a/scripts/publish-ownership-snapshot-test.sh +++ b/scripts/publish-ownership-snapshot-test.sh @@ -396,6 +396,22 @@ if [[ "$old_asset" != "$new_asset" ]] && remote_asset_exists "$old_asset" && rem else fail "forced refresh keeps the previous content-addressed artifact (old=$old_asset new=$new_asset count=$(asset_count))" fi +old_sha="${old_asset#ownership-snapshot-2026-08-31-}" +old_sha="${old_sha%.sqlite}" +if [[ "$(remote_manifest_field '.snapshot.replaces_sqlite_sha256 | join(",")')" == "$old_sha" ]]; then + pass "same-date rebuild lists the build it replaces" +else + fail "same-date rebuild lists the build it replaces (got $(remote_manifest_field '.snapshot.replaces_sqlite_sha256'))" +fi +mid_sha="$(remote_manifest_field '.snapshot.sqlite_sha256')" +export FAKE_BUILD_VARIANT="second-replacement" +run_publisher --force +unset FAKE_BUILD_VARIANT +if [[ "$(remote_manifest_field '.snapshot.replaces_sqlite_sha256 | join(",")')" == "$old_sha,$mid_sha" ]]; then + pass "a second rebuild carries earlier replaced builds forward" +else + fail "a second rebuild carries earlier replaced builds forward (got $(remote_manifest_field '.snapshot.replaces_sqlite_sha256'))" +fi # =========================================================================== # 5. a trustworthy-looking manifest with a missing asset forces a republish diff --git a/scripts/publish-ownership-snapshot.sh b/scripts/publish-ownership-snapshot.sh index ebd2b7c..dd2c9c3 100755 --- a/scripts/publish-ownership-snapshot.sh +++ b/scripts/publish-ownership-snapshot.sh @@ -461,6 +461,25 @@ cp "$STAGED_MANIFEST_PATH" "$OUTPUT_DIR/ownership-snapshot-manifest.json" cp "$STAGED_SQLITE_PATH" "$OUTPUT_DIR/" MANIFEST_PATH="$OUTPUT_DIR/ownership-snapshot-manifest.json" + +# A rebuild for the published as-of date (e.g. after a parser fix) lists the +# builds it replaces, so clients still holding one update without --force. +if [[ "$BUILT_AS_OF" == "$PUBLISHED_AS_OF" && "$PUBLISHED_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then + replaces_tmp="$PUBLISH_WORKDIR/manifest-replaces.json" + jq --arg published "$PUBLISHED_SHA256" \ + --argjson previous "$(jq -c '.snapshot.replaces_sqlite_sha256 // []' <<< "$PUBLISHED_MANIFEST" 2>/dev/null || echo '[]')" ' + .snapshot.sqlite_sha256 as $own + | .snapshot.replaces_sqlite_sha256 = + ([$previous[], $published] + | map(ascii_downcase) | map(select(. != ($own | ascii_downcase))) + | reduce .[] as $sha ([]; if index([$sha]) then . else . + [$sha] end) + | .[-20:]) + | if (.snapshot.replaces_sqlite_sha256 | length) == 0 + then del(.snapshot.replaces_sqlite_sha256) else . end + ' "$MANIFEST_PATH" > "$replaces_tmp" + mv "$replaces_tmp" "$MANIFEST_PATH" +fi + SQLITE_PATH="$OUTPUT_DIR/$(basename "$STAGED_SQLITE_PATH")" SQLITE_ASSET_NAME="$(basename "$STAGED_SQLITE_PATH")" LOCAL_SQLITE_BYTES="$(wc -c < "$SQLITE_PATH" | tr -d ' ')" From 8e3323e4f71980fc3feaec98b7835c4c6a5356a1 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 19:01:29 +0000 Subject: [PATCH 13/14] chore: release 0.3.0 with idx-mcp Bump the workspace and npm packages to 0.3.0. The release workflow builds and attaches idx-mcp binaries next to idx, the npm launcher and installer pick their binary from package.json so npm/idx-mcp reuses them, and the npm smoke test covers both packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 31 +++++++--- Cargo.lock | 6 +- Cargo.toml | 4 +- README.md | 12 ++-- ROADMAP.md | 1 - docs/DESIGN.md | 2 +- docs/INSTALL.md | 4 +- docs/NPM_DISTRIBUTION.md | 29 +++++++--- npm/idx-mcp/.gitignore | 1 + npm/idx-mcp/README.md | 12 ++++ npm/idx-mcp/package.json | 34 +++++++++++ package.json | 2 +- scripts/idx.js | 13 +++-- scripts/install.js | 28 +++++---- scripts/npm-smoke.sh | 105 ++++++++++++++++++---------------- 15 files changed, 189 insertions(+), 95 deletions(-) create mode 100644 npm/idx-mcp/.gitignore create mode 100644 npm/idx-mcp/README.md create mode 100644 npm/idx-mcp/package.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f18dc43..135a50f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ on: - "crates/*/Cargo.toml" - "Cargo.lock" - "package.json" + - "npm/idx-mcp/package.json" - "scripts/install.js" - "install.sh" @@ -69,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: @@ -105,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 @@ -116,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 diff --git a/Cargo.lock b/Cargo.lock index e88b191..58923e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,7 +901,7 @@ dependencies = [ [[package]] name = "idx-cli" -version = "0.2.4" +version = "0.3.0" dependencies = [ "assert_cmd", "chrono", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "idx-core" -version = "0.2.4" +version = "0.3.0" dependencies = [ "chrono", "clap", @@ -940,7 +940,7 @@ dependencies = [ [[package]] name = "idx-mcp" -version = "0.2.4" +version = "0.3.0" dependencies = [ "clap", "idx-core", diff --git a/Cargo.toml b/Cargo.toml index 03b6714..5b2edf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"] [workspace.package] -version = "0.2.4" +version = "0.3.0" edition = "2024" rust-version = "1.85" license = "MIT" @@ -11,7 +11,7 @@ repository = "https://github.com/0xrsydn/idx-cli" homepage = "https://github.com/0xrsydn/idx-cli" [workspace.dependencies] -idx-core = { path = "crates/idx-core", version = "=0.2.4" } +idx-core = { path = "crates/idx-core", version = "=0.3.0" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } diff --git a/README.md b/README.md index 8877a0c..9b9a5c1 100644 --- a/README.md +++ b/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 @@ -167,13 +167,17 @@ change it to `auto` with `idx config set general.output auto`. `{data, source, fetched_at, stale}`; errors use the CLI's `{error, code, message}` body. It shares idx's config, cache, and ownership database. -Until it is released, build it from this repo: +Install it like `idx` (npm, Cargo, or Nix), then register it with your MCP +client: ```bash -cargo install --path crates/idx-mcp # or: nix build .#idx-mcp -claude mcp add idx -- idx-mcp # Claude Code; other clients run `idx-mcp` +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). diff --git a/ROADMAP.md b/ROADMAP.md index 7380e4a..f4d7f89 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,7 +19,6 @@ history lives in git. - Additional providers (Alpha Vantage, Twelve Data, IDX official) ## Agent integration -- Publish `idx-mcp` (crates.io, npm, release assets); see `docs/DESIGN.md` phase 5 - JSON Schema export for command output - Composite `analyze` command (quote, fundamentals, ownership, sentiment) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fe57bcb..fbb6a02 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-4 done. +**Status:** Accepted 2026-10-14. Released in v0.3.0; phases 0-5 done. ## Goal diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f31b1fc..00ee84e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -11,7 +11,7 @@ Each release also carries a copy of the script as a release asset, so a versioned URL stays stable: ```bash -curl -fsSL https://github.com/0xrsydn/idx-cli/releases/download/v0.2.4/install.sh | sh +curl -fsSL https://github.com/0xrsydn/idx-cli/releases/download/v0.3.0/install.sh | sh ``` ## What it does @@ -45,7 +45,7 @@ them yourself. Pass flags through the pipe with `sh -s --`: ```bash -curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.2.4 --dir ~/bin +curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.3.0 --dir ~/bin ``` ## Supported platforms diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index d09ed7a..f31681e 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -1,6 +1,10 @@ # npm distribution -The npm package `idx-cli` is a small wrapper around the native `idx` binary. +The npm packages `idx-cli` (repo root) and `idx-mcp` (`npm/idx-mcp/`) are small +wrappers around the native `idx` and `idx-mcp` binaries. Both use the same +launcher and installer from `scripts/`; the package's `nativeBinary` field +picks the binary, and `npm/idx-mcp` copies the scripts in at `prepack`. +The `idx-cli` package The package keeps the JavaScript launcher and postinstall downloader. The downloader selects a prebuilt GitHub Release asset for the current platform and places it at the path used by the `idx` bin entry. @@ -14,6 +18,9 @@ Supported release assets: | macOS arm64 | `aarch64-apple-darwin` | `idx-darwin-arm64` | | macOS x64 | `x86_64-apple-darwin` | `idx-darwin-x64` | +`idx-mcp` assets use the same platforms with an `idx-mcp-` prefix, for +example `idx-mcp-linux-x64`. + The Linux binaries are statically linked against musl, so they do not depend on the host glibc version. @@ -31,7 +38,7 @@ npx idx-cli --help ``` The package version and the release tag must match. For example, package -version `0.2.4` downloads assets from the `v0.2.4` GitHub Release. +version `0.3.0` downloads assets from the `v0.3.0` GitHub Release. The postinstall script downloads `SHA256SUMS` from the same release and refuses to install a binary whose checksum does not match. @@ -81,7 +88,7 @@ URLs must stay aligned with `scripts/install.js`. To attach binaries to a tag that already exists, run the workflow manually: ```bash -gh workflow run release.yml -f tag=v0.2.4 +gh workflow run release.yml -f tag=v0.3.0 ``` Pull requests that touch the workflow or the installer run the build jobs @@ -89,9 +96,13 @@ without publishing, so target breakage shows up before a release. Release order: -1. Push the tag, or run the workflow for an existing tag. -2. Confirm the release has all binaries plus `SHA256SUMS`. -3. Run `npm publish` from the tagged commit. The account has two-factor auth, - so npm asks for a browser approval or `--otp=`. A version number - can never be republished, so bump `Cargo.toml` and `package.json` together - before tagging. +1. Bump the version together in `Cargo.toml` (workspace version and the + `idx-core` dependency pin), `package.json`, and `npm/idx-mcp/package.json`. + A version number can never be republished. +2. Push the tag, or run the workflow for an existing tag. +3. Publish the crates from the tagged commit, dependency first: + `cargo publish -p idx-core`, then `-p idx-cli` and `-p idx-mcp`. +4. Confirm the release has all eight binaries plus `SHA256SUMS`. +5. Run `npm publish` in the repo root and in `npm/idx-mcp/` from the tagged + commit. The account has two-factor auth, so npm asks for a browser + approval or `--otp=`. diff --git a/npm/idx-mcp/.gitignore b/npm/idx-mcp/.gitignore new file mode 100644 index 0000000..5093a29 --- /dev/null +++ b/npm/idx-mcp/.gitignore @@ -0,0 +1 @@ +scripts/ diff --git a/npm/idx-mcp/README.md b/npm/idx-mcp/README.md new file mode 100644 index 0000000..4ec4992 --- /dev/null +++ b/npm/idx-mcp/README.md @@ -0,0 +1,12 @@ +# idx-mcp + +MCP server (stdio) for Indonesian stock market (IDX) data: quotes, price +history, screener, company financials, analysis, and KSEI shareholder +ownership. This package downloads the native `idx-mcp` binary for your +platform from the matching GitHub release and verifies its SHA-256. + +```bash +claude mcp add idx -- npx -y idx-mcp +``` + +See https://github.com/0xrsydn/idx-cli#mcp-server for the tools and options. diff --git a/npm/idx-mcp/package.json b/npm/idx-mcp/package.json new file mode 100644 index 0000000..4419647 --- /dev/null +++ b/npm/idx-mcp/package.json @@ -0,0 +1,34 @@ +{ + "name": "idx-mcp", + "version": "0.3.0", + "description": "MCP server for Indonesian stock market (IDX) market, financial, and ownership data", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/0xrsydn/idx-cli.git", + "directory": "npm/idx-mcp" + }, + "homepage": "https://github.com/0xrsydn/idx-cli", + "keywords": [ + "idx", + "stocks", + "indonesia", + "finance", + "mcp" + ], + "engines": { + "node": ">=16" + }, + "nativeBinary": "idx-mcp", + "bin": { + "idx-mcp": "./scripts/idx.js" + }, + "files": [ + "scripts/idx.js", + "scripts/install.js" + ], + "scripts": { + "prepack": "mkdir -p scripts && cp ../../scripts/idx.js ../../scripts/install.js scripts/", + "postinstall": "node scripts/install.js" + } +} diff --git a/package.json b/package.json index 7ca925e..d9cd0f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "idx-cli", - "version": "0.2.4", + "version": "0.3.0", "description": "CLI tool for Indonesian stock market (IDX) analysis", "license": "MIT", "repository": { diff --git a/scripts/idx.js b/scripts/idx.js index 42cbc8d..86006ef 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -5,12 +5,17 @@ const fs = require("node:fs"); const path = require("node:path"); const { spawn } = require("node:child_process"); -const binaryName = process.platform === "win32" ? "idx.exe" : "idx"; +const packageManifest = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"), +); +const nativeBinary = packageManifest.nativeBinary || "idx"; +const packageName = packageManifest.name; +const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const binaryPath = path.join(__dirname, "..", "bin", binaryName); if (!fs.existsSync(binaryPath)) { - console.error(`idx-cli: installed binary not found at ${binaryPath}`); - console.error("idx-cli: rerun npm install or set IDX_BINARY_URL for a local binary"); + console.error(`${packageName}: installed binary not found at ${binaryPath}`); + console.error(`${packageName}: rerun npm install or set IDX_BINARY_URL for a local binary`); process.exit(1); } @@ -24,7 +29,7 @@ for (const signal of ["SIGTERM", "SIGHUP"]) { process.on("SIGINT", () => {}); child.once("error", (error) => { - console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`); + console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`); process.exit(1); }); diff --git a/scripts/install.js b/scripts/install.js index ececbaf..a92e465 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -13,7 +13,11 @@ const packageRoot = path.resolve(__dirname, ".."); const packageManifest = JSON.parse( fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"), ); -const binaryName = process.platform === "win32" ? "idx.exe" : "idx"; +// The package names its native binary (`idx` or `idx-mcp`); release assets +// are named `-`. +const nativeBinary = packageManifest.nativeBinary || "idx"; +const packageName = packageManifest.name; +const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const binaryDirectory = path.join(packageRoot, "bin"); const binaryPath = path.join(binaryDirectory, binaryName); const githubRepository = "0xrsydn/idx-cli"; @@ -24,22 +28,22 @@ const targets = { "linux/x64": { label: "linux-x64", triple: "x86_64-unknown-linux-gnu", - asset: "idx-linux-x64", + asset: `${nativeBinary}-linux-x64`, }, "linux/arm64": { label: "linux-arm64", triple: "aarch64-unknown-linux-gnu", - asset: "idx-linux-arm64", + asset: `${nativeBinary}-linux-arm64`, }, "darwin/arm64": { label: "darwin-arm64", triple: "aarch64-apple-darwin", - asset: "idx-darwin-arm64", + asset: `${nativeBinary}-darwin-arm64`, }, "darwin/x64": { label: "darwin-x64", triple: "x86_64-apple-darwin", - asset: "idx-darwin-x64", + asset: `${nativeBinary}-darwin-x64`, }, }; @@ -111,7 +115,7 @@ function request(url, onResponse, redirects = 0) { { headers: { Accept: "application/octet-stream", - "User-Agent": `idx-cli-npm/${packageManifest.version}`, + "User-Agent": `${packageName}-npm/${packageManifest.version}`, }, }, (response) => { @@ -226,7 +230,7 @@ async function main() { // `npm install` inside the Rust checkout should not fetch a release binary. if (!override && fs.existsSync(path.join(packageRoot, "Cargo.toml"))) { - console.log("idx-cli: source checkout detected; skipping binary download (set IDX_BINARY_URL to override)"); + console.log(`${packageName}: source checkout detected; skipping binary download (set IDX_BINARY_URL to override)`); return; } @@ -241,22 +245,22 @@ async function main() { verifyChecksum(localPath, overrideSha256, localPath); } replaceWithLocalBinary(localPath); - console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`); + console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`); return; } - console.log(`idx-cli: downloading binary from IDX_BINARY_URL (${override})`); + console.log(`${packageName}: downloading binary from IDX_BINARY_URL (${override})`); } else { - console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`); + console.log(`${packageName}: downloading ${target.asset} for ${target.label} from ${source}`); } const expectedSha256 = override ? overrideSha256 : await expectedReleaseChecksum(target.asset); await replaceWithDownloadedBinary(source, expectedSha256); - console.log(`idx-cli: installed binary at ${binaryPath}`); + console.log(`${packageName}: installed binary at ${binaryPath}`); } main().catch((error) => { - console.error(`idx-cli: ${error.message}`); + console.error(`${packageName}: ${error.message}`); process.exit(1); }); diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index e6b58ac..a39301b 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -3,7 +3,6 @@ set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/idx-cli-npm-smoke.XXXXXX")" -install_dir="${smoke_dir}/install" npm_cache="${smoke_dir}/npm-cache" cleanup() { @@ -21,54 +20,64 @@ trap on_error ERR cd "$repo_root" -release_binary="${repo_root}/target/release/idx" -if [[ -x "$release_binary" ]]; then - echo "npm smoke: reusing ${release_binary}" +# Packs the npm package in , installs it with the local release binary, +# and checks that ` --help` prints ``. +smoke_package() { + local package_dir="$1" bin="$2" usage="$3" + local release_binary="${repo_root}/target/release/${bin}" + local work="${smoke_dir}/${bin}" + local install_dir="${work}/install" + mkdir -p "$work" "$install_dir" + + if [[ ! -x "$release_binary" ]]; then + echo "npm smoke: release binary was not produced at ${release_binary}" >&2 + exit 1 + fi + + echo "npm smoke: packing ${package_dir}" + local tarball_name tarball + tarball_name="$(cd "$package_dir" && npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$work" | tail -n 1)" + tarball="${work}/${tarball_name}" + if [[ -z "$tarball_name" || ! -f "$tarball" ]]; then + echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 + exit 1 + fi + + local binary_url + binary_url="$(node -e 'const { pathToFileURL } = require("node:url"); process.stdout.write(pathToFileURL(process.argv[1]).href)' "$release_binary")" + echo "npm smoke: installing ${tarball_name} into ${install_dir}" + npm_config_cache="$npm_cache" IDX_BINARY_URL="$binary_url" npm install \ + --prefix "$install_dir" \ + "$tarball" \ + --no-save \ + --no-package-lock \ + --no-audit \ + --no-fund \ + --foreground-scripts + + local resolved_bin="${install_dir}/node_modules/.bin/${bin}" + if [[ ! -x "$resolved_bin" ]]; then + echo "npm smoke: npm did not create an executable bin link at ${resolved_bin}" >&2 + exit 1 + fi + + echo "npm smoke: running installed ${bin} --help" + "$resolved_bin" --help | tee "${work}/help.txt" + if ! grep -q "$usage" "${work}/help.txt"; then + echo "npm smoke: ${bin} --help did not contain '${usage}'" >&2 + exit 1 + fi +} + +if [[ -x target/release/idx && -x target/release/idx-mcp ]]; then + echo "npm smoke: reusing target/release/{idx,idx-mcp}" else - echo "npm smoke: building ${release_binary}" - cargo build --release --locked + echo "npm smoke: building release binaries" + cargo build --release --locked -p idx-cli -p idx-mcp fi -if [[ ! -x "$release_binary" ]]; then - echo "npm smoke: release binary was not produced at ${release_binary}" >&2 - exit 1 -fi - -echo "npm smoke: packing wrapper" mkdir -p "$npm_cache" -tarball_name="$(npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" | tail -n 1)" -tarball="${smoke_dir}/${tarball_name}" -if [[ -z "$tarball_name" || ! -f "$tarball" ]]; then - echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 - exit 1 -fi - -mkdir -p "$install_dir" -binary_url="$(node -e 'const { pathToFileURL } = require("node:url"); process.stdout.write(pathToFileURL(process.argv[1]).href)' "$release_binary")" -echo "npm smoke: installing tarball into ${install_dir}" -npm_config_cache="$npm_cache" IDX_BINARY_URL="$binary_url" npm install \ - --prefix "$install_dir" \ - "$tarball" \ - --no-save \ - --no-package-lock \ - --no-audit \ - --no-fund \ - --foreground-scripts - -resolved_bin="${install_dir}/node_modules/.bin/idx" -if [[ ! -x "$resolved_bin" ]]; then - echo "npm smoke: npm did not create an executable bin link at ${resolved_bin}" >&2 - exit 1 -fi - -help_output="${smoke_dir}/help.txt" -echo "npm smoke: running installed idx --help" -"$resolved_bin" --help | tee "$help_output" -if ! grep -q "Usage: idx" "$help_output"; then - echo "npm smoke: --help output did not contain the expected usage line" >&2 - exit 1 -fi - -echo "npm smoke: running installed idx config --help" -"$resolved_bin" config --help -echo "npm smoke: PASS (pack, install, --help, and config --help)" +smoke_package "$repo_root" idx "Usage: idx" +"${smoke_dir}/idx/install/node_modules/.bin/idx" config --help >/dev/null +smoke_package "${repo_root}/npm/idx-mcp" idx-mcp "Usage: idx-mcp" +echo "npm smoke: PASS (idx-cli and idx-mcp: pack, install, --help)" From 58d421028f9df6409b85ff066941c8329e63fd57 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 19:44:24 +0000 Subject: [PATCH 14/14] fix: address release review (WAL-aware sync hash, empty MCP inputs) - Sync hashed only the main database file, but local edits can sit in the -wal sidecar, so an edited database could match an earlier build and be replaced. Checkpoint the WAL before hashing; if changes remain, never take the rebuilt-snapshot path. - MCP tools rejected nothing for empty symbols/sections/kinds and returned an empty success; they now fail with INVALIDINPUT, as the CLI requires input. Co-Authored-By: Claude Opus 5.5 --- crates/idx-core/src/ownership/snapshot.rs | 112 +++++++++++++++++----- crates/idx-mcp/src/server.rs | 23 +++++ crates/idx-mcp/tests/mcp.rs | 11 +++ docs/OWNERSHIP_SYNC.md | 3 +- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/idx-core/src/ownership/snapshot.rs b/crates/idx-core/src/ownership/snapshot.rs index d23af4d..3b26a18 100644 --- a/crates/idx-core/src/ownership/snapshot.rs +++ b/crates/idx-core/src/ownership/snapshot.rs @@ -93,7 +93,9 @@ pub struct OwnershipSyncResult { struct LocalSnapshotState { /// SHA-256 of the database file. Queries leave a synced file /// byte-identical, so this identifies an unmodified published snapshot. - file_sha256: String, + /// `None` when committed changes could not be checkpointed out of the + /// WAL, so the file alone does not show the database's contents. + file_sha256: Option, latest_release: Option, release_count: usize, } @@ -338,11 +340,14 @@ fn inspect_local_db(db_path: &Path) -> Result, IdxErr return Ok(None); } - // Hash before opening: schema setup may write to an empty database. - let bytes = fs::read(db_path) - .map_err(|e| IdxError::Io(format!("failed to read {}: {e}", db_path.display())))?; - let file_sha256 = sha256_hex(&bytes); - drop(bytes); + let file_sha256 = if checkpoint_wal(db_path) { + // Hash before opening: schema setup may write to an empty database. + let bytes = fs::read(db_path) + .map_err(|e| IdxError::Io(format!("failed to read {}: {e}", db_path.display())))?; + Some(sha256_hex(&bytes)) + } else { + None + }; let conn = Connection::open(db_path).map_err(|e| { IdxError::DatabaseError(format!("failed to open {}: {e}", db_path.display())) @@ -357,6 +362,20 @@ fn inspect_local_db(db_path: &Path) -> Result, IdxErr })) } +/// Moves committed WAL frames into the database file, so its hash covers local +/// edits too. Returns false when changes remain in the WAL. +fn checkpoint_wal(db_path: &Path) -> bool { + let wal_path = sidecar_path(db_path, "-wal"); + let wal_is_empty = || fs::metadata(&wal_path).map_or(true, |meta| meta.len() == 0); + if wal_is_empty() { + return true; + } + if let Ok(conn) = Connection::open(db_path) { + let _ = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())); + } + wal_is_empty() +} + fn build_sync_decision( local: Option<&LocalSnapshotState>, snapshot: &OwnershipSnapshotArtifact, @@ -414,7 +433,8 @@ fn build_sync_decision( }; } - if local.file_sha256 == snapshot.sqlite_sha256 { + let file_sha256 = local.file_sha256.as_deref(); + if file_sha256 == Some(snapshot.sqlite_sha256.as_str()) { return SyncDecision { action: OwnershipSyncAction::NoChange, should_download: false, @@ -429,7 +449,7 @@ fn build_sync_decision( if snapshot .replaces_sqlite_sha256 .iter() - .any(|sha256| sha256.eq_ignore_ascii_case(&local.file_sha256)) + .any(|sha256| file_sha256.is_some_and(|local| sha256.eq_ignore_ascii_case(local))) { return SyncDecision { action: OwnershipSyncAction::Updated, @@ -657,19 +677,23 @@ fn build_backup_path(db_path: &Path) -> PathBuf { } } +fn sidecar_path(db_path: &Path, suffix: &str) -> PathBuf { + let file_name = format!( + "{}{suffix}", + db_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("ownership.db") + ); + match db_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(&file_name), + _ => PathBuf::from(&file_name), + } +} + fn remove_sqlite_sidecars(db_path: &Path) -> Result<(), IdxError> { for suffix in ["-wal", "-shm"] { - let file_name = format!( - "{}{suffix}", - db_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("ownership.db") - ); - let sidecar = match db_path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent.join(&file_name), - _ => PathBuf::from(&file_name), - }; + let sidecar = sidecar_path(db_path, suffix); if sidecar.exists() { fs::remove_file(&sidecar).map_err(|e| { @@ -807,8 +831,7 @@ mod tests { replaces_sqlite_sha256: Vec::new(), }; let local = super::LocalSnapshotState { - file_sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - .to_string(), + file_sha256: Some("d".repeat(64)), latest_release: Some(OwnershipRelease { id: 1, source_url: None, @@ -849,7 +872,7 @@ mod tests { file_sha256: &str, ) -> super::LocalSnapshotState { super::LocalSnapshotState { - file_sha256: file_sha256.to_string(), + file_sha256: Some(file_sha256.to_string()), latest_release: Some(OwnershipRelease { id: 3, source_url: None, @@ -1036,4 +1059,49 @@ mod tests { .unwrap(); assert_eq!(release_count, 1); } + + #[test] + fn local_hash_covers_committed_changes_still_in_the_wal() { + let dir = std::env::temp_dir().join(format!( + "idx-snapshot-wal-{}-{}", + std::process::id(), + fastrand::u64(..) + )); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("ownership.db"); + let release = |sha256: &str| OwnershipRelease { + id: 0, + source_url: None, + sha256: sha256.to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 9, 30).unwrap(), + row_count: 1, + imported_at: 1, + }; + { + let conn = Connection::open(&db_path).unwrap(); + ensure_schema(&conn).unwrap(); + insert_release(&conn, &release("published")).unwrap(); + } + let published = super::sha256_hex(&std::fs::read(&db_path).unwrap()); + + // A committed edit that stays in the WAL: the main file still hashes + // as the published build while the connection is open. + let editor = Connection::open(&db_path).unwrap(); + ensure_schema(&editor).unwrap(); + editor + .query_row("PRAGMA wal_autocheckpoint = 0", [], |_| Ok(())) + .unwrap(); + insert_release(&editor, &release("local edit")).unwrap(); + assert_eq!( + super::sha256_hex(&std::fs::read(&db_path).unwrap()), + published + ); + + let local = super::inspect_local_db(&db_path).unwrap().unwrap(); + assert_ne!(local.file_sha256.as_deref(), Some(published.as_str())); + assert_eq!(local.release_count, 2); + + drop(editor); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index e9412d2..ac817b2 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -99,6 +99,9 @@ for one or more IDX tickers." )] async fn get_quotes(&self, params: Parameters) -> ToolResult { let SymbolsParams { symbols } = params.0; + if let Some(err) = empty_input("symbols", &symbols) { + return err; + } self.live(move |idx| { let quotes = idx.market().quotes(&symbols, CachePolicy::Default)?; Ok(json!({ "quotes": quotes })) @@ -155,6 +158,9 @@ earnings, analyst sentiment, insights, and news. Sections that fail are listed u async fn get_company(&self, params: Parameters) -> ToolResult { let CompanyParams { symbol, sections } = params.0; let sections = sections.unwrap_or_else(|| CompanySection::ALL.to_vec()); + if let Some(err) = empty_input("sections", §ions) { + return err; + } self.live(move |idx| { let financial = idx.financial(); let policy = CachePolicy::Default; @@ -195,6 +201,9 @@ are listed under `errors`; the call fails only if every analysis fails." async fn analyze_stock(&self, params: Parameters) -> ToolResult { let AnalyzeParams { symbol, kinds } = params.0; let kinds = kinds.unwrap_or_else(|| AnalysisKind::ALL.to_vec()); + if let Some(err) = empty_input("kinds", &kinds) { + return err; + } self.live(move |idx| { let analysis = idx.analysis(); let policy = CachePolicy::Default; @@ -224,6 +233,9 @@ tickers. Tickers that fail are listed under `failures`." )] async fn compare_stocks(&self, params: Parameters) -> ToolResult { let SymbolsParams { symbols } = params.0; + if let Some(err) = empty_input("symbols", &symbols) { + return err; + } self.live(move |idx| { let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; let failures: Vec = comparison @@ -377,6 +389,17 @@ where }) } +/// An explicitly empty list is an input error, as in the CLI, rather than an +/// empty success an agent could mistake for data. +fn empty_input(field: &str, values: &[T]) -> Option { + values.is_empty().then(|| { + let err = IdxError::InvalidInput(format!("{field} must not be empty")); + Ok(CallToolResult::structured_error(Value::Object(error_body( + &err, + )))) + }) +} + fn to_json(value: T) -> Result { serde_json::to_value(value).map_err(|err| IdxError::ParseError(err.to_string())) } diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 8698570..9581448 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -243,6 +243,17 @@ fn errors_carry_cli_codes() { assert!(is_error); assert_eq!(body["code"], "INVALIDINPUT"); + for (tool, arguments) in [ + ("get_quotes", json!({"symbols": []})), + ("compare_stocks", json!({"symbols": []})), + ("get_company", json!({"symbol": "BBCA", "sections": []})), + ("analyze_stock", json!({"symbol": "BBCA", "kinds": []})), + ] { + let (is_error, body) = client.call(tool, arguments); + assert!(is_error, "{tool}: {body}"); + assert_eq!(body["code"], "INVALIDINPUT", "{tool}"); + } + // Empty ownership database: investor tools point at the sync path. let status = client.ok("ownership_status", json!({})); assert_eq!(status["populated"], false); diff --git a/docs/OWNERSHIP_SYNC.md b/docs/OWNERSHIP_SYNC.md index e589ce4..92215e8 100644 --- a/docs/OWNERSHIP_SYNC.md +++ b/docs/OWNERSHIP_SYNC.md @@ -76,7 +76,8 @@ Without `--force`, sync behaves conservatively: - local latest release older than snapshot: replace with snapshot - local DB file is byte-identical to `sqlite_sha256`: no-op - local DB file is byte-identical to a build in `replaces_sqlite_sha256`: replace - (queries never write to a synced file, so this means it was not modified) + (queries never write to a synced file, so this means it was not modified; + committed changes still in the `-wal` file are checkpointed before hashing) - local latest release/date/count matches snapshot: no-op - local latest release matches but has fewer releases than snapshot: replace to fill missing history - local DB newer than snapshot: no-op and keep local data