From c94c8263077b6a20d92045a98997252a36228a64 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 6 Apr 2026 17:47:20 +0700 Subject: [PATCH] chore: prepare v0.2.1 release --- .github/workflows/ci.yml | 26 + .gitignore | 1 + Cargo.lock | 2 +- Cargo.toml | 12 +- FEATURE_SPEC.md | 14 +- README.md | 19 +- TODO.md | 57 +- docs/SMOKE.md | 3 + flake.nix | 46 +- scripts/live-smoke.sh | 22 + src/api/mod.rs | 233 +++++- src/api/msn/map.rs | 167 ++++- src/api/msn/mod.rs | 21 +- src/api/msn/raw_types.rs | 1 + src/api/types.rs | 6 + src/api/yahoo/client.rs | 192 +++-- src/api/yahoo/mod.rs | 4 +- src/api/yahoo/parse.rs | 25 +- src/cache.rs | 7 +- src/cli/cache.rs | 3 +- src/cli/mod.rs | 2 +- src/cli/ownership.rs | 101 ++- src/cli/stocks.rs | 466 +++++++++--- src/config.rs | 29 +- src/main.rs | 45 +- src/output/table.rs | 2 +- src/ownership/db.rs | 685 +++++++++++++++--- src/ownership/graph.rs | 140 +++- tests/cli.rs | 477 +++++++++++- tests/fixtures/chart_bbca_with_gap.json | 17 + tests/fixtures/msn_keyratios_bbca.json | 28 + .../fixtures/msn_keyratios_industry_only.json | 20 + 32 files changed, 2487 insertions(+), 386 deletions(-) create mode 100644 tests/fixtures/chart_bbca_with_gap.json create mode 100644 tests/fixtures/msn_keyratios_industry_only.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d24db3e..16eb7b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,29 @@ jobs: - name: Test run: cargo test + + - name: Package + run: cargo package --locked + + - name: Install Smoke + run: | + root="$(mktemp -d)" + cargo install --path . --locked --root "$root" + "$root/bin/idx" version + scripts/live-smoke.sh --bin "$root/bin/idx" --no-build --mode mock + + nix: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v30 + + - name: Build default package + run: nix build .#default + + - name: Run default app + run: nix run .#default -- version diff --git a/.gitignore b/.gitignore index aa5c97a..e5145bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ target/ .direnv/ +.claude/ result tmp/ diff --git a/Cargo.lock b/Cargo.lock index 0367ad2..244ee73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "idx-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "assert_cmd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 1491e0e..50be806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,23 @@ [package] name = "idx-cli" -version = "0.2.0" +version = "0.2.1" 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" diff --git a/FEATURE_SPEC.md b/FEATURE_SPEC.md index 4149612..2abaabd 100644 --- a/FEATURE_SPEC.md +++ b/FEATURE_SPEC.md @@ -57,8 +57,8 @@ The remaining work is architecture cleanup, a few correctness edge cases, and se | Fundamental | `idx stocks fundamental` | Implemented | Shipped and exercised | | Compare | `idx stocks compare` | Implemented | Shipped and exercised | | Company profile | `idx stocks profile` | Implemented | Cache/offline parity and fixture-backed coverage are in place | -| Financial statements | `idx stocks financials` | Implemented with gaps | Output cleanup landed; statement filter flags and richer period controls are still missing | -| Earnings | `idx stocks earnings` | Implemented with gaps | History/forecast split is rendered; filter flags are still missing | +| Financial statements | `idx stocks financials` | Implemented | Output cleanup landed, and statement filters now support `--statement income|balance|cashflow` | +| Earnings | `idx stocks earnings` | Implemented | History/forecast split is rendered, and filters now support `--forecast|--history` with `--annual|--quarterly` | | Sentiment | `idx stocks sentiment` | Implemented | Fixture-backed CLI coverage exists | | Insights | `idx stocks insights` | Implemented | Summary/highlights/risks/`last_updated` mapping was corrected and tested | | News | `idx stocks news` | Implemented | Fixture-backed CLI coverage exists | @@ -153,11 +153,11 @@ Done when: ### P1 - UX and output contract cleanup -Tasks: -- Add `financials` filters such as `--statement income|balance|cashflow`. -- Add `earnings` filters such as `--forecast|--history` and `--annual|--quarterly`. -- Review JSON payload consistency where symbol or context fields are still sparse. -- Decide whether `screen` stays under `stocks` long term or graduates into a richer dedicated surface later. +Completed on `2026-04-02`: +- `financials` now supports `--statement income|balance|cashflow`. +- `earnings` now supports `--forecast|--history` and `--annual|--quarterly`. +- JSON payload context is less sparse for MSN `earnings`, `insights`, and `news`; each now carries the resolved symbol. +- `screen` stays under `stocks` for now; revisit a dedicated surface only if `screen query` / `screen presets` grow into a richer workflow. Done when: - Existing shipped commands are easier to drive without changing product scope. diff --git a/README.md b/README.md index 121f56a..b2a785f 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,30 @@ CLI tool for Indonesian stock market (IDX) analysis, built in Rust for humans an ## Installation +### Cargo + ```bash cargo install idx-cli ``` +This installs the `idx` binary. `idx-cli` currently requires Rust `1.85+`. + +If you use the Cargo install path directly, some commands also require helper tools at runtime: + +- Yahoo-authenticated flows require a `curl_chrome*` binary from `curl-impersonate-chrome` in `PATH`, or `IDX_CURL_IMPERSONATE_BIN` pointing at that binary. +- Ownership PDF import requires `mutool` from MuPDF in `PATH`. + +Install those helpers with your OS package manager, or use the Nix app/package below so they are wrapped automatically. + +### Nix + ```bash -nix run github:0xrsydn/idx-cli +nix run github:0xrsydn/idx-cli -- version +nix profile install github:0xrsydn/idx-cli#default ``` +The Nix app wraps `idx` with `curl-impersonate` and `mupdf`, so the Yahoo auth and ownership PDF helper tools are available automatically. + ## Quick start ```bash @@ -54,7 +70,6 @@ Precedence order: - Use `idx --help` and subcommand help for discoverability - Use `-o json` / `--output json` for structured output -- See `skills/` for agent workflows and task recipes ## Development diff --git a/TODO.md b/TODO.md index ee37310..b37e35d 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,42 @@ - [x] Batch 4 verification: `cargo test` - [x] Batch 4 verification: fallback ingest produces a compatible SQLite state for `ownership releases`, `ticker`, and `changes` +## 🎯 Current Core Work (from FEATURE_SPEC.md) + +### P0 — Correctness and architecture +- [x] Unify provider and capability flow so `src/cli/stocks.rs` stops constructing `MsnProvider` directly for MSN-only command handlers +- [x] Fix screener row hygiene so incomplete MSN screener rows with missing price data are filtered or rejected instead of defaulting to `0.0` +- [x] Decide and implement the fundamentals fallback policy when company metrics are missing +- [x] Harden Yahoo reliability edge cases: intermittent `429` handling and documented/intentional SMA200 behavior with fewer than `200` candles + +### P1 — UX and output contract cleanup +- [x] Add `stocks financials` filters such as `--statement income|balance|cashflow` +- [x] Add `stocks earnings` filters such as `--forecast|--history` and `--annual|--quarterly` +- [x] Review JSON payload consistency where symbol or context fields are still sparse +- [x] Decide whether `screen` stays under `stocks` long term or graduates into a richer dedicated surface later + +### P2 — Deferred but real work +- [ ] Add MSN chart/history support through `stocks history --history-provider msn` +- [ ] Define and implement `ownership import --fetch-bing` +- [ ] Decide whether richer financial statements should stay single-period or grow into multi-period fetch support + +## 🚀 Publish Readiness (2026-04-03) + +### P1 — Release blockers +- [x] Keep the installed binary name as `idx`; `Cargo.toml` already ships `[[bin]] name = "idx"` while the package remains `idx-cli` +- [x] Expose a default Nix package/app so the documented `nix run github:0xrsydn/idx-cli` path actually works +- [x] Restrict packaged crate contents so internal repo files like `.claude/`, `CLAUDE.md`, `AGENTS.md`, and agent-planning docs are not shipped to crates.io +- [x] Document real runtime dependencies and platform assumptions for `cargo install idx-cli`, including `curl-impersonate-chrome` for Yahoo auth and `mutool` for ownership PDF parsing +- [x] Add release/install verification to CI, at minimum `cargo package` and an install smoke path; include Nix app/package verification if the README keeps advertising `nix run` + +### P2 — Pre-publish cleanup +- [x] Wire `--quiet` so non-essential `info:` / `warning:` output is actually suppressed +- [x] Fail fast on invalid `IDX_CACHE_QUOTE_TTL` / `IDX_CACHE_FUNDAMENTAL_TTL` env values instead of silently ignoring them +- [x] Propagate `-v` into the history/technical provider path so verbose Yahoo diagnostics can surface +- [x] Honor XDG overrides consistently for ownership DB/raw download defaults, not only config/cache +- [x] Tighten `scripts/live-smoke.sh` so it does not silently validate a stale `target/debug/idx` binary +- [x] Remove or fix the README reference to the non-existent `skills/` directory + ## 📋 Backlog (per SPEC.md) - [ ] `market summary` — IHSG index, market breadth - [ ] `market movers` — top gainers/losers/volume @@ -126,7 +162,21 @@ - [ ] CSV/TSV output formats - [ ] Additional providers (Alpha Vantage, Twelve Data, IDX official) -## 🔬 Latest Smoke Findings (2026-03-28) +## 🔬 Latest Smoke Findings (2026-04-02) +- [x] Final release-hygiene pass on `2026-04-06`: crate metadata now declares `rust-version = 1.85`, README install docs now spell out Cargo helper-runtime expectations plus persistent `nix profile install`, and CI install smoke now runs the mock smoke matrix against the installed binary instead of only checking `idx version` +- [x] Verification on `2026-04-06`: `nix develop --command cargo build`, `nix develop --command cargo clippy -- -D warnings`, `nix develop --command cargo test`, `nix develop --command cargo package --allow-dirty --locked`, and `scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock` all passed +- [x] Publish review blocker batch on `2026-04-05`: the default Nix app/package path builds again without relying on an untracked runtime module, `ownership import --force` now re-imports the same release SHA atomically, and current ownership views (`ticker`, `entity`, `cross-holders`, `concentration`, `graph`) now scope KSEI data to the latest imported release instead of blending historical snapshots +- [x] Verification on `2026-04-05`: `nix develop --command cargo build`, `nix develop --command cargo clippy -- -D warnings`, `nix develop --command cargo test`, `nix build .#default`, `nix develop --command cargo package --allow-dirty --locked`, fresh `cargo install --path . --locked --root tmp/release-install`, `./tmp/release-install/bin/idx version`, and `nix run .#default -- version` all passed +- [x] Publish P2 cleanup batch on `2026-04-03`: `--quiet` now suppresses non-essential CLI warnings/info, invalid cache TTL env vars fail during startup, `-v` reaches Yahoo history diagnostics, ownership default DB/raw paths honor XDG overrides, and `scripts/live-smoke.sh` now refuses a stale `target/debug/idx` when build refresh is skipped +- [x] Verification on `2026-04-03`: `nix develop --command cargo build`, `nix develop --command cargo clippy -- -D warnings`, `nix develop --command cargo test`, `bash -n scripts/live-smoke.sh`, and `scripts/live-smoke.sh --mode mock --dry-run` all passed +- [x] Publish blocker batch on `2026-04-03`: crate packaging now excludes repo-internal agent harness files, the flake exports a default package/app for `nix run`, README install docs now describe runtime helper dependencies, and CI now checks package/install surfaces +- [x] Verification on `2026-04-03`: `nix develop --command cargo build`, `nix develop --command cargo clippy -- -D warnings`, `nix develop --command cargo test`, `nix develop --command cargo package --allow-dirty --locked`, fresh `cargo install --path . --locked --root tmp/release-install`, `./tmp/release-install/bin/idx version`, `nix build .#default`, and `nix run .#default -- version` all passed +- [x] P0 provider/capability routing is now centralized through `SelectedProvider`; `stocks` handlers no longer construct `MsnProvider` directly for MSN-only commands +- [x] MSN fundamentals now reject industry-only metrics with an explicit unsupported error; the normal MSN mock/live path uses company metrics only +- [x] MSN screener parsing now drops rows with missing/invalid/non-positive price data instead of synthesizing `0.0` price rows +- [x] Yahoo `429` retry/backoff is now centralized and regression-tested for chart and quote-summary fetches +- [x] `stocks technical` table output now says `Trend unavailable (need at least 200 daily candles)` when fewer than `200` daily candles are available +- [x] Verification on `2026-04-02`: `nix develop --command cargo build`, `nix develop --command cargo clippy -- -D warnings`, `nix develop --command cargo test`, `scripts/live-smoke.sh --mode mock`, and `scripts/live-smoke.sh --group live-table --group live-json --group routing --group cache --group errors` all passed (`tmp/live-smoke/20260402-122215`, `tmp/live-smoke/20260402-122218`) - [x] Live smoke passed for shipped `stocks` commands: `quote`, `history`, `technical`, `growth`, `valuation`, `risk`, `fundamental`, `compare`, `profile`, `financials`, `earnings`, `sentiment`, `insights`, `news`, `screen` - [x] Yahoo routing verified for live `quote` and `history` - [x] `stocks history --history-provider msn` correctly fails for IDX as unsupported @@ -145,6 +195,11 @@ - [x] Re-run full live smoke to reconfirm all MSN-only commands after the latest hardening fixes - [x] `stocks financials BBCA` table now trims ISO timestamps from section headers and humanizes raw line-item keys - [x] `stocks earnings BBCA` table now splits history vs forecast and formats annual periods, revenue values, and dates for table mode +- [x] `stocks financials` now supports section filters via `--statement income|balance|cashflow`, with filtered table output and JSON sections rendered as `null` when intentionally excluded +- [x] `stocks earnings` now supports `--forecast|--history` and `--annual|--quarterly`, so table and JSON output can be scoped without changing the cached source payload +- [x] JSON payload context is now less sparse for MSN `earnings`, `insights`, and `news`; each payload now carries the resolved stock symbol even when sourced from older cache entries +- [x] `stocks financials` JSON now normalizes `instrument.symbol` to the exchange-qualified ticker (for example `BBCA.JK`), and older cached payloads are backfilled at read time so users do not need to clear cache after upgrading +- [x] Product decision on `2026-04-02`: keep `screen` under `stocks` for now; revisit a dedicated surface only when `screen query` / `screen presets` graduate from backlog into a richer workflow - [x] Fixture-backed parser and CLI JSON regression coverage now covers the remaining MSN-only `sentiment`, `news`, and `screen` commands - [x] Fresh post-coverage live MSN smoke rerun still passes for table and JSON surfaces: 30/30 (`tmp/live-smoke/20260327-163403`) - [ ] `ownership import --fetch-bing` is still deferred and returns unsupported diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 740fcbd..cffccc7 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -17,6 +17,7 @@ scripts/live-smoke.sh --mode mock scripts/live-smoke.sh --group live-table --group live-json scripts/live-smoke.sh --group cache --symbol BBRI scripts/live-smoke.sh --dry-run --mode full +scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock ``` ## Modes @@ -41,6 +42,8 @@ scripts/live-smoke.sh --dry-run --mode full - The runner forces `IDX_OUTPUT=table` as its default environment so table cases stay stable; JSON checks use `-o json` explicitly. - Cache-group warm cases clear the smoke cache before they run so each warm/offline/stale sequence starts clean and stale-cache assertions are not masked by earlier groups. +- Use `--bin --no-build` when you want to validate an installed binary instead of the workspace `target/debug/idx` build. - Ownership commands that need imported data are intentionally not part of the baseline runner yet. The current baseline only covers `ownership releases` and the known unsupported `ownership import --fetch-bing`. +- `ownership sync` is still primarily covered by fixture-backed CLI tests rather than the reusable smoke runner. - The new `ownership-import` group is intentionally opt-in for explicit `--group ownership-import` runs or `--mode full`; it discovers live URLs first, imports the supported `above1` attachment into the temp DB, then asserts the current `above5` and `investor-type` URLs fail with explicit unsupported-schema UX. - When a case fails, inspect the per-case log in `tmp/live-smoke/.../logs/` before updating `TODO.md` or `FEATURE_SPEC.md`. diff --git a/flake.nix b/flake.nix index ce30017..b5f318f 100644 --- a/flake.nix +++ b/flake.nix @@ -15,22 +15,56 @@ let overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { inherit system overlays; }; + lib = pkgs.lib; + cargoManifest = builtins.fromTOML (builtins.readFile ./Cargo.toml); rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" "rust-analyzer" ]; }; + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + runtimeDeps = with pkgs; [ + curl-impersonate + mupdf + ]; + idxPackage = rustPlatform.buildRustPackage { + pname = cargoManifest.package.name; + version = cargoManifest.package.version; + src = lib.cleanSource ./.; + cargoLock = { + lockFile = ./Cargo.lock; + }; + doCheck = false; + nativeBuildInputs = with pkgs; [ + makeWrapper + pkg-config + ]; + buildInputs = with pkgs; [ + openssl + ]; + postInstall = '' + wrapProgram "$out/bin/idx" \ + --prefix PATH : "${lib.makeBinPath runtimeDeps}" + ''; + }; in { + packages.default = idxPackage; + apps.default = { + type = "app"; + program = "${idxPackage}/bin/idx"; + }; + checks.default = idxPackage; + devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ + inputsFrom = [ idxPackage ]; + packages = with pkgs; [ rustToolchain - pkg-config - openssl cargo-watch cargo-nextest prek - curl-impersonate # required for Yahoo Finance auth (curl_chrome* binaries for TLS fingerprinting) - mupdf # mutool for KSEI PDF parsing (ownership module) - ]; + ] ++ runtimeDeps; shellHook = '' export PATH="$PWD/target/debug:$PATH" diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 5f0d8a5..6a6a635 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -350,6 +350,7 @@ prepare_paths() { build_binary() { local build_log + local stale_input if [[ -z "$BIN_PATH" ]]; then BIN_PATH="$ROOT_DIR/target/debug/idx" @@ -371,6 +372,27 @@ build_binary() { fi fi + if (( BUILD == 0 )) && [[ "$BIN_PATH" == "$ROOT_DIR/target/debug/idx" && -x "$BIN_PATH" ]]; then + stale_input="$( + find \ + "$ROOT_DIR/src" \ + "$ROOT_DIR/tests" \ + "$ROOT_DIR/Cargo.toml" \ + "$ROOT_DIR/Cargo.lock" \ + "$ROOT_DIR/scripts/live-smoke.sh" \ + -type f \ + -newer "$BIN_PATH" \ + -print \ + -quit \ + 2>/dev/null + )" + if [[ -n "$stale_input" ]]; then + echo "refusing to run smoke checks against stale $BIN_PATH; newer input detected at $stale_input" >&2 + echo "rebuild first or omit --no-build so the runner refreshes target/debug/idx" >&2 + exit 1 + fi + fi + if (( BUILD )); then build_log="$LOG_DIR/build.log" ( diff --git a/src/api/mod.rs b/src/api/mod.rs index 3cd552c..36452a1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -62,6 +62,75 @@ pub trait NewsProvider { fn news(&self, symbol: &str, limit: usize) -> Result, IdxError>; } +#[allow(dead_code)] +pub trait ScreenerProvider { + fn screener(&self, filter: &str, region: &str, limit: usize) -> Result, IdxError>; +} + +pub struct SelectedProvider { + kind: ProviderKind, + market: Box, + profile: Option>, + financials: Option>, + earnings: Option>, + sentiment: Option>, + insights: Option>, + news: Option>, + screener: Option>, +} + +impl SelectedProvider { + pub fn kind(&self) -> ProviderKind { + self.kind + } + + pub fn market(&self) -> &dyn MarketDataProvider { + self.market.as_ref() + } + + pub fn profile_provider(&self, subject: &str) -> Result<&dyn ProfileProvider, IdxError> { + self.profile + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn financials_provider(&self, subject: &str) -> Result<&dyn FinancialsProvider, IdxError> { + self.financials + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn earnings_provider(&self, subject: &str) -> Result<&dyn EarningsProvider, IdxError> { + self.earnings + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn sentiment_provider(&self, subject: &str) -> Result<&dyn SentimentProvider, IdxError> { + self.sentiment + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn insights_provider(&self, subject: &str) -> Result<&dyn InsightsProvider, IdxError> { + self.insights + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn news_provider(&self, subject: &str) -> Result<&dyn NewsProvider, IdxError> { + self.news + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } + + pub fn screener_provider(&self, subject: &str) -> Result<&dyn ScreenerProvider, IdxError> { + self.screener + .as_deref() + .ok_or_else(|| msn_capability_error(subject)) + } +} + pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result { let trimmed = symbol.trim().to_uppercase(); if trimmed.is_empty() { @@ -78,17 +147,71 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase())) } -pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box { - if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { - Box::new(MockProvider::from_fixtures(provider)) - } else { - match provider { - ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)), - ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)), - } +pub fn default_provider(provider: ProviderKind, verbose: bool) -> SelectedProvider { + build_selected_provider( + provider, + verbose, + std::env::var("IDX_USE_MOCK_PROVIDER").is_ok(), + ) +} + +fn build_selected_provider( + provider: ProviderKind, + verbose: bool, + use_mock: bool, +) -> SelectedProvider { + match (provider, use_mock) { + (ProviderKind::Yahoo, true) => SelectedProvider { + kind: ProviderKind::Yahoo, + market: Box::new(MockProvider::from_fixtures(ProviderKind::Yahoo)), + profile: None, + financials: None, + earnings: None, + sentiment: None, + insights: None, + 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 { + 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)), + 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))), + }, } } +fn msn_capability_error(subject: &str) -> IdxError { + IdxError::Unsupported(format!("{subject}: command requires --provider msn")) +} + /// Resolves a history provider based on the selected market data provider and /// history provider strategy. /// @@ -115,7 +238,12 @@ pub fn history_provider( .into(), )); } - return Ok((resolved, Box::new(MockProvider::from_fixtures(resolved)))); + return Ok(( + resolved, + Box::new(MockProvider::from_fixtures_with_history_verbose( + resolved, verbose, + )), + )); } match resolved { @@ -135,21 +263,30 @@ pub struct MockProvider { 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(), + ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose), ProviderKind::Msn => Self::from_msn_fixtures(), } } - fn from_yahoo_fixtures() -> Self { + 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_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.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()); @@ -157,8 +294,9 @@ impl MockProvider { .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("BBCA.JK", &history_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, @@ -170,8 +308,10 @@ impl MockProvider { 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_raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_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())); @@ -225,7 +365,8 @@ impl HistoryProvider for MockProvider { #[cfg(test)] mod tests { - use super::resolve_symbol; + use super::{ProviderKind, build_selected_provider, resolve_symbol}; + use crate::error::IdxError; #[test] fn resolves_symbol_variants() { @@ -241,4 +382,60 @@ mod tests { // Valid ticker returns Ok assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK"); } + + #[test] + fn selected_provider_exposes_expected_capabilities_for_msn() { + let provider = build_selected_provider(ProviderKind::Msn, false, false); + + assert!(provider.profile_provider("BBCA.JK").is_ok()); + assert!(provider.financials_provider("BBCA.JK").is_ok()); + assert!(provider.earnings_provider("BBCA.JK").is_ok()); + assert!(provider.sentiment_provider("BBCA.JK").is_ok()); + assert!(provider.insights_provider("BBCA.JK").is_ok()); + assert!(provider.news_provider("BBCA.JK").is_ok()); + assert!(provider.screener_provider("screen").is_ok()); + } + + #[test] + fn selected_provider_exposes_expected_capabilities_for_mock_msn() { + let provider = build_selected_provider(ProviderKind::Msn, false, true); + + assert!(provider.profile_provider("BBCA.JK").is_ok()); + assert!(provider.financials_provider("BBCA.JK").is_ok()); + assert!(provider.earnings_provider("BBCA.JK").is_ok()); + assert!(provider.sentiment_provider("BBCA.JK").is_ok()); + assert!(provider.insights_provider("BBCA.JK").is_ok()); + assert!(provider.news_provider("BBCA.JK").is_ok()); + assert!(provider.screener_provider("screen").is_ok()); + } + + #[test] + fn selected_provider_rejects_msn_only_capabilities_for_yahoo() { + let provider = build_selected_provider(ProviderKind::Yahoo, false, false); + let err = match provider.profile_provider("BBCA.JK") { + Ok(_) => panic!("yahoo should not expose msn-only profile"), + Err(err) => err, + }; + + assert!(matches!(err, IdxError::Unsupported(_))); + assert_eq!( + err.to_string(), + "unsupported: BBCA.JK: command requires --provider msn" + ); + } + + #[test] + fn selected_provider_rejects_msn_only_capabilities_for_mock_yahoo() { + let provider = build_selected_provider(ProviderKind::Yahoo, false, true); + let err = match provider.screener_provider("screen") { + Ok(_) => panic!("mock yahoo should not expose msn-only screener"), + Err(err) => err, + }; + + assert!(matches!(err, IdxError::Unsupported(_))); + assert_eq!( + err.to_string(), + "unsupported: screen: command requires --provider msn" + ); + } } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 2752980..a0459a3 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -65,13 +65,11 @@ pub(super) fn parse_fundamentals( quote: Option<&MsnQuote>, ) -> Result { let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?; - let metrics = if ratios.company_metrics.is_empty() { - &ratios.industry_metrics - } else { - &ratios.company_metrics - }; - if preferred_metric(metrics).is_none() { - return Err(IdxError::ProviderUnavailable); + let metrics = &ratios.company_metrics; + if metrics.is_empty() || !metrics.iter().any(metric_has_supported_values) { + return Err(IdxError::Unsupported( + "company fundamentals unavailable from MSN; industry fallback is disabled".into(), + )); } Ok(Fundamentals { @@ -105,8 +103,31 @@ pub(super) fn parse_fundamentals( }) } -fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> { - metrics.iter().max_by_key(|metric| metric_rank(metric)) +fn metric_has_supported_values(metric: &IndustryMetric) -> bool { + [ + metric + .price_to_earnings_ratio + .filter(|value| value.is_finite()), + metric + .forward_price_to_eps + .filter(|value| value.is_finite()), + metric.price_to_book_ratio.filter(|value| value.is_finite()), + normalize_percentish(metric.roe), + normalize_percentish(metric.profit_margin.or(metric.net_margin)), + normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current)), + normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate)), + normalize_percentish( + metric + .net_income_ytd_ytd_growth_rate + .or(metric.earnings_growth_rate), + ), + metric + .debt_to_equity_ratio + .filter(|value| value.is_finite()), + sanitize_current_ratio(metric.current_ratio), + ] + .into_iter() + .any(|value| value.is_some()) } fn best_metric_value( @@ -398,7 +419,8 @@ pub(super) fn parse_financial_statements( .and_then(|v| v.instrument_id.clone()) .unwrap_or_default(), symbol: instrument - .and_then(|v| v.symbol.clone()) + .and_then(|v| v.symbol.as_deref().and_then(ticker_from_symbol)) + .map(|ticker| normalized_symbol(symbol, &ticker)) .unwrap_or_else(|| symbol.to_string()), name: instrument .and_then(|v| v.display_name.clone().or_else(|| v.short_name.clone())) @@ -411,7 +433,7 @@ pub(super) fn parse_financial_statements( } pub(super) fn parse_earnings( - _symbol: &str, + symbol: &str, raw: &RawEarningsResponse, ) -> Result { let mut forecast = Vec::new(); @@ -430,6 +452,7 @@ pub(super) fn parse_earnings( history.sort_by_key(|row| row.earning_release_date.clone().unwrap_or_default()); Ok(EarningsReport { + symbol: symbol.to_string(), eps_last_year: raw.eps_last_year.unwrap_or_default(), revenue_last_year: raw.revenue_last_year.unwrap_or_default(), forecast, @@ -504,6 +527,7 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result Result Result, IdxError> { +pub(super) fn parse_news(symbol: &str, raw: &RawNewsFeed) -> Result, IdxError> { let source = raw .sub_cards .as_ref() @@ -529,6 +553,7 @@ pub(super) fn parse_news(raw: &RawNewsFeed) -> Result, IdxError> { .iter() .map(|item| NewsItem { id: item.id.clone().unwrap_or_default(), + symbol: symbol.to_string(), title: item.title.clone().unwrap_or_default(), url: item.url.clone().unwrap_or_default(), description: item.description.clone().unwrap_or_default(), @@ -549,12 +574,10 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result = quotes .iter() - .map(|q| { - let raw_price = q.price.unwrap_or(0.0); + .filter_map(|q| { + let raw_price = q.price.filter(|price| price.is_finite() && *price > 0.0)?; let price = round_price(raw_price); let prev_close = q.price_previous_close.map(round_price); let change = prev_close @@ -565,7 +588,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result low => { let pos = (raw_price - low) / (high - low); @@ -580,7 +603,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result (None, None), }; - Quote { + Some(Quote { symbol: normalized_symbol(&ticker, &ticker), price, change, @@ -593,7 +616,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result = serde_json::from_str( + r#"[ + { + "underlyingInstrument": { + "instrumentId": "bn91jc", + "displayName": "Bank Central Asia Tbk PT", + "symbol": "BBCA" + }, + "balanceSheets": { + "currency": "IDR", + "reportDate": "2025-03-31T00:00:00Z", + "endDate": "2025-03-31T00:00:00Z", + "totalAssets": 1533763445000000.0 + } + } + ]"#, + ) + .expect("financial statements should deserialize"); + + let financials = + parse_financial_statements("BBCA.JK", &raw).expect("financials should parse"); + + assert_eq!(financials.instrument.id, "bn91jc"); + assert_eq!(financials.instrument.symbol, "BBCA.JK"); + assert_eq!(financials.instrument.name, "Bank Central Asia Tbk PT"); + } + #[test] fn parses_screener_fixture_quotes() { let raw: RawScreenerResponse = serde_json::from_str(include_str!( @@ -736,4 +791,74 @@ mod tests { assert_eq!(quotes[0].range_signal.as_deref(), Some("upper")); assert_eq!(quotes[0].avg_volume, Some(10_000_000)); } + + #[test] + fn parse_screener_results_filters_invalid_rows() { + let raw: RawScreenerResponse = serde_json::from_str( + r#"{ + "quote": [ + { "symbol": "BBCA", "price": 9875, "pricePreviousClose": 9758 }, + { "symbol": "BBRI", "price": 0 }, + { "symbol": "BMRI" }, + { "symbol": "", "price": 5150 } + ] + }"#, + ) + .expect("screener fixture should deserialize"); + + let quotes = parse_screener_results(&raw).expect("screener should parse"); + + assert_eq!(quotes.len(), 1); + assert_eq!(quotes[0].symbol, "BBCA.JK"); + assert_eq!(quotes[0].price, 9_875); + } + + #[test] + fn parse_screener_results_errors_when_all_rows_are_invalid() { + let raw: RawScreenerResponse = serde_json::from_str( + r#"{ + "quote": [ + { "symbol": "BBRI", "price": 0 }, + { "symbol": "BMRI" } + ] + }"#, + ) + .expect("screener fixture should deserialize"); + + let err = parse_screener_results(&raw).expect_err("invalid screener rows should fail"); + + assert!(matches!(err, IdxError::ParseError(_))); + assert_eq!( + err.to_string(), + "parse error: screener returned no priced stocks" + ); + } + + #[test] + fn parse_fundamentals_rejects_industry_only_metrics() { + let raw: Vec = serde_json::from_str( + r#"[ + { + "industryMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "priceToEarningsRatio": 12.5, + "priceToBookRatio": 1.7 + } + ], + "companyMetrics": [] + } + ]"#, + ) + .expect("key ratios should deserialize"); + + let err = parse_fundamentals(&raw, None).expect_err("industry fallback should be rejected"); + + assert!(matches!(err, IdxError::Unsupported(_))); + assert_eq!( + err.to_string(), + "unsupported: company fundamentals unavailable from MSN; industry fallback is disabled" + ); + } } diff --git a/src/api/msn/mod.rs b/src/api/msn/mod.rs index 39f0fda..c8cb4c1 100644 --- a/src/api/msn/mod.rs +++ b/src/api/msn/mod.rs @@ -12,7 +12,7 @@ use crate::api::types::{ }; use crate::api::{ EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider, - ProfileProvider, QuoteProvider, SentimentProvider, + ProfileProvider, QuoteProvider, ScreenerProvider, SentimentProvider, }; use crate::error::IdxError; @@ -34,16 +34,6 @@ impl MsnProvider { client: MsnClient::new(), } } - - pub fn screener( - &self, - filter: &str, - region: &str, - limit: usize, - ) -> Result, IdxError> { - let raw = self.client.fetch_screener(filter, region, limit)?; - parse_screener_results(&raw) - } } impl QuoteProvider for MsnProvider { @@ -99,6 +89,13 @@ impl InsightsProvider for MsnProvider { impl NewsProvider for MsnProvider { fn news(&self, symbol: &str, limit: usize) -> Result, IdxError> { let raw = self.client.fetch_news(symbol, limit)?; - parse_news(&raw) + parse_news(symbol, &raw) + } +} + +impl ScreenerProvider for MsnProvider { + fn screener(&self, filter: &str, region: &str, limit: usize) -> Result, IdxError> { + let raw = self.client.fetch_screener(filter, region, limit)?; + parse_screener_results(&raw) } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index d447ed5..47ed8eb 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -35,6 +35,7 @@ pub(crate) struct MsnQuote { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct KeyRatios { + #[allow(dead_code)] #[serde(default)] pub(crate) industry_metrics: Vec, #[serde(default)] diff --git a/src/api/types.rs b/src/api/types.rs index 90d8682..606d0ba 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -143,6 +143,8 @@ pub struct StatementSection { #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EarningsReport { + #[serde(default)] + pub symbol: String, pub eps_last_year: f64, pub revenue_last_year: f64, pub forecast: Vec, @@ -183,6 +185,8 @@ pub struct SentimentPeriod { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InsightData { pub id: String, + #[serde(default)] + pub symbol: String, pub summary: String, pub highlights: Vec, pub risks: Vec, @@ -193,6 +197,8 @@ pub struct InsightData { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewsItem { pub id: String, + #[serde(default)] + pub symbol: String, pub title: String, pub url: String, pub description: String, diff --git a/src/api/yahoo/client.rs b/src/api/yahoo/client.rs index eb913f8..0f96262 100644 --- a/src/api/yahoo/client.rs +++ b/src/api/yahoo/client.rs @@ -1,11 +1,13 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::process::Stdio; use std::sync::Mutex; use std::time::Duration; use crate::api::types::{Interval, Period}; use crate::curl_impersonate; use crate::error::IdxError; +use crate::runtime; use super::raw_types::{ChartResponse, QuoteSummaryResponse}; @@ -48,7 +50,7 @@ impl YahooClient { } fn cookie_jar_path() -> PathBuf { - PathBuf::from(format!("/tmp/idx_yf_{}.txt", std::process::id())) + std::env::temp_dir().join(format!("idx_yf_{}.txt", std::process::id())) } pub(super) fn parse_crumb_body(raw: &str) -> Result { @@ -132,14 +134,9 @@ impl YahooClient { // Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar). // We allow non-zero exit here since 404 still writes the cookie. let _ = Command::new(binary) - .args([ - "--silent", - "--cookie-jar", - cookie_jar_str, - COOKIE_FETCH_URL, - "--output", - "/dev/null", - ]) + .args(["--silent", "--cookie-jar", cookie_jar_str, COOKIE_FETCH_URL]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .output(); // Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie). @@ -176,36 +173,60 @@ impl YahooClient { Ok(()) } + fn retry_rate_limited(&self, request: F) -> Result, IdxError> + where + F: FnMut() -> Result, + { + Self::retry_rate_limited_with(request, std::thread::sleep, jitter) + } + + fn retry_rate_limited_with( + mut request: F, + mut sleeper: S, + mut jitter_fn: J, + ) -> Result, IdxError> + where + F: FnMut() -> Result, + S: FnMut(Duration), + J: FnMut() -> Duration, + { + let mut wait = Duration::from_millis(250); + + for attempt in 0..3 { + match request() { + Err(ureq::Error::StatusCode(429)) => { + if attempt < 2 { + sleeper(wait + jitter_fn()); + wait *= 2; + continue; + } + return Err(IdxError::RateLimited); + } + other => return Ok(other), + } + } + + Err(IdxError::RateLimited) + } + pub(super) fn fetch_chart( &self, symbol: &str, period: &Period, interval: &Interval, ) -> Result { - let mut wait = Duration::from_millis(250); - for attempt in 0..3 { - let url = Self::chart_url(symbol, period, interval); - let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call(); - match response { - Ok(ok) => { - return ok - .into_body() - .read_json::() - .map_err(|e| IdxError::ParseError(e.to_string())); - } - Err(ureq::Error::StatusCode(429)) => { - if attempt < 2 { - std::thread::sleep(wait + jitter()); - wait *= 2; - } - } - Err(ureq::Error::StatusCode(404)) => { - return Err(IdxError::SymbolNotFound(symbol.to_string())); - } - Err(e) => return Err(IdxError::Http(e.to_string())), - } + let url = Self::chart_url(symbol, period, interval); + let response = self + .retry_rate_limited(|| self.agent.get(&url).header("User-Agent", USER_AGENT).call())?; + + match response { + Ok(ok) => ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(e.to_string())), + Err(ureq::Error::StatusCode(404)) => Err(IdxError::SymbolNotFound(symbol.to_string())), + Err(e) => Err(IdxError::Http(e.to_string())), } - Err(IdxError::RateLimited) } pub(super) fn fetch_quote_summary( @@ -217,48 +238,41 @@ impl YahooClient { let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) { Ok(header) => header, Err(err) => { - eprintln!("warning: failed to parse Yahoo cookie jar: {err}"); + runtime::warn(format!("failed to parse Yahoo cookie jar: {err}")); return Err(IdxError::AuthError(format!( "failed to parse Yahoo cookies: {err}" ))); } }; let url = Self::quote_summary_url(symbol, &crumb); - let mut wait = Duration::from_millis(250); - - for attempt in 0..3 { + let response = self.retry_rate_limited(|| { let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT); if !cookie_header.is_empty() { req = req.header("Cookie", &cookie_header); } - let response = req.call(); - match response { - Ok(ok) => { - return ok - .into_body() - .read_json::() - .map_err(|e| IdxError::ParseError(e.to_string())); - } - Err(ureq::Error::StatusCode(401)) => { - if auth_attempt == 0 { - self.clear_crumb()?; - break; - } - return Err(IdxError::Http( - "yahoo quoteSummary returned unauthorized (401)".to_string(), - )); - } - Err(ureq::Error::StatusCode(429)) => { - if attempt < 2 { - std::thread::sleep(wait + jitter()); - wait *= 2; - } - } - Err(ureq::Error::StatusCode(404)) => { - return Err(IdxError::SymbolNotFound(symbol.to_string())); - } - Err(e) => return Err(IdxError::Http(e.to_string())), + req.call() + })?; + + match response { + Ok(ok) => { + return ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(e.to_string())); } + Err(ureq::Error::StatusCode(401)) => { + if auth_attempt == 0 { + self.clear_crumb()?; + continue; + } + return Err(IdxError::Http( + "yahoo quoteSummary returned unauthorized (401)".to_string(), + )); + } + Err(ureq::Error::StatusCode(404)) => { + return Err(IdxError::SymbolNotFound(symbol.to_string())); + } + Err(e) => return Err(IdxError::Http(e.to_string())), } } @@ -273,6 +287,7 @@ fn jitter() -> Duration { #[cfg(test)] mod tests { use super::YahooClient; + use std::time::Duration; #[test] fn parses_crumb_body_trimmed() { @@ -290,4 +305,55 @@ mod tests { .expect_err("rate limited crumb must fail"); assert!(matches!(rate_limited, crate::error::IdxError::Http(_))); } + + #[test] + fn retry_rate_limited_succeeds_after_retry() { + let mut attempts = 0; + let mut sleeps = Vec::new(); + + let result = YahooClient::retry_rate_limited_with( + || { + attempts += 1; + if attempts < 3 { + Err(ureq::Error::StatusCode(429)) + } else { + Ok("ok") + } + }, + |duration| sleeps.push(duration), + || Duration::ZERO, + ) + .expect("retry helper should not fail") + .expect("third attempt should succeed"); + + assert_eq!(result, "ok"); + assert_eq!(attempts, 3); + assert_eq!( + sleeps, + vec![Duration::from_millis(250), Duration::from_millis(500)] + ); + } + + #[test] + fn retry_rate_limited_returns_rate_limited_after_exhaustion() { + let mut attempts = 0; + let mut sleeps = Vec::new(); + + let err = YahooClient::retry_rate_limited_with( + || { + attempts += 1; + Err::<(), ureq::Error>(ureq::Error::StatusCode(429)) + }, + |duration| sleeps.push(duration), + || Duration::ZERO, + ) + .expect_err("rate-limited helper should fail after three attempts"); + + assert!(matches!(err, crate::error::IdxError::RateLimited)); + assert_eq!(attempts, 3); + assert_eq!( + sleeps, + vec![Duration::from_millis(250), Duration::from_millis(500)] + ); + } } diff --git a/src/api/yahoo/mod.rs b/src/api/yahoo/mod.rs index a3f7f3b..ca8fd7c 100644 --- a/src/api/yahoo/mod.rs +++ b/src/api/yahoo/mod.rs @@ -11,7 +11,9 @@ use client::YahooClient; use map::{parse_fundamentals, parse_quote}; use parse::parse_history_with_verbose; -pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; +pub(crate) use parse::{ + parse_fundamentals_from_str, parse_history_from_str_with_verbose, parse_quote_from_str, +}; pub struct YahooProvider { client: YahooClient, diff --git a/src/api/yahoo/parse.rs b/src/api/yahoo/parse.rs index deafa55..fd25299 100644 --- a/src/api/yahoo/parse.rs +++ b/src/api/yahoo/parse.rs @@ -10,10 +10,19 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result Result, IdxError> { + parse_history_from_str_with_verbose(symbol, raw, false) +} + +pub(crate) fn parse_history_from_str_with_verbose( + symbol: &str, + raw: &str, + verbose: bool, +) -> Result, IdxError> { let chart: ChartResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; - parse_history_with_verbose(symbol, &chart, false) + parse_history_with_verbose(symbol, &chart, verbose) } pub(crate) fn parse_fundamentals_from_str( @@ -32,9 +41,9 @@ pub(super) fn parse_history_with_verbose( ) -> Result, IdxError> { let (history, dropped) = parse_history(symbol, chart)?; if dropped > 0 && verbose { - eprintln!( - "warning: dropped {dropped} OHLC row(s) from Yahoo response due to missing fields" - ); + crate::runtime::warn(format!( + "dropped {dropped} OHLC row(s) from Yahoo response due to missing fields" + )); } Ok(history) } @@ -43,7 +52,8 @@ pub(super) fn parse_history_with_verbose( mod tests { use super::{ ChartResponse, parse_fundamentals_from_str, parse_history_from_str, - parse_history_with_verbose, parse_quote, parse_quote_from_str, + parse_history_from_str_with_verbose, parse_history_with_verbose, parse_quote, + parse_quote_from_str, }; const SAMPLE: &str = r#"{ @@ -100,6 +110,11 @@ mod tests { parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed"); assert!(!history.is_empty()); + let verbose_history = parse_history_from_str_with_verbose("BBCA.JK", &history_raw, true) + .expect("fixture history parsed in verbose mode"); + assert_eq!(verbose_history.len(), history.len()); + assert_eq!(verbose_history[0].close, history[0].close); + let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) .expect("fixture fundamentals parsed"); assert_eq!(fundamentals.trailing_pe, Some(25.4)); diff --git a/src/cache.rs b/src/cache.rs index cbc9f21..9f7dc21 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -8,6 +8,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use crate::error::IdxError; +use crate::runtime; const CURRENT_SCHEMA_VERSION: u32 = 1; @@ -175,9 +176,9 @@ impl Cache { let entry: CacheEntry = match serde_json::from_str(&raw) { Ok(e) => e, Err(e) => { - eprintln!( - "warning: corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}" - ); + runtime::warn(format!( + "corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}" + )); let _ = fs::remove_file(&path); return Ok(None); } diff --git a/src/cli/cache.rs b/src/cli/cache.rs index 6181727..9ec9351 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -2,6 +2,7 @@ use clap::{Args, Subcommand}; use crate::cache::Cache; use crate::error::IdxError; +use crate::runtime; #[derive(Debug, Args)] #[command(about = "Manage local cache")] @@ -43,7 +44,7 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> { let (removed, failed) = cache.clear()?; println!("cleared {removed} files"); if !failed.is_empty() { - eprintln!("warning: failed to remove {} file(s)", failed.len()); + runtime::warn(format!("failed to remove {} file(s)", failed.len())); } } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e758d97..d645bd6 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -48,7 +48,7 @@ pub enum Commands { #[command(about = "Manage local cache")] Cache(cache::CacheCmd), #[cfg(feature = "ownership")] - #[command(about = "Ownership intelligence (KSEI + Bing)")] + #[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")] Ownership(ownership::OwnershipCmd), #[command(about = "Generate shell completions")] Completions { shell: Shell }, diff --git a/src/cli/ownership.rs b/src/cli/ownership.rs index 6c3329a..e4c9827 100644 --- a/src/cli/ownership.rs +++ b/src/cli/ownership.rs @@ -5,7 +5,6 @@ use std::path::{Path, PathBuf}; use chrono::Utc; use clap::{Args, Subcommand}; use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL}; -use directories::ProjectDirs; use owo_colors::OwoColorize; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -15,12 +14,18 @@ use crate::error::IdxError; use crate::output::OutputFormat; use crate::output::json; use crate::output::table::format_idr; +use crate::runtime; use crate::ownership::types::{ ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource, }; use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot}; #[derive(Debug, Args)] +#[command( + about = "Ownership intelligence (KSEI + Bing)", + long_about = "Ownership intelligence (KSEI + Bing).\n\nPreferred bootstrap path:\n 1. Run `idx ownership sync` to install or refresh a maintained SQLite snapshot.\n 2. If no snapshot manifest is available, run `idx ownership discover` and then `idx ownership import --url `.\n 3. Local `.pdf`, `.zip`, and `.txt` imports remain available for manual or fallback workflows.", + after_help = "Examples:\n idx ownership sync\n idx ownership sync --manifest /path/to/ownership-snapshot-manifest.json\n idx ownership discover --limit 1\n idx ownership import --url \n idx ownership releases" +)] pub struct OwnershipCmd { #[command(subcommand)] pub command: OwnershipCommand, @@ -28,11 +33,22 @@ pub struct OwnershipCmd { #[derive(Debug, Subcommand)] pub enum OwnershipCommand { - /// Discover the latest IDX-hosted ownership report URLs. + #[command( + about = "Discover the latest IDX-hosted ownership report URLs", + after_help = "Examples:\n idx ownership discover --limit 1\n idx ownership discover --family all --limit 6\n\nUse this when no snapshot manifest is available and you need a direct PDF URL for `idx ownership import --url`." + )] Discover(DiscoverArgs), - /// Import ownership data from KSEI PDF or archive fallback files. + #[command( + about = "Import ownership data directly from source files", + long_about = "Import ownership data directly from source files.\n\nPrefer `idx ownership sync` for normal bootstrap/update flows. Use `import` when you need a direct PDF import from IDX discovery output, a local PDF, or a local KSEI archive fallback file.", + after_help = "Examples:\n idx ownership import --url \n idx ownership import --file /path/to/ksei.pdf\n idx ownership import --file /path/to/BalanceposEfek20260227.zip" + )] Import(ImportArgs), - /// Install or refresh a maintained ownership SQLite snapshot. + #[command( + about = "Install or refresh a maintained ownership SQLite snapshot", + long_about = "Install or refresh a maintained ownership SQLite snapshot.\n\nThis is the normal bootstrap/update path for ownership data.\n\nManifest lookup order:\n 1. `--manifest`\n 2. `IDX_OWNERSHIP_SNAPSHOT_MANIFEST`\n 3. `ownership.snapshot_manifest` in config", + after_help = "Examples:\n idx ownership sync\n idx ownership sync --manifest /path/to/ownership-snapshot-manifest.json\n IDX_OWNERSHIP_SNAPSHOT_MANIFEST=https://example.com/latest.json idx ownership sync" + )] Sync(SyncArgs), /// Show all holders for a ticker (KSEI + Bing combined). Ticker(TickerArgs), @@ -68,26 +84,26 @@ pub struct DiscoverArgs { #[derive(Debug, Args)] pub struct ImportArgs { - /// URL to a remote ownership PDF. + /// Direct URL to a remote ownership PDF, typically from `ownership discover`. #[arg(long)] pub url: Option, - /// Path to local KSEI ownership PDF, ZIP, or TXT file. + /// Path to a local ownership file: PDF (primary), ZIP/TXT archive (fallback). #[arg(long)] pub file: Option, /// Fetch Bing institutional data for these symbols. #[arg(long, value_delimiter = ',')] pub fetch_bing: Option>, - /// Re-import even if already imported. + /// Re-import even if the release SHA-256 has already been seen. #[arg(long)] pub force: bool, } #[derive(Debug, Args)] pub struct SyncArgs { - /// Snapshot manifest location (URL or local path). + /// Snapshot manifest location (URL or local path). If omitted, config/env lookup is used. #[arg(long)] pub manifest: Option, - /// Replace the local DB even when already current or newer. + /// Replace the local DB even when it is already current or newer than the snapshot. #[arg(long)] pub force: bool, } @@ -536,7 +552,10 @@ fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { let flow = db::query_bing_flow(&conn, ticker_id)?; let Some(flow) = flow else { - println!("No institutional flow data. Run: idx ownership import --fetch-bing {symbol}"); + if matches!(config.output, OutputFormat::Json) { + return json::print_json(&Option::::None); + } + println!("No institutional flow data available for {symbol}."); return Ok(()); }; @@ -759,12 +778,12 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> .collect(); if !clean.is_empty() { - eprintln!( - "info: --fetch-bing requested for {} symbol(s), implementation deferred for Sprint 6", + runtime::info(format!( + "--fetch-bing requested for {} symbol(s), implementation deferred for Sprint 6", clean.len() - ); + )); for symbol in &clean { - eprintln!(" - {symbol}"); + runtime::info(format!(" - {symbol}")); } return Err(IdxError::Unsupported( @@ -781,7 +800,23 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> let sha256 = sha256_file(&import_input.import_path)?; if !args.force && db::release_exists(&conn, &sha256)? { - println!("Release already imported (sha256: {sha256}). Use --force to re-import."); + let result = OwnershipImportResult { + action: "skipped_existing", + inserted_rows: 0, + ticker_count: 0, + as_of_date: None, + sha256, + source_url: import_input.source_url, + }; + + if matches!(config.output, OutputFormat::Json) { + return json::print_json(&result); + } + + println!( + "Release already imported (sha256: {}). Use --force to re-import.", + result.sha256 + ); return Ok(()); } @@ -830,7 +865,6 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> }); } - let inserted_rows = db::insert_ksei_holdings(&conn, &holdings)?; let as_of_date = holdings .iter() .map(|h| h.report_date) @@ -844,15 +878,28 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError> source_url: import_input.source_url, sha256, as_of_date, - row_count: inserted_rows, + row_count: holdings.len(), imported_at: Utc::now().timestamp(), }; - let _ = db::insert_release(&conn, &release)?; + let inserted_rows = db::write_ksei_release(&conn, &release, &holdings, args.force)?; + + let result = OwnershipImportResult { + action: if args.force { "reimported" } else { "imported" }, + inserted_rows, + ticker_count: ticker_ids.len(), + as_of_date: Some(as_of_date.format("%Y-%m-%d").to_string()), + sha256: release.sha256.clone(), + source_url: release.source_url.clone(), + }; + + if matches!(config.output, OutputFormat::Json) { + return json::print_json(&result); + } println!( "Imported {} rows for {} tickers (as of {}).", - inserted_rows, - ticker_ids.len(), + result.inserted_rows, + result.ticker_count, as_of_date.format("%Y-%m-%d") ); @@ -916,6 +963,16 @@ struct ResolvedImportInput { 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, @@ -972,9 +1029,7 @@ fn detect_local_import_format(path: &Path) -> Result Result { - let dirs = ProjectDirs::from("", "", "idx") - .ok_or_else(|| IdxError::Io("unable to resolve cache directory".to_string()))?; - let raw_dir = dirs.cache_dir().join("ownership").join("raw"); + let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw"); fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?; let mut file_name = url diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 3d249ad..217ac3c 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; use serde::{Serialize, de::DeserializeOwned}; use crate::analysis::fundamental::{ @@ -9,17 +9,13 @@ use crate::analysis::fundamental::{ }; use crate::analysis::signals::{self, Signal, TechnicalSignal}; use crate::analysis::technical; -use crate::api::msn::MsnProvider; use crate::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, NewsItem, Ohlc, Period, Quote, SentimentData, }; -use crate::api::{ - EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider, - ProfileProvider, SentimentProvider, history_provider, -}; +use crate::api::{MarketDataProvider, SelectedProvider, history_provider}; use crate::cache::Cache; -use crate::config::{HistoryProviderKind, IdxConfig}; +use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind}; use crate::error::IdxError; use crate::output::{ MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, @@ -27,6 +23,7 @@ use crate::output::{ render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, render_technical, render_valuation, }; +use crate::runtime; struct FundamentalCacheSpec { bucket: String, @@ -53,8 +50,67 @@ const SCREENER_FILTERS: &[&str] = &[ const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"]; -fn cache_bucket(config: &IdxConfig, key: &str) -> String { - format!("{}-{key}", config.provider.as_str()) +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, +} + +#[derive(Debug, Clone, Copy, Args, Default)] +pub struct EarningsFilterArgs { + /// Only include forward earnings rows. + #[arg(long)] + forecast: bool, + /// Only include historical earnings rows. + #[arg(long)] + history: bool, + /// Only include annual periods. + #[arg(long)] + annual: bool, + /// Only include quarterly periods. + #[arg(long)] + quarterly: bool, +} + +impl 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, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EarningsPeriodKind { + Annual, + Quarterly, + Unknown, } #[derive(Debug, Args)] @@ -132,10 +188,24 @@ pub enum StocksSubcommand { }, #[command(about = "Get company profile")] Profile { symbol: String }, - #[command(about = "Get financial statements")] - Financials { symbol: String }, - #[command(about = "Get earnings report")] - Earnings { symbol: String }, + #[command( + about = "Get financial statements", + after_help = "Examples:\n idx stocks financials BBCA\n idx stocks financials BBCA --statement income\n idx stocks financials BBCA --statement income,balance" + )] + Financials { + symbol: String, + #[command(flatten)] + filters: FinancialsFilterArgs, + }, + #[command( + about = "Get earnings report", + after_help = "Examples:\n idx stocks earnings BBCA\n idx stocks earnings BBCA --history --quarterly\n idx -o json stocks earnings BBCA --forecast --annual" + )] + Earnings { + symbol: String, + #[command(flatten)] + filters: EarningsFilterArgs, + }, #[command(about = "Get crowd sentiment")] Sentiment { symbol: String }, #[command(about = "Get AI insights")] @@ -168,9 +238,10 @@ pub enum StocksSubcommand { pub fn handle( cmd: &StocksCmd, config: &IdxConfig, - provider: &dyn MarketDataProvider, + provider: &SelectedProvider, offline: bool, no_cache: bool, + verbose: bool, ) -> Result<(), IdxError> { if offline && no_cache { return Err(IdxError::InvalidInput( @@ -182,7 +253,7 @@ pub fn handle( match &cmd.command { StocksSubcommand::Quote { symbols } => { - let quote_bucket = cache_bucket(config, "quote"); + 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)?; @@ -198,7 +269,7 @@ pub fn handle( continue; } - match provider.quote(&resolved) { + match provider.market().quote(&resolved) { Ok(q) => { if !no_cache { cache.put("e_bucket, &resolved, &q, config.quote_ttl)?; @@ -209,9 +280,9 @@ pub fn handle( if !no_cache && let Some(stale) = cache.get_stale("e_bucket, &resolved)? { - eprintln!( - "warning: network failed, serving stale cache for {resolved}" - ); + runtime::warn(format!( + "network failed, serving stale cache for {resolved}" + )); quotes.push(stale); continue; } @@ -229,16 +300,16 @@ pub fn handle( } => { let history_mode = history_provider_override.unwrap_or(config.history_provider); let (history_source, hist_provider) = - history_provider(config.provider, history_mode, false)?; + history_provider(provider.kind(), history_mode, verbose)?; if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != config.provider + && history_source != provider.kind() && !matches!(config.output, crate::output::OutputFormat::Json) { - eprintln!( - "info: history provider fallback active ({} -> {})", - config.provider.as_str(), + 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 = crate::api::resolve_symbol(symbol, &config.exchange)?; @@ -282,7 +353,9 @@ pub fn handle( &format!("{resolved}-{key}"), )? { - eprintln!("warning: network failed, serving stale cache for {resolved}"); + runtime::warn(format!( + "network failed, serving stale cache for {resolved}" + )); return render_history(&resolved, &stale, &config.output); } Err(err) @@ -295,16 +368,16 @@ pub fn handle( } => { let history_mode = history_provider_override.unwrap_or(config.history_provider); let (history_source, hist_provider) = - history_provider(config.provider, history_mode, false)?; + history_provider(provider.kind(), history_mode, verbose)?; if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != config.provider + && history_source != provider.kind() && !matches!(config.output, crate::output::OutputFormat::Json) { - eprintln!( - "info: history provider fallback active ({} -> {})", - config.provider.as_str(), + 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 = crate::api::resolve_symbol(symbol, &config.exchange)?; @@ -333,7 +406,9 @@ pub fn handle( && let Some(stale) = cache.get_stale::(&technical_bucket, &resolved)? { - eprintln!("warning: network failed, serving stale cache for {resolved}"); + runtime::warn(format!( + "network failed, serving stale cache for {resolved}" + )); return render_technical(&stale, &config.output, config.no_color); } Err(err) @@ -344,10 +419,10 @@ pub fn handle( let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: GrowthReport = fetch_fundamental_analysis_report( &cache, - provider, + provider.market(), &resolved, FundamentalCacheSpec { - bucket: cache_bucket(config, "growth"), + bucket: cache_bucket(provider.kind(), "growth"), ttl_secs: config.fundamental_ttl, }, offline, @@ -360,10 +435,10 @@ pub fn handle( let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: ValuationReport = fetch_fundamental_analysis_report( &cache, - provider, + provider.market(), &resolved, FundamentalCacheSpec { - bucket: cache_bucket(config, "valuation"), + bucket: cache_bucket(provider.kind(), "valuation"), ttl_secs: config.fundamental_ttl, }, offline, @@ -376,10 +451,10 @@ pub fn handle( let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: RiskReport = fetch_fundamental_analysis_report( &cache, - provider, + provider.market(), &resolved, FundamentalCacheSpec { - bucket: cache_bucket(config, "risk"), + bucket: cache_bucket(provider.kind(), "risk"), ttl_secs: config.fundamental_ttl, }, offline, @@ -392,10 +467,10 @@ pub fn handle( let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: FundamentalReport = fetch_fundamental_analysis_report( &cache, - provider, + provider.market(), &resolved, FundamentalCacheSpec { - bucket: cache_bucket(config, "fundamental"), + bucket: cache_bucket(provider.kind(), "fundamental"), ttl_secs: config.fundamental_ttl, }, offline, @@ -406,10 +481,10 @@ pub fn handle( } StocksSubcommand::Profile { symbol } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "profile-v2"); - let profile: CompanyProfile = fetch_msn_with_cache( + let bucket = cache_bucket(provider.kind(), "profile-v2"); + let profile_provider = provider.profile_provider(&resolved)?; + let profile: CompanyProfile = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &resolved, @@ -418,16 +493,16 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).profile(&resolved), + || profile_provider.profile(&resolved), )?; render_profile(&profile, &config.output) } - StocksSubcommand::Financials { symbol } => { + StocksSubcommand::Financials { symbol, filters } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "financials"); - let financials: FinancialStatements = fetch_msn_with_cache( + let bucket = cache_bucket(provider.kind(), "financials"); + let financials_provider = provider.financials_provider(&resolved)?; + let mut financials: FinancialStatements = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &resolved, @@ -436,16 +511,22 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).financials(&resolved), + || financials_provider.financials(&resolved), )?; - render_financials(&financials, &config.output) + 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) } - StocksSubcommand::Earnings { symbol } => { + StocksSubcommand::Earnings { symbol, filters } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "earnings"); - let earnings: EarningsReport = fetch_msn_with_cache( + let bucket = cache_bucket(provider.kind(), "earnings"); + let earnings_provider = provider.earnings_provider(&resolved)?; + let mut earnings: EarningsReport = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &resolved, @@ -454,16 +535,20 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).earnings(&resolved), + || earnings_provider.earnings(&resolved), )?; - render_earnings(&earnings, &config.output) + if earnings.symbol.is_empty() { + earnings.symbol = resolved.clone(); + } + let filtered = filter_earnings_report(&earnings, filters); + render_earnings(&filtered, &config.output) } StocksSubcommand::Sentiment { symbol } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "sentiment"); - let sentiment: SentimentData = fetch_msn_with_cache( + let bucket = cache_bucket(provider.kind(), "sentiment"); + let sentiment_provider = provider.sentiment_provider(&resolved)?; + let sentiment: SentimentData = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &resolved, @@ -472,16 +557,16 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).sentiment(&resolved), + || sentiment_provider.sentiment(&resolved), )?; render_sentiment(&sentiment, &config.output) } StocksSubcommand::Insights { symbol } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "insights-v2"); - let insights: InsightData = fetch_msn_with_cache( + let bucket = cache_bucket(provider.kind(), "insights-v2"); + let insights_provider = provider.insights_provider(&resolved)?; + let mut insights: InsightData = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &resolved, @@ -490,17 +575,20 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).insights(&resolved), + || insights_provider.insights(&resolved), )?; + if insights.symbol.is_empty() { + insights.symbol = resolved.clone(); + } render_insights(&insights, &config.output) } StocksSubcommand::News { symbol, limit } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(config, "news"); + let bucket = cache_bucket(provider.kind(), "news"); let key = format!("{resolved}-{limit}"); - let news: Vec = fetch_msn_with_cache( + let news_provider = provider.news_provider(&resolved)?; + let mut news: Vec = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &key, @@ -509,8 +597,13 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).news(&resolved, *limit), + || news_provider.news(&resolved, *limit), )?; + for item in &mut news { + if item.symbol.is_empty() { + item.symbol = resolved.clone(); + } + } render_news(&news, &config.output) } StocksSubcommand::Screen { @@ -524,11 +617,11 @@ pub fn handle( // client-side sorting picks the correct top N. let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap"); let fetch_limit = if needs_full_fetch { 500 } else { *limit }; - let bucket = cache_bucket(config, "screen"); + let bucket = cache_bucket(provider.kind(), "screen"); let key = format!("{filter}:{region}:{fetch_limit}"); - let mut quotes: Vec = fetch_msn_with_cache( + let screener_provider = provider.screener_provider("screen")?; + let mut quotes: Vec = fetch_with_cache( &cache, - config.provider, CacheFetchSpec { bucket: &bucket, key: &key, @@ -537,7 +630,7 @@ pub fn handle( }, offline, no_cache, - || MsnProvider::new(false).screener(filter_key, region_key, fetch_limit), + || screener_provider.screener(filter_key, region_key, fetch_limit), )?; sort_screener_quotes(&mut quotes, filter); quotes.truncate(*limit); @@ -551,10 +644,10 @@ pub fn handle( let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; match fetch_fundamental_analysis_report( &cache, - provider, + provider.market(), &resolved, FundamentalCacheSpec { - bucket: cache_bucket(config, "fundamental"), + bucket: cache_bucket(provider.kind(), "fundamental"), ttl_secs: config.fundamental_ttl, }, offline, @@ -563,7 +656,9 @@ pub fn handle( ) { Ok(report) => reports.push(report), Err(err) => { - eprintln!("warning: failed to fetch fundamentals for {resolved}: {err}"); + runtime::warn(format!( + "failed to fetch fundamentals for {resolved}: {err}" + )); last_error = Some(err); } } @@ -628,10 +723,10 @@ where if !no_cache && let Some(stale) = cache.get_stale::(cache_spec.bucket, cache_spec.key)? { - eprintln!( - "warning: network failed, serving stale cache for {}", + runtime::warn(format!( + "network failed, serving stale cache for {}", cache_spec.subject - ); + )); return Ok(stale); } Err(err) @@ -672,7 +767,9 @@ where } Err(err) => { if !no_cache && let Some(stale) = cache.get_stale::(&cache_spec.bucket, resolved)? { - eprintln!("warning: network failed, serving stale cache for {resolved}"); + runtime::warn(format!( + "network failed, serving stale cache for {resolved}" + )); return Ok(stale); } Err(err) @@ -759,32 +856,69 @@ fn average_last(values: &[f64], period: usize) -> Option { Some(values[start..].iter().sum::() / period as f64) } -fn fetch_msn_with_cache( - cache: &Cache, - provider: crate::config::ProviderKind, - cache_spec: CacheFetchSpec<'_>, - offline: bool, - no_cache: bool, - fetch_fn: F, -) -> Result -where - T: Serialize + DeserializeOwned, - F: FnOnce() -> Result, -{ - ensure_msn_provider(cache_spec.subject, provider)?; - fetch_with_cache(cache, cache_spec, offline, no_cache, fetch_fn) +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 ensure_msn_provider( - subject: &str, - provider: crate::config::ProviderKind, -) -> Result<(), IdxError> { - if !matches!(provider, crate::config::ProviderKind::Msn) { - return Err(IdxError::Unsupported(format!( - "{subject}: command requires --provider msn" - ))); +fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport { + let filter_rows = |rows: &[crate::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() + }, } - Ok(()) +} + +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> { @@ -860,11 +994,19 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) { #[cfg(test)] mod tests { + use std::collections::HashMap; + use chrono::{Days, NaiveDate}; - use super::build_technical_report; + use super::{ + EarningsFilterArgs, EarningsPeriodKind, FinancialStatementKind, FinancialsFilterArgs, + build_technical_report, classify_earnings_period, filter_earnings_report, + filter_financial_statements, + }; use crate::analysis::signals::Signal; - use crate::api::types::Ohlc; + use crate::api::types::{ + EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection, + }; use crate::error::IdxError; #[test] @@ -1016,4 +1158,118 @@ mod tests { 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: HashMap::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)]), + 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)]), + 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); + } } diff --git a/src/config.rs b/src/config.rs index 2274ddd..a0f55d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,14 +143,10 @@ impl IdxConfig { 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 Ok(v) = std::env::var("IDX_CACHE_QUOTE_TTL") - && let Ok(parsed) = v.parse::() - { + if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? { cfg.quote_ttl = parsed; } - if let Ok(v) = std::env::var("IDX_CACHE_FUNDAMENTAL_TTL") - && let Ok(parsed) = v.parse::() - { + if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? { cfg.fundamental_ttl = parsed; } @@ -199,6 +195,27 @@ impl IdxConfig { } } +fn parse_env_ttl(name: &str) -> Result, IdxError> { + let Ok(raw) = std::env::var(name) else { + return Ok(None); + }; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(IdxError::ConfigError(format!( + "invalid {name} value '{raw}': expected a non-negative integer" + ))); + } + + let parsed = trimmed.parse::().map_err(|_| { + IdxError::ConfigError(format!( + "invalid {name} value '{raw}': expected a non-negative integer" + )) + })?; + + Ok(Some(parsed)) +} + 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() } diff --git a/src/main.rs b/src/main.rs index 94a0f6b..83d863c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,33 @@ 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 clap::CommandFactory; use clap::Parser; use clap_complete::{generate, shells}; @@ -27,6 +54,7 @@ fn main() { fn run() -> Result<(), IdxError> { let cli = Cli::parse(); + runtime::set_quiet(cli.quiet); let config = match IdxConfig::load_with_cli(&cli) { Ok(config) => config, Err(err) => { @@ -50,13 +78,16 @@ 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.as_ref(), - cli.offline, - cli.no_cache, - ) { + if let Err(err) = + cli::stocks::handle( + stocks, + &config, + &provider, + cli.offline, + cli.no_cache, + cli.verbose > 0, + ) + { emit_error(&err, &config.output); return Err(err); } diff --git a/src/output/table.rs b/src/output/table.rs index 94199fc..1b92e1b 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -557,7 +557,7 @@ fn trend_context(report: &TechnicalReport) -> String { format_idr(sma50.round() as i64), format_idr(sma200.round() as i64) ), - _ => "Insufficient data".to_string(), + _ => "Trend unavailable (need at least 200 daily candles)".to_string(), } } diff --git a/src/ownership/db.rs b/src/ownership/db.rs index 19a7035..d6cdb77 100644 --- a/src/ownership/db.rs +++ b/src/ownership/db.rs @@ -159,19 +159,11 @@ pub fn upsert_ticker(conn: &Connection, code: &str, name: Option<&str>) -> Resul .map_err(|e| IdxError::DatabaseError(e.to_string())) } -/// Bulk insert KSEI holdings within a transaction. -/// Uses INSERT OR IGNORE for dedup (unique on release_sha256 + ticker_id + raw_investor_name). -pub fn insert_ksei_holdings( - conn: &Connection, - holdings: &[KseiHolding], -) -> Result { +fn insert_ksei_holdings_rows(conn: &Connection, holdings: &[KseiHolding]) -> Result { if holdings.is_empty() { return Ok(0); } - conn.execute("BEGIN IMMEDIATE", []) - .map_err(|e| IdxError::DatabaseError(e.to_string()))?; - let mut inserted = 0usize; for h in holdings { let locality = h.locality.map(locality_to_db); @@ -203,13 +195,34 @@ pub fn insert_ksei_holdings( match changed { Ok(n) => inserted += n, - Err(err) => { - let _ = conn.execute("ROLLBACK", []); - return Err(err); - } + Err(err) => return Err(err), } } + Ok(inserted) +} + +/// Bulk insert KSEI holdings within a transaction. +/// Uses INSERT OR IGNORE for dedup (unique on release_sha256 + ticker_id + raw_investor_name). +pub fn insert_ksei_holdings( + conn: &Connection, + holdings: &[KseiHolding], +) -> Result { + if holdings.is_empty() { + return Ok(0); + } + + conn.execute("BEGIN IMMEDIATE", []) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + + let inserted = match insert_ksei_holdings_rows(conn, holdings) { + Ok(inserted) => inserted, + Err(err) => { + let _ = conn.execute("ROLLBACK", []); + return Err(err); + } + }; + conn.execute("COMMIT", []) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -286,6 +299,48 @@ pub fn insert_release(conn: &Connection, release: &OwnershipRelease) -> Result Result { + conn.execute("BEGIN IMMEDIATE", []) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + + let result = (|| { + if replace_existing { + conn.execute( + "DELETE FROM ksei_holdings WHERE release_sha256 = ?1", + params![&release.sha256], + ) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + conn.execute( + "DELETE FROM ownership_releases WHERE sha256 = ?1", + params![&release.sha256], + ) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + } + + let inserted = insert_ksei_holdings_rows(conn, holdings)?; + let _ = insert_release(conn, release)?; + Ok(inserted) + })(); + + match result { + Ok(inserted) => { + conn.execute("COMMIT", []) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + Ok(inserted) + } + Err(err) => { + let _ = conn.execute("ROLLBACK", []); + Err(err) + } + } +} + /// Check if a release with this SHA-256 already exists. pub fn release_exists(conn: &Connection, sha256: &str) -> Result { let exists: i64 = conn @@ -306,15 +361,8 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result>(0), - ) - .map_err(|e| IdxError::DatabaseError(e.to_string()))? - .map(|s| parse_iso_date(&s)) - .transpose()?; + let latest_ksei = latest_ksei_release(conn)?; + let ksei_as_of = latest_ksei.as_ref().map(|release| release.as_of_date); let bing_as_of = conn .query_row( @@ -326,20 +374,20 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result = Vec::new(); - { + if let Some(latest_ksei) = latest_ksei.as_ref() { let mut stmt = conn .prepare( "SELECT k.entity_id, COALESCE(e.canonical_name, k.raw_investor_name), k.investor_type, k.locality, k.total_shares, k.percentage_bps FROM ksei_holdings k LEFT JOIN entities e ON e.id = k.entity_id - WHERE k.ticker_id = ?1 + WHERE k.ticker_id = ?1 AND k.release_sha256 = ?2 ORDER BY k.percentage_bps DESC, k.total_shares DESC", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let rows = stmt - .query_map(params![ticker.id], |row| { + .query_map(params![ticker.id, &latest_ksei.sha256], |row| { Ok(( row.get::<_, Option>(0)?, row.get::<_, String>(1)?, @@ -368,20 +416,20 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result>(0)?, row.get::<_, String>(1)?, @@ -443,18 +491,18 @@ pub fn query_entity_holdings( let mut holdings = Vec::new(); - { + if let Some(latest_ksei) = latest_ksei_release(conn)? { let mut stmt = conn .prepare( "SELECT t.id, t.code, t.name, t.sector, k.total_shares, k.percentage_bps, k.report_date FROM ksei_holdings k JOIN tickers t ON t.id = k.ticker_id - WHERE k.entity_id = ?1", + WHERE k.entity_id = ?1 AND k.release_sha256 = ?2", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let rows = stmt - .query_map(params![entity_id], |row| { + .query_map(params![entity_id, latest_ksei.sha256], |row| { Ok(EntityTickerRow { ticker: Ticker { id: row.get(0)?, @@ -479,11 +527,19 @@ pub fn query_entity_holdings( { let mut stmt = conn .prepare( - "SELECT t.id, t.code, t.name, t.sector, + "WITH latest_bing AS ( + SELECT ticker_id, MAX(report_date) AS report_date + FROM bing_holdings + GROUP BY ticker_id + ) + SELECT t.id, t.code, t.name, t.sector, COALESCE(b.shares_held, 0), COALESCE(b.pct_ownership_bps, 0), b.report_date FROM bing_holdings b + JOIN latest_bing lb + ON lb.ticker_id = b.ticker_id + AND lb.report_date = b.report_date JOIN tickers t ON t.id = b.ticker_id - WHERE b.entity_id = ?1", + WHERE b.entity_id = ?1 AND b.signal = 'holder'", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -535,55 +591,108 @@ pub fn query_cross_holders( min_tickers: usize, limit: usize, ) -> Result, IdxError> { - let mut stmt = conn - .prepare( - "SELECT e.id, e.canonical_name, e.entity_type, e.country, - COUNT(DISTINCT u.ticker_id) AS ticker_count, - SUM(u.percentage_bps) AS total_bps - FROM entities e - JOIN ( - SELECT entity_id, ticker_id, percentage_bps - FROM ksei_holdings - WHERE entity_id IS NOT NULL - UNION ALL - SELECT entity_id, ticker_id, COALESCE(pct_ownership_bps, 0) AS percentage_bps - FROM bing_holdings - WHERE entity_id IS NOT NULL - ) u ON u.entity_id = e.id - GROUP BY e.id - HAVING COUNT(DISTINCT u.ticker_id) >= ?1 - ORDER BY ticker_count DESC, total_bps DESC, e.canonical_name ASC - LIMIT ?2", - ) - .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + let mut aggregates: std::collections::BTreeMap, i64)> = + std::collections::BTreeMap::new(); - let rows = stmt - .query_map( - params![ - i64::try_from(min_tickers) - .map_err(|e| IdxError::DatabaseError(format!("invalid min_tickers: {e}")))?, - i64::try_from(limit) - .map_err(|e| IdxError::DatabaseError(format!("invalid limit: {e}")))?, - ], - |row| { - Ok(CrossHolderRow { - entity: Entity { + if let Some(latest_ksei) = latest_ksei_release(conn)? { + let mut stmt = conn + .prepare( + "SELECT e.id, e.canonical_name, e.entity_type, e.country, k.ticker_id, k.percentage_bps + FROM ksei_holdings k + JOIN entities e ON e.id = k.entity_id + WHERE k.entity_id IS NOT NULL AND k.release_sha256 = ?1", + ) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + + let rows = stmt + .query_map(params![latest_ksei.sha256], |row| { + Ok(( + Entity { id: row.get(0)?, canonical_name: row.get(1)?, entity_type: row.get(2)?, country: row.get(3)?, }, - ticker_count: row.get::<_, i64>(4)? as usize, - total_bps: row.get(5)?, - }) - }, - ) - .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; - let mut out = Vec::new(); - for row in rows { - out.push(row.map_err(|e| IdxError::DatabaseError(e.to_string()))?); + for row in rows { + let (entity, ticker_id, percentage_bps) = + row.map_err(|e| IdxError::DatabaseError(e.to_string()))?; + let entry = aggregates + .entry(entity.id) + .or_insert_with(|| (entity, std::collections::BTreeSet::new(), 0)); + entry.1.insert(ticker_id); + entry.2 += percentage_bps; + } } + + { + let mut stmt = conn + .prepare( + "WITH latest_bing AS ( + SELECT ticker_id, MAX(report_date) AS report_date + FROM bing_holdings + GROUP BY ticker_id + ) + SELECT e.id, e.canonical_name, e.entity_type, e.country, b.ticker_id, + COALESCE(b.pct_ownership_bps, 0) + FROM bing_holdings b + JOIN latest_bing lb + ON lb.ticker_id = b.ticker_id + AND lb.report_date = b.report_date + JOIN entities e ON e.id = b.entity_id + WHERE b.entity_id IS NOT NULL AND b.signal = 'holder'", + ) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(( + Entity { + id: row.get(0)?, + canonical_name: row.get(1)?, + entity_type: row.get(2)?, + country: row.get(3)?, + }, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }) + .map_err(|e| IdxError::DatabaseError(e.to_string()))?; + + for row in rows { + let (entity, ticker_id, percentage_bps) = + row.map_err(|e| IdxError::DatabaseError(e.to_string()))?; + let entry = aggregates + .entry(entity.id) + .or_insert_with(|| (entity, std::collections::BTreeSet::new(), 0)); + entry.1.insert(ticker_id); + entry.2 += percentage_bps; + } + } + + let mut out = aggregates + .into_values() + .filter_map(|(entity, tickers, total_bps)| { + (tickers.len() >= min_tickers).then_some(CrossHolderRow { + entity, + ticker_count: tickers.len(), + total_bps, + }) + }) + .collect::>(); + + out.sort_by(|a, b| { + b.ticker_count + .cmp(&a.ticker_count) + .then_with(|| b.total_bps.cmp(&a.total_bps)) + .then_with(|| a.entity.canonical_name.cmp(&b.entity.canonical_name)) + }); + out.truncate(limit); Ok(out) } @@ -601,17 +710,22 @@ pub fn query_concentration( ))); } + let Some(latest_ksei) = latest_ksei_release(conn)? else { + return Ok(Vec::new()); + }; + let mut stmt = conn .prepare( "SELECT t.code, k.percentage_bps FROM tickers t JOIN ksei_holdings k ON k.ticker_id = t.id + WHERE k.release_sha256 = ?1 ORDER BY t.code ASC, k.percentage_bps DESC", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let rows = stmt - .query_map([], |row| { + .query_map(params![latest_ksei.sha256], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) }) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -1096,11 +1210,48 @@ pub fn db_path(_config: &IdxConfig) -> Result { } } + if let Ok(dir) = std::env::var("XDG_DATA_HOME") + && !dir.is_empty() + { + let path = PathBuf::from(dir); + if path.is_absolute() { + return Ok(path.join("idx").join("ownership.db")); + } + } + ProjectDirs::from("", "", "idx") .map(|dirs| dirs.data_local_dir().join("ownership.db")) .ok_or_else(|| IdxError::DatabaseError("unable to resolve ownership db path".to_string())) } +#[derive(Debug, Clone)] +struct LatestKseiRelease { + sha256: String, + as_of_date: NaiveDate, +} + +fn latest_ksei_release(conn: &Connection) -> Result, IdxError> { + conn.query_row( + "SELECT sha256, as_of_date + FROM ownership_releases + ORDER BY as_of_date DESC, imported_at DESC + LIMIT 1", + [], + |row| { + Ok(LatestKseiRelease { + sha256: row.get(0)?, + as_of_date: parse_iso_date(&row.get::<_, String>(1)?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + }) + }, + ) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + _ => Err(IdxError::DatabaseError(e.to_string())), + }) +} + fn query_ticker(conn: &Connection, code: &str) -> Result, IdxError> { conn.query_row( "SELECT id, code, name, sector FROM tickers WHERE code = ?1", @@ -1190,7 +1341,7 @@ mod tests { use crate::ownership::db::{ compute_concentration, ensure_schema, get_ticker_id, insert_bing_holdings, insert_ksei_holdings, insert_release, query_concentration, query_cross_holders, - query_ticker_holdings, release_exists, upsert_ticker, + query_ticker_holdings, release_exists, upsert_ticker, write_ksei_release, }; use crate::ownership::types::{ BingHolding, FlowSignal, KseiHolding, Locality, OwnershipRelease, @@ -1278,7 +1429,15 @@ mod tests { }, ]; - assert_eq!(insert_ksei_holdings(&conn, &holdings).unwrap(), 2); + let release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "r1".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: holdings.len(), + imported_at: 1, + }; + assert_eq!(write_ksei_release(&conn, &release, &holdings, false).unwrap(), 2); let data = query_ticker_holdings(&conn, "BBCA").unwrap(); assert_eq!(data.ticker.code, "BBCA"); @@ -1322,6 +1481,165 @@ mod tests { assert_eq!(count, 1); } + #[test] + fn test_write_ksei_release_replaces_existing_sha_when_requested() { + let conn = setup(); + let bbri_id = upsert_ticker(&conn, "BBRI", None).unwrap(); + + let initial = KseiHolding { + id: 0, + ticker_id: bbri_id, + entity_id: None, + raw_investor_name: "FORCE".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 10, + holdings_scrip: 0, + total_shares: 10, + percentage_bps: 100, + report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + release_sha256: "force-sha".to_string(), + }; + let initial_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "force-sha".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: 1, + imported_at: 1, + }; + + assert_eq!( + write_ksei_release(&conn, &initial_release, std::slice::from_ref(&initial), false) + .unwrap(), + 1 + ); + + let replacement = KseiHolding { + percentage_bps: 250, + total_shares: 25, + holdings_scripless: 25, + ..initial + }; + let replacement_release = OwnershipRelease { + imported_at: 2, + ..initial_release + }; + + assert_eq!( + write_ksei_release( + &conn, + &replacement_release, + std::slice::from_ref(&replacement), + true, + ) + .unwrap(), + 1 + ); + + let release_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ownership_releases WHERE sha256 = 'force-sha'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(release_count, 1); + + let latest_bps: i64 = conn + .query_row( + "SELECT percentage_bps FROM ksei_holdings WHERE release_sha256 = 'force-sha'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(latest_bps, 250); + } + + #[test] + fn test_query_ticker_holdings_uses_latest_release_only() { + let conn = setup(); + let tlkm_id = upsert_ticker(&conn, "TLKM", None).unwrap(); + + let older_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "old-release".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 1, 30).unwrap(), + row_count: 2, + imported_at: 1, + }; + let older_rows = vec![ + KseiHolding { + id: 0, + ticker_id: tlkm_id, + entity_id: None, + raw_investor_name: "OLDER A".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 40, + holdings_scrip: 0, + total_shares: 40, + percentage_bps: 4000, + report_date: older_release.as_of_date, + release_sha256: older_release.sha256.clone(), + }, + KseiHolding { + id: 0, + ticker_id: tlkm_id, + entity_id: None, + raw_investor_name: "OLDER B".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 30, + holdings_scrip: 0, + total_shares: 30, + percentage_bps: 3000, + report_date: older_release.as_of_date, + release_sha256: older_release.sha256.clone(), + }, + ]; + write_ksei_release(&conn, &older_release, &older_rows, false).unwrap(); + + let latest_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "latest-release".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: 1, + imported_at: 2, + }; + let latest_rows = vec![KseiHolding { + id: 0, + ticker_id: tlkm_id, + entity_id: None, + raw_investor_name: "LATEST".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 25, + holdings_scrip: 0, + total_shares: 25, + percentage_bps: 2500, + report_date: latest_release.as_of_date, + release_sha256: latest_release.sha256.clone(), + }]; + write_ksei_release(&conn, &latest_release, &latest_rows, false).unwrap(); + + let data = query_ticker_holdings(&conn, "TLKM").unwrap(); + assert_eq!(data.ksei_as_of, Some(latest_release.as_of_date)); + assert_eq!(data.holders.len(), 1); + assert_eq!(data.holders[0].name, "LATEST"); + assert_eq!(data.concentration.top1_bps, 2500); + } + #[test] fn test_cross_holders_same_entity_three_tickers() { let conn = setup(); @@ -1333,9 +1651,10 @@ mod tests { .unwrap(); let eid = conn.last_insert_rowid(); + let mut holdings = Vec::new(); for (code, bps) in [("BBCA", 1000), ("BBRI", 1100), ("BMRI", 1200)] { let tid = upsert_ticker(&conn, code, None).unwrap(); - let h = KseiHolding { + holdings.push(KseiHolding { id: 0, ticker_id: tid, entity_id: Some(eid), @@ -1349,10 +1668,18 @@ mod tests { total_shares: 10, percentage_bps: bps, report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), - release_sha256: format!("r-{code}"), - }; - insert_ksei_holdings(&conn, &[h]).unwrap(); + release_sha256: "current-release".to_string(), + }); } + let release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "current-release".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: holdings.len(), + imported_at: 1, + }; + write_ksei_release(&conn, &release, &holdings, false).unwrap(); let rows = query_cross_holders(&conn, 3, 10).unwrap(); assert_eq!(rows.len(), 1); @@ -1360,6 +1687,97 @@ mod tests { assert_eq!(rows[0].ticker_count, 3); } + #[test] + fn test_cross_holders_use_latest_release_only() { + let conn = setup(); + conn.execute( + "INSERT INTO entities (canonical_name, entity_type, country, created_at, updated_at) + VALUES ('OMEGA', NULL, NULL, 0, 0)", + [], + ) + .unwrap(); + let eid = conn.last_insert_rowid(); + + let aa = upsert_ticker(&conn, "AA", None).unwrap(); + let bb = upsert_ticker(&conn, "BB", None).unwrap(); + + let old_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "old-cross".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 1, 30).unwrap(), + row_count: 2, + imported_at: 1, + }; + let old_rows = vec![ + KseiHolding { + id: 0, + ticker_id: aa, + entity_id: Some(eid), + raw_investor_name: "OMEGA".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 1000, + report_date: old_release.as_of_date, + release_sha256: old_release.sha256.clone(), + }, + KseiHolding { + id: 0, + ticker_id: bb, + entity_id: Some(eid), + raw_investor_name: "OMEGA".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 1200, + report_date: old_release.as_of_date, + release_sha256: old_release.sha256.clone(), + }, + ]; + write_ksei_release(&conn, &old_release, &old_rows, false).unwrap(); + + let latest_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "latest-cross".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: 1, + imported_at: 2, + }; + let latest_rows = vec![KseiHolding { + id: 0, + ticker_id: aa, + entity_id: Some(eid), + raw_investor_name: "OMEGA".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 900, + report_date: latest_release.as_of_date, + release_sha256: latest_release.sha256.clone(), + }]; + write_ksei_release(&conn, &latest_release, &latest_rows, false).unwrap(); + + assert!(query_cross_holders(&conn, 2, 10).unwrap().is_empty()); + let rows = query_cross_holders(&conn, 1, 10).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].ticker_count, 1); + assert_eq!(rows[0].total_bps, 900); + } + #[test] fn test_concentration_sorting() { let conn = setup(); @@ -1373,10 +1791,9 @@ mod tests { (bb, 3000, "B2"), ]; - for (tid, bps, name) in rows { - insert_ksei_holdings( - &conn, - &[KseiHolding { + let holdings = rows + .into_iter() + .map(|(tid, bps, name)| KseiHolding { id: 0, ticker_id: tid, entity_id: None, @@ -1390,11 +1807,18 @@ mod tests { total_shares: 1, percentage_bps: bps, report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), - release_sha256: format!("{tid}-{name}"), - }], - ) - .unwrap(); - } + release_sha256: "current-release".to_string(), + }) + .collect::>(); + let release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "current-release".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: holdings.len(), + imported_at: 1, + }; + write_ksei_release(&conn, &release, &holdings, false).unwrap(); let by_top1 = query_concentration(&conn, "top1", 10).unwrap(); assert_eq!(by_top1[0].0, "AA"); @@ -1406,6 +1830,87 @@ mod tests { assert_eq!(by_hhi[0].0, "AA"); } + #[test] + fn test_concentration_uses_latest_release_only() { + let conn = setup(); + let aa = upsert_ticker(&conn, "AA", None).unwrap(); + + let old_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "old-concentration".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 1, 30).unwrap(), + row_count: 1, + imported_at: 1, + }; + let old_rows = vec![KseiHolding { + id: 0, + ticker_id: aa, + entity_id: None, + raw_investor_name: "OLD".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 6000, + report_date: old_release.as_of_date, + release_sha256: old_release.sha256.clone(), + }]; + write_ksei_release(&conn, &old_release, &old_rows, false).unwrap(); + + let latest_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "latest-concentration".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: 2, + imported_at: 2, + }; + let latest_rows = vec![ + KseiHolding { + id: 0, + ticker_id: aa, + entity_id: None, + raw_investor_name: "NEW A".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 2000, + report_date: latest_release.as_of_date, + release_sha256: latest_release.sha256.clone(), + }, + KseiHolding { + id: 0, + ticker_id: aa, + entity_id: None, + raw_investor_name: "NEW B".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 1500, + report_date: latest_release.as_of_date, + release_sha256: latest_release.sha256.clone(), + }, + ]; + write_ksei_release(&conn, &latest_release, &latest_rows, false).unwrap(); + + let rows = query_concentration(&conn, "top1", 10).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].1.top1_bps, 2000); + assert_eq!(rows[0].1.top3_bps, 3500); + } + #[test] fn test_insert_bing_and_query_empty_db_paths() { let conn = setup(); diff --git a/src/ownership/graph.rs b/src/ownership/graph.rs index 7b038f6..b9a8095 100644 --- a/src/ownership/graph.rs +++ b/src/ownership/graph.rs @@ -26,12 +26,24 @@ pub fn query_ownership_graph( let mut stmt = conn .prepare( "WITH RECURSIVE + latest_ksei AS ( + SELECT sha256 + FROM ownership_releases + ORDER BY as_of_date DESC, imported_at DESC + LIMIT 1 + ), + latest_bing AS ( + SELECT ticker_id, MAX(report_date) AS report_date + FROM bing_holdings + GROUP BY ticker_id + ), all_edges AS ( SELECT 'entity:' || k.entity_id AS from_id, 'ticker:' || t.code AS to_id FROM ksei_holdings k JOIN tickers t ON t.id = k.ticker_id + JOIN latest_ksei lk ON lk.sha256 = k.release_sha256 WHERE k.entity_id IS NOT NULL UNION @@ -40,8 +52,12 @@ pub fn query_ownership_graph( 'entity:' || b.entity_id AS from_id, 'ticker:' || t.code AS to_id FROM bing_holdings b + JOIN latest_bing lb + ON lb.ticker_id = b.ticker_id + AND lb.report_date = b.report_date JOIN tickers t ON t.id = b.ticker_id WHERE b.entity_id IS NOT NULL + AND b.signal = 'holder' ), neighbors AS ( SELECT from_id AS a, to_id AS b FROM all_edges @@ -207,6 +223,15 @@ fn detect_root_node(conn: &Connection, root: &str) -> Result { fn query_all_edges(conn: &Connection) -> Result, IdxError> { let mut out = Vec::new(); + if let Ok(release_sha) = conn + .query_row( + "SELECT sha256 + FROM ownership_releases + ORDER BY as_of_date DESC, imported_at DESC + LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) { let mut stmt = conn .prepare( @@ -215,12 +240,13 @@ fn query_all_edges(conn: &Connection) -> Result, IdxError> { k.percentage_bps FROM ksei_holdings k JOIN tickers t ON t.id = k.ticker_id - WHERE k.entity_id IS NOT NULL", + WHERE k.entity_id IS NOT NULL + AND k.release_sha256 = ?1", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; let rows = stmt - .query_map([], |row| { + .query_map(params![release_sha], |row| { Ok(GraphEdge { from: row.get(0)?, to: row.get(1)?, @@ -238,12 +264,21 @@ fn query_all_edges(conn: &Connection) -> Result, IdxError> { { let mut stmt = conn .prepare( - "SELECT 'entity:' || b.entity_id, + "WITH latest_bing AS ( + SELECT ticker_id, MAX(report_date) AS report_date + FROM bing_holdings + GROUP BY ticker_id + ) + SELECT 'entity:' || b.entity_id, 'ticker:' || t.code, COALESCE(b.pct_ownership_bps, 0) FROM bing_holdings b + JOIN latest_bing lb + ON lb.ticker_id = b.ticker_id + AND lb.report_date = b.report_date JOIN tickers t ON t.id = b.ticker_id - WHERE b.entity_id IS NOT NULL", + WHERE b.entity_id IS NOT NULL + AND b.signal = 'holder'", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -330,3 +365,100 @@ fn source_label(source: OwnershipSource) -> &'static str { fn escape_dot(value: &str) -> String { value.replace('"', "\\\"") } + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use rusqlite::Connection; + + use crate::ownership::db::{ensure_schema, write_ksei_release, upsert_ticker}; + use crate::ownership::types::{KseiHolding, OwnershipRelease}; + + use super::query_ownership_graph; + + fn setup() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + ensure_schema(&conn).unwrap(); + conn + } + + #[test] + fn graph_uses_latest_ksei_release_only() { + let conn = setup(); + let aa = upsert_ticker(&conn, "AA", None).unwrap(); + + conn.execute( + "INSERT INTO entities (canonical_name, entity_type, country, created_at, updated_at) + VALUES ('OLDER HOLDER', NULL, NULL, 0, 0)", + [], + ) + .unwrap(); + let older_entity = conn.last_insert_rowid(); + + conn.execute( + "INSERT INTO entities (canonical_name, entity_type, country, created_at, updated_at) + VALUES ('LATEST HOLDER', NULL, NULL, 0, 0)", + [], + ) + .unwrap(); + let latest_entity = conn.last_insert_rowid(); + + let old_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "old-graph".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 1, 30).unwrap(), + row_count: 1, + imported_at: 1, + }; + let old_rows = vec![KseiHolding { + id: 0, + ticker_id: aa, + entity_id: Some(older_entity), + raw_investor_name: "OLDER HOLDER".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 1000, + report_date: old_release.as_of_date, + release_sha256: old_release.sha256.clone(), + }]; + write_ksei_release(&conn, &old_release, &old_rows, false).unwrap(); + + let latest_release = OwnershipRelease { + id: 0, + source_url: None, + sha256: "latest-graph".to_string(), + as_of_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), + row_count: 1, + imported_at: 2, + }; + let latest_rows = vec![KseiHolding { + id: 0, + ticker_id: aa, + entity_id: Some(latest_entity), + raw_investor_name: "LATEST HOLDER".to_string(), + investor_type: None, + locality: None, + nationality: None, + domicile: None, + holdings_scripless: 1, + holdings_scrip: 0, + total_shares: 1, + percentage_bps: 1200, + report_date: latest_release.as_of_date, + release_sha256: latest_release.sha256.clone(), + }]; + write_ksei_release(&conn, &latest_release, &latest_rows, false).unwrap(); + + let (nodes, edges) = query_ownership_graph(&conn, "AA", 1).unwrap(); + assert!(nodes.iter().any(|node| node.label == "LATEST HOLDER")); + assert!(!nodes.iter().any(|node| node.label == "OLDER HOLDER")); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].percentage_bps, 1200); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index bd0e5d9..3639acb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -9,19 +9,92 @@ use std::thread; use assert_cmd::Command; use predicates::prelude::*; use rusqlite::Connection; +use serde_json::Value; use sha2::{Digest, Sha256}; use zip::write::SimpleFileOptions; fn bin() -> Command { - let current = std::env::current_exe().expect("current test executable path"); - let debug_dir = current - .parent() - .and_then(|path| path.parent()) - .expect("target debug directory"); - let exe = debug_dir.join(format!("idx{}", std::env::consts::EXE_SUFFIX)); + let exe = resolve_idx_binary_path(); Command::new(exe) } +fn resolve_idx_binary_path() -> PathBuf { + if let Some(path) = std::env::var_os("CARGO_BIN_EXE_idx").map(PathBuf::from) + && candidate_is_idx_binary(&path) + { + return path; + } + + let target_debug = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join(format!("idx{}", std::env::consts::EXE_SUFFIX)); + if candidate_is_idx_binary(&target_debug) { + return target_debug; + } + + let current = std::env::current_exe().expect("current test executable path"); + let deps_dir = current.parent().expect("deps directory"); + let exe_suffix = std::env::consts::EXE_SUFFIX; + let mut candidates: Vec = fs::read_dir(deps_dir) + .expect("read deps dir") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| is_executable(path)) + .filter(|path| { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + name.starts_with("idx-") + && !name.ends_with(".d") + && (exe_suffix.is_empty() || name.ends_with(exe_suffix)) + }) + .collect(); + + candidates.sort_by_key(|path| { + fs::metadata(path) + .and_then(|meta| meta.modified()) + .expect("modified time") + }); + candidates.reverse(); + + for candidate in candidates { + if candidate_is_idx_binary(&candidate) { + return candidate; + } + } + + panic!("unable to resolve idx app binary in {}", deps_dir.display()); +} + +fn candidate_is_idx_binary(path: &Path) -> bool { + if !path.is_file() || !is_executable(path) { + return false; + } + + let Ok(output) = std::process::Command::new(path).arg("version").output() else { + return false; + }; + + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == env!("CARGO_PKG_VERSION") +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + fs::metadata(path) + .map(|meta| meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("exe")) + .unwrap_or(true) +} + fn test_env_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); @@ -36,6 +109,7 @@ 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")); cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_CACHE_HOME", &cache_home); cmd @@ -46,6 +120,13 @@ fn test_bin(name: &str) -> Command { bin_with_root(&root) } +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + fn spawn_single_response_server(content_type: &str, body: impl Into>) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); let addr = listener.local_addr().expect("local addr"); @@ -295,6 +376,43 @@ fn help_works() { test_bin("help").arg("--help").assert().success(); } +#[test] +fn ownership_help_mentions_sync_as_preferred_bootstrap() { + test_bin("ownership-help") + .args(["ownership", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Preferred bootstrap path")) + .stdout(predicate::str::contains("idx ownership sync")) + .stdout(predicate::str::contains("idx ownership discover")); +} + +#[test] +fn ownership_import_help_mentions_sync_preference_and_fallback_files() { + test_bin("ownership-import-help") + .args(["ownership", "import", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Prefer `idx ownership sync`")) + .stdout(predicate::str::contains( + "PDF (primary), ZIP/TXT archive (fallback)", + )) + .stdout(predicate::str::contains( + "idx ownership import --file /path/to/BalanceposEfek20260227.zip", + )); +} + +#[test] +fn ownership_sync_help_mentions_manifest_lookup_order() { + test_bin("ownership-sync-help") + .args(["ownership", "sync", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Manifest lookup order")) + .stdout(predicate::str::contains("IDX_OWNERSHIP_SNAPSHOT_MANIFEST")) + .stdout(predicate::str::contains("ownership.snapshot_manifest")); +} + #[test] fn version_prints_cargo_version() { test_bin("version") @@ -349,7 +467,10 @@ fn technical_with_mock_provider_table_contains_expected_rows() { .success() .stdout(predicate::str::contains("Technical Analysis for")) .stdout(predicate::str::contains("RSI (14)")) - .stdout(predicate::str::contains("Overall Signal")); + .stdout(predicate::str::contains("Overall Signal")) + .stdout(predicate::str::contains( + "Trend unavailable (need at least 200 daily candles)", + )); } #[test] @@ -464,6 +585,43 @@ fn msn_financials_with_mock_fixture_table_contains_sections() { .stdout(predicate::str::contains("Cash Flow")); } +#[test] +fn msn_financials_with_statement_filter_table_only_shows_requested_section() { + test_bin("msn-financials-statement-table") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "financials", "BBCA", "--statement", "cashflow"]) + .assert() + .success() + .stdout(predicate::str::contains("Cash Flow")) + .stdout(predicate::str::contains("Operating Cash Flow")) + .stdout(predicate::str::contains("Income Statement").not()) + .stdout(predicate::str::contains("Balance Sheet").not()); +} + +#[test] +fn msn_financials_with_statement_filter_json_keeps_context_and_nulls_filtered_sections() { + test_bin("msn-financials-statement-json") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args([ + "-o", + "json", + "stocks", + "financials", + "BBCA", + "--statement", + "income,balance", + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"instrument\"")) + .stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\"")) + .stdout(predicate::str::contains("\"income_statement\"")) + .stdout(predicate::str::contains("\"balance_sheet\"")) + .stdout(predicate::str::contains("\"cash_flow\": null")); +} + #[test] fn msn_earnings_with_mock_fixture_table_is_sectioned_and_formatted() { test_bin("msn-earnings-table") @@ -480,6 +638,21 @@ fn msn_earnings_with_mock_fixture_table_is_sectioned_and_formatted() { .stdout(predicate::str::contains("2026-03-15")); } +#[test] +fn msn_earnings_with_filters_table_limits_scope_and_period() { + test_bin("msn-earnings-filter-table") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "earnings", "BBCA", "--history", "--quarterly"]) + .assert() + .success() + .stdout(predicate::str::contains("Earnings History")) + .stdout(predicate::str::contains("Q4 2025")) + .stdout(predicate::str::contains("FY2025").not()) + .stdout(predicate::str::contains("Earnings Forecast").not()) + .stdout(predicate::str::contains("Q1 2026").not()); +} + #[test] fn msn_earnings_with_mock_fixture_json_contains_forecast_and_history() { test_bin("msn-earnings-json") @@ -488,11 +661,35 @@ fn msn_earnings_with_mock_fixture_json_contains_forecast_and_history() { .args(["-o", "json", "stocks", "earnings", "BBCA"]) .assert() .success() + .stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\"")) .stdout(predicate::str::contains("\"forecast\"")) .stdout(predicate::str::contains("\"history\"")) .stdout(predicate::str::contains("Q12026")); } +#[test] +fn msn_earnings_with_filters_json_limits_rows() { + test_bin("msn-earnings-filter-json") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args([ + "-o", + "json", + "stocks", + "earnings", + "BBCA", + "--forecast", + "--annual", + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\"")) + .stdout(predicate::str::contains("\"period_type\": \"2026\"")) + .stdout(predicate::str::contains("\"history\": []")) + .stdout(predicate::str::contains("Q12026").not()) + .stdout(predicate::str::contains("Q42025").not()); +} + #[test] fn msn_sentiment_with_mock_fixture_table_contains_ranges() { test_bin("msn-sentiment-table") @@ -550,6 +747,7 @@ fn msn_insights_with_mock_fixture_json_contains_summary_and_last_updated() { .args(["-o", "json", "stocks", "insights", "BBCA"]) .assert() .success() + .stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\"")) .stdout(predicate::str::contains( "\"summary\": \"Mixed analyst signals", )) @@ -580,6 +778,7 @@ fn msn_news_with_mock_fixture_json_contains_provider_and_timestamp() { .assert() .success() .stdout(predicate::str::contains("\"id\": \"news-1\"")) + .stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\"")) .stdout(predicate::str::contains( "\"title\": \"BCA reports steady growth\"", )) @@ -853,6 +1052,24 @@ fn ownership_import_fetch_bing_reports_unsupported() { )); } +#[test] +fn ownership_releases_uses_xdg_data_home_default_db_path() { + let root = test_env_dir("ownership-xdg-data-home"); + let data_home = root.join("xdg-data"); + + bin_with_root(&root) + .env("XDG_DATA_HOME", &data_home) + .args(["ownership", "releases"]) + .assert() + .success() + .stdout(predicate::str::contains("No ownership releases imported yet.")); + + assert!( + data_home.join("idx").join("ownership.db").exists(), + "ownership db should be created under XDG_DATA_HOME when no custom path is configured" + ); +} + #[test] fn ownership_discover_lists_fixture_candidates() { let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json") @@ -1111,6 +1328,46 @@ fn ownership_import_url_supported_pdf_succeeds_with_fake_mutool() { .stdout(predicate::str::contains(&pdf_url)); } +#[test] +fn ownership_import_url_caches_download_under_xdg_cache_home() { + let root = test_env_dir("ownership-import-xdg-cache"); + let db_path = root.join("ownership.db"); + let cache_home = root.join("xdg-cache"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("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"); + + bin_with_root(&root) + .args([ + "config", + "set", + "ownership.db_path", + db_path.to_str().unwrap(), + ]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .env("XDG_CACHE_HOME", &cache_home) + .args(["ownership", "import", "--url", &pdf_url]) + .assert() + .success(); + + assert!( + cache_home + .join("idx") + .join("ownership") + .join("raw") + .join("cached-raw.pdf") + .exists(), + "downloaded remote PDF should be cached under XDG_CACHE_HOME" + ); +} + #[test] fn ownership_import_url_duplicate_release_is_skipped() { let root = test_env_dir("ownership-import-duplicate-release"); @@ -1148,6 +1405,55 @@ fn ownership_import_url_duplicate_release_is_skipped() { .stdout(predicate::str::contains("Release already imported")); } +#[test] +fn ownership_import_force_reimports_existing_release() { + let root = test_env_dir("ownership-import-force"); + let db_path = root.join("ownership.db"); + let pdf_path = root.join("force.pdf"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("fixtures/ksei_above1_stext_excerpt.xml"), + ); + fs::write(&pdf_path, fake_pdf_bytes()).expect("write local pdf fixture"); + + bin_with_root(&root) + .args([ + "config", + "set", + "ownership.db_path", + db_path.to_str().unwrap(), + ]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--file", pdf_path.to_str().unwrap()]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args([ + "ownership", + "import", + "--force", + "--file", + pdf_path.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Imported 1 rows for 1 tickers")); + + let output = bin_with_root(&root) + .args(["-o", "json", "ownership", "releases"]) + .output() + .expect("ownership releases json output"); + assert!(output.status.success()); + let releases: Value = serde_json::from_slice(&output.stdout).expect("parse releases json"); + assert_eq!(releases.as_array().unwrap().len(), 1); +} + #[test] fn ownership_import_url_rejects_legacy_above5_pdf_schema() { let root = test_env_dir("ownership-import-above5-unsupported"); @@ -1277,6 +1583,26 @@ fn ownership_import_file_zip_archive_supports_releases_ticker_and_changes() { .stdout(predicate::str::contains("KSEI AGGREGATE LOCAL CP")) .stdout(predicate::str::contains("KSEI AGGREGATE FOREIGN MF")); + let ticker_output = bin_with_root(&root) + .args(["-o", "json", "ownership", "ticker", "AADI", "--source", "ksei"]) + .output() + .expect("ownership ticker json output"); + assert!(ticker_output.status.success()); + let ticker: Value = + serde_json::from_slice(&ticker_output.stdout).expect("parse ownership ticker json"); + let holders = ticker["holders"].as_array().expect("holders array"); + assert_eq!(ticker["ksei_as_of"].as_str(), Some("2026-02-27")); + assert_eq!(holders.len(), 18); + assert_eq!(ticker["concentration"]["top1_bps"].as_i64(), Some(6467)); + assert!(holders.iter().any(|row| { + row["name"].as_str() == Some("KSEI AGGREGATE LOCAL CP") + && row["percentage_bps"].as_i64() == Some(6467) + })); + assert!(!holders.iter().any(|row| { + row["name"].as_str() == Some("KSEI AGGREGATE LOCAL CP") + && row["percentage_bps"].as_i64() == Some(6481) + })); + bin_with_root(&root) .args([ "ownership", @@ -1433,6 +1759,31 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() { .stderr(predicate::str::contains("warning: network failed")); } +#[test] +fn quiet_suppresses_non_essential_history_messages() { + let root = test_env_dir("quiet-history-messages"); + let cache_home = root.join("cache"); + + bin_with_root(&root) + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "technical", "BBCA"]) + .assert() + .success(); + + bin_with_root(&root) + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .env("IDX_MOCK_ERROR", "1") + .args(["--quiet", "stocks", "technical", "BBCA"]) + .assert() + .success() + .stderr(predicate::str::contains("info:").not()) + .stderr(predicate::str::contains("warning:").not()); +} + #[test] fn cache_namespace_isolated_by_provider() { let root = test_env_dir("provider-cache"); @@ -1502,6 +1853,29 @@ fn invalid_provider_env_honors_json_output() { .stderr(predicate::str::contains("invalid provider")); } +#[test] +fn invalid_quote_ttl_env_returns_non_zero() { + test_bin("invalid-quote-ttl") + .env("IDX_CACHE_QUOTE_TTL", "bogus") + .args(["version"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid IDX_CACHE_QUOTE_TTL value")); +} + +#[test] +fn invalid_fundamental_ttl_env_honors_json_output() { + test_bin("invalid-fundamental-ttl-json") + .env("IDX_CACHE_FUNDAMENTAL_TTL", "bogus") + .args(["-o", "json", "version"]) + .assert() + .failure() + .stderr(predicate::str::contains("\"error\": true")) + .stderr(predicate::str::contains( + "invalid IDX_CACHE_FUNDAMENTAL_TTL value", + )); +} + #[test] fn offline_and_no_cache_flags_are_rejected() { test_bin("offline-no-cache") @@ -1585,3 +1959,92 @@ fn msn_screen_rejects_invalid_region_in_json_mode() { .stderr(predicate::str::contains("\"error\": true")) .stderr(predicate::str::contains("invalid screener region")); } + +#[test] +fn verbose_history_surfaces_yahoo_dropped_row_diagnostics() { + let history_fixture = fixture_path("chart_bbca_with_gap.json"); + + test_bin("verbose-history-diagnostics") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_MOCK_YAHOO_HISTORY_FIXTURE", &history_fixture) + .args(["-v", "stocks", "history", "BBCA", "--period", "3mo"]) + .assert() + .success() + .stderr(predicate::str::contains("dropped 1 OHLC row(s)")); +} + +#[test] +fn yahoo_provider_rejects_msn_only_stock_commands() { + let cases = [ + ( + "yahoo-profile-unsupported", + vec!["stocks", "profile", "BBCA"], + ), + ( + "yahoo-financials-unsupported", + vec!["stocks", "financials", "BBCA"], + ), + ( + "yahoo-earnings-unsupported", + vec!["stocks", "earnings", "BBCA"], + ), + ( + "yahoo-sentiment-unsupported", + vec!["stocks", "sentiment", "BBCA"], + ), + ( + "yahoo-insights-unsupported", + vec!["stocks", "insights", "BBCA"], + ), + ("yahoo-news-unsupported", vec!["stocks", "news", "BBCA"]), + ("yahoo-screen-unsupported", vec!["stocks", "screen"]), + ]; + + for (name, args) in cases { + test_bin(name) + .env("IDX_PROVIDER", "yahoo") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(args) + .assert() + .failure() + .stderr(predicate::str::contains("command requires --provider msn")); + } +} + +#[test] +fn industry_only_msn_mock_fundamentals_are_rejected_for_analysis_commands() { + let fixture = fixture_path("msn_keyratios_industry_only.json"); + let fixture_str = fixture + .to_str() + .expect("fixture path should be valid unicode") + .to_string(); + let cases = [ + ("growth-industry-only", vec!["stocks", "growth", "BBCA"]), + ( + "valuation-industry-only", + vec!["stocks", "valuation", "BBCA"], + ), + ("risk-industry-only", vec!["stocks", "risk", "BBCA"]), + ( + "fundamental-industry-only", + vec!["stocks", "fundamental", "BBCA"], + ), + ( + "compare-industry-only", + vec!["stocks", "compare", "BBCA,BBRI"], + ), + ]; + + for (name, args) in cases { + test_bin(name) + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_MOCK_MSN_KEYRATIOS_FIXTURE", &fixture_str) + .args(args) + .assert() + .failure() + .stderr(predicate::str::contains( + "company fundamentals unavailable from MSN; industry fallback is disabled", + )); + } +} diff --git a/tests/fixtures/chart_bbca_with_gap.json b/tests/fixtures/chart_bbca_with_gap.json new file mode 100644 index 0000000..a099d93 --- /dev/null +++ b/tests/fixtures/chart_bbca_with_gap.json @@ -0,0 +1,17 @@ +{ + "chart": { + "result": [{ + "timestamp": [1709251200, 1709337600], + "indicators": { + "quote": [{ + "open": [9800.0, 9850.0], + "high": [9900.0, 9900.0], + "low": [9750.0, 9800.0], + "close": [9875.0, null], + "volume": [12300000, 11000000] + }] + } + }], + "error": null + } +} diff --git a/tests/fixtures/msn_keyratios_bbca.json b/tests/fixtures/msn_keyratios_bbca.json index 84ce690..6e6ea1a 100644 --- a/tests/fixtures/msn_keyratios_bbca.json +++ b/tests/fixtures/msn_keyratios_bbca.json @@ -31,6 +31,34 @@ "priceToEarningsRatio": 24.1, "priceToBookRatio": 4.2 } + ], + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "FY", + "revenueGrowthRate": 0.081, + "earningsGrowthRate": 0.121, + "netMargin": 0.324, + "roe": 0.198, + "returnOnAssetCurrent": 0.031, + "debtToEquityRatio": 0.42, + "currentRatio": 1.18, + "priceToEarningsRatio": 25.4, + "priceToBookRatio": 4.6 + }, + { + "year": "2024", + "fiscalPeriodType": "FY", + "revenueGrowthRate": 0.073, + "earningsGrowthRate": 0.102, + "netMargin": 0.311, + "roe": 0.187, + "returnOnAssetCurrent": 0.029, + "debtToEquityRatio": 0.45, + "currentRatio": 1.14, + "priceToEarningsRatio": 24.1, + "priceToBookRatio": 4.2 + } ] } ] diff --git a/tests/fixtures/msn_keyratios_industry_only.json b/tests/fixtures/msn_keyratios_industry_only.json new file mode 100644 index 0000000..6c540ec --- /dev/null +++ b/tests/fixtures/msn_keyratios_industry_only.json @@ -0,0 +1,20 @@ +[ + { + "industryMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "priceToEarningsRatio": 12.5, + "forwardPriceToEPS": 11.8, + "priceToBookRatio": 1.7, + "roe": 18.4, + "profitMargin": 22.1, + "revenueGrowthRate": 9.4, + "earningsGrowthRate": 8.1, + "debtToEquityRatio": 0.4, + "currentRatio": 1.3 + } + ], + "companyMetrics": [] + } +]