chore: prepare v0.2.1 release

This commit is contained in:
Rasyidan Akbar F. 2026-04-06 17:47:20 +07:00
commit c94c826307
32 changed files with 2483 additions and 382 deletions

View file

@ -30,3 +30,29 @@ jobs:
- name: Test - name: Test
run: cargo 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

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
node_modules/ node_modules/
target/ target/
.direnv/ .direnv/
.claude/
result result
tmp/ tmp/

2
Cargo.lock generated
View file

@ -711,7 +711,7 @@ dependencies = [
[[package]] [[package]]
name = "idx-cli" name = "idx-cli"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"assert_cmd", "assert_cmd",
"chrono", "chrono",

View file

@ -1,13 +1,23 @@
[package] [package]
name = "idx-cli" name = "idx-cli"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
rust-version = "1.85"
description = "CLI tool for Indonesian stock market (IDX) analysis" description = "CLI tool for Indonesian stock market (IDX) analysis"
license = "MIT" license = "MIT"
repository = "https://github.com/0xrsydn/idx-cli" repository = "https://github.com/0xrsydn/idx-cli"
homepage = "https://github.com/0xrsydn/idx-cli" homepage = "https://github.com/0xrsydn/idx-cli"
keywords = ["idx", "stocks", "indonesia", "cli", "finance"] keywords = ["idx", "stocks", "indonesia", "cli", "finance"]
categories = ["command-line-utilities", "finance"] categories = ["command-line-utilities", "finance"]
include = [
"/Cargo.toml",
"/Cargo.lock",
"/LICENSE",
"/README.md",
"/src/**",
"/tests/cli.rs",
"/tests/fixtures/**",
]
[[bin]] [[bin]]
name = "idx" name = "idx"

View file

@ -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 | | Fundamental | `idx stocks fundamental` | Implemented | Shipped and exercised |
| Compare | `idx stocks compare` | 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 | | 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 | | Financial statements | `idx stocks financials` | Implemented | Output cleanup landed, and statement filters now support `--statement income|balance|cashflow` |
| Earnings | `idx stocks earnings` | Implemented with gaps | History/forecast split is rendered; filter flags are still missing | | 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 | | 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 | | 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 | | News | `idx stocks news` | Implemented | Fixture-backed CLI coverage exists |
@ -153,11 +153,11 @@ Done when:
### P1 - UX and output contract cleanup ### P1 - UX and output contract cleanup
Tasks: Completed on `2026-04-02`:
- Add `financials` filters such as `--statement income|balance|cashflow`. - `financials` now supports `--statement income|balance|cashflow`.
- Add `earnings` filters such as `--forecast|--history` and `--annual|--quarterly`. - `earnings` now supports `--forecast|--history` and `--annual|--quarterly`.
- Review JSON payload consistency where symbol or context fields are still sparse. - JSON payload context is less sparse for MSN `earnings`, `insights`, and `news`; each now carries the resolved symbol.
- Decide whether `screen` stays under `stocks` long term or graduates into a richer dedicated surface later. - `screen` stays under `stocks` for now; revisit a dedicated surface only if `screen query` / `screen presets` grow into a richer workflow.
Done when: Done when:
- Existing shipped commands are easier to drive without changing product scope. - Existing shipped commands are easier to drive without changing product scope.

View file

@ -4,14 +4,30 @@ CLI tool for Indonesian stock market (IDX) analysis, built in Rust for humans an
## Installation ## Installation
### Cargo
```bash ```bash
cargo install idx-cli 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 ```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 ## Quick start
```bash ```bash
@ -54,7 +70,6 @@ Precedence order:
- Use `idx --help` and subcommand help for discoverability - Use `idx --help` and subcommand help for discoverability
- Use `-o json` / `--output json` for structured output - Use `-o json` / `--output json` for structured output
- See `skills/` for agent workflows and task recipes
## Development ## Development

57
TODO.md
View file

@ -114,6 +114,42 @@
- [x] Batch 4 verification: `cargo test` - [x] Batch 4 verification: `cargo test`
- [x] Batch 4 verification: fallback ingest produces a compatible SQLite state for `ownership releases`, `ticker`, and `changes` - [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) ## 📋 Backlog (per SPEC.md)
- [ ] `market summary` — IHSG index, market breadth - [ ] `market summary` — IHSG index, market breadth
- [ ] `market movers` — top gainers/losers/volume - [ ] `market movers` — top gainers/losers/volume
@ -126,7 +162,21 @@
- [ ] CSV/TSV output formats - [ ] CSV/TSV output formats
- [ ] Additional providers (Alpha Vantage, Twelve Data, IDX official) - [ ] 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] 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] Yahoo routing verified for live `quote` and `history`
- [x] `stocks history --history-provider msn` correctly fails for IDX as unsupported - [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] 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 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 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] 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`) - [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 - [ ] `ownership import --fetch-bing` is still deferred and returns unsupported

View file

@ -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 live-table --group live-json
scripts/live-smoke.sh --group cache --symbol BBRI scripts/live-smoke.sh --group cache --symbol BBRI
scripts/live-smoke.sh --dry-run --mode full scripts/live-smoke.sh --dry-run --mode full
scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock
``` ```
## Modes ## 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. - 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. - 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 <path> --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 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. - 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`. - When a case fails, inspect the per-case log in `tmp/live-smoke/.../logs/` before updating `TODO.md` or `FEATURE_SPEC.md`.

View file

@ -15,22 +15,56 @@
let let
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs { inherit system overlays; };
lib = pkgs.lib;
cargoManifest = builtins.fromTOML (builtins.readFile ./Cargo.toml);
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" ]; 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 in
{ {
packages.default = idxPackage;
apps.default = {
type = "app";
program = "${idxPackage}/bin/idx";
};
checks.default = idxPackage;
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ inputsFrom = [ idxPackage ];
packages = with pkgs; [
rustToolchain rustToolchain
pkg-config
openssl
cargo-watch cargo-watch
cargo-nextest cargo-nextest
prek prek
curl-impersonate # required for Yahoo Finance auth (curl_chrome* binaries for TLS fingerprinting) ] ++ runtimeDeps;
mupdf # mutool for KSEI PDF parsing (ownership module)
];
shellHook = '' shellHook = ''
export PATH="$PWD/target/debug:$PATH" export PATH="$PWD/target/debug:$PATH"

View file

@ -350,6 +350,7 @@ prepare_paths() {
build_binary() { build_binary() {
local build_log local build_log
local stale_input
if [[ -z "$BIN_PATH" ]]; then if [[ -z "$BIN_PATH" ]]; then
BIN_PATH="$ROOT_DIR/target/debug/idx" BIN_PATH="$ROOT_DIR/target/debug/idx"
@ -371,6 +372,27 @@ build_binary() {
fi fi
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 if (( BUILD )); then
build_log="$LOG_DIR/build.log" build_log="$LOG_DIR/build.log"
( (

View file

@ -62,6 +62,75 @@ pub trait NewsProvider {
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError>; fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError>;
} }
#[allow(dead_code)]
pub trait ScreenerProvider {
fn screener(&self, filter: &str, region: &str, limit: usize) -> Result<Vec<Quote>, IdxError>;
}
pub struct SelectedProvider {
kind: ProviderKind,
market: Box<dyn MarketDataProvider>,
profile: Option<Box<dyn ProfileProvider>>,
financials: Option<Box<dyn FinancialsProvider>>,
earnings: Option<Box<dyn EarningsProvider>>,
sentiment: Option<Box<dyn SentimentProvider>>,
insights: Option<Box<dyn InsightsProvider>>,
news: Option<Box<dyn NewsProvider>>,
screener: Option<Box<dyn ScreenerProvider>>,
}
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<String, IdxError> { pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result<String, IdxError> {
let trimmed = symbol.trim().to_uppercase(); let trimmed = symbol.trim().to_uppercase();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -78,17 +147,71 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result<String, IdxError>
Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase())) Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase()))
} }
pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box<dyn MarketDataProvider> { pub fn default_provider(provider: ProviderKind, verbose: bool) -> SelectedProvider {
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { build_selected_provider(
Box::new(MockProvider::from_fixtures(provider)) provider,
} else { verbose,
match provider { std::env::var("IDX_USE_MOCK_PROVIDER").is_ok(),
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)), )
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)), }
}
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 /// Resolves a history provider based on the selected market data provider and
/// history provider strategy. /// history provider strategy.
/// ///
@ -115,7 +238,12 @@ pub fn history_provider(
.into(), .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 { match resolved {
@ -135,21 +263,30 @@ pub struct MockProvider {
impl MockProvider { impl MockProvider {
pub fn from_fixtures(provider: ProviderKind) -> Self { 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() { if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Self::with_error(IdxError::ProviderUnavailable); return Self::with_error(IdxError::ProviderUnavailable);
} }
match provider { match provider {
ProviderKind::Yahoo => Self::from_yahoo_fixtures(), ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose),
ProviderKind::Msn => Self::from_msn_fixtures(), 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") let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json")
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json") let history_path = std::env::var("IDX_MOCK_YAHOO_HISTORY_FIXTURE")
.unwrap_or_else(|_| "{}".to_string()); .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") let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
@ -157,7 +294,8 @@ impl MockProvider {
.map_err(|e| IdxError::ParseError(e.to_string())); .map_err(|e| IdxError::ParseError(e.to_string()));
let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
.map_err(|e| IdxError::ParseError(e.to_string())); .map_err(|e| IdxError::ParseError(e.to_string()));
let history = yahoo::parse_history_from_str("BBCA.JK", &history_raw) let history =
yahoo::parse_history_from_str_with_verbose("BBCA.JK", &history_raw, history_verbose)
.map_err(|e| IdxError::ParseError(e.to_string())); .map_err(|e| IdxError::ParseError(e.to_string()));
Self { Self {
@ -170,8 +308,10 @@ impl MockProvider {
fn from_msn_fixtures() -> Self { fn from_msn_fixtures() -> Self {
let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json")
.unwrap_or_else(|_| "[]".to_string()); .unwrap_or_else(|_| "[]".to_string());
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_bbca.json") let fundamentals_path = std::env::var("IDX_MOCK_MSN_KEYRATIOS_FIXTURE")
.unwrap_or_else(|_| "[]".to_string()); .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", &quote_raw) let quote = msn::parse_quote_from_str("BBCA.JK", &quote_raw)
.map_err(|e| IdxError::ParseError(e.to_string())); .map_err(|e| IdxError::ParseError(e.to_string()));
@ -225,7 +365,8 @@ impl HistoryProvider for MockProvider {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::resolve_symbol; use super::{ProviderKind, build_selected_provider, resolve_symbol};
use crate::error::IdxError;
#[test] #[test]
fn resolves_symbol_variants() { fn resolves_symbol_variants() {
@ -241,4 +382,60 @@ mod tests {
// Valid ticker returns Ok // Valid ticker returns Ok
assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK"); 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"
);
}
} }

View file

@ -65,13 +65,11 @@ pub(super) fn parse_fundamentals(
quote: Option<&MsnQuote>, quote: Option<&MsnQuote>,
) -> Result<Fundamentals, IdxError> { ) -> Result<Fundamentals, IdxError> {
let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?; let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?;
let metrics = if ratios.company_metrics.is_empty() { let metrics = &ratios.company_metrics;
&ratios.industry_metrics if metrics.is_empty() || !metrics.iter().any(metric_has_supported_values) {
} else { return Err(IdxError::Unsupported(
&ratios.company_metrics "company fundamentals unavailable from MSN; industry fallback is disabled".into(),
}; ));
if preferred_metric(metrics).is_none() {
return Err(IdxError::ProviderUnavailable);
} }
Ok(Fundamentals { Ok(Fundamentals {
@ -105,8 +103,31 @@ pub(super) fn parse_fundamentals(
}) })
} }
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> { fn metric_has_supported_values(metric: &IndustryMetric) -> bool {
metrics.iter().max_by_key(|metric| metric_rank(metric)) [
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<T: Copy>( fn best_metric_value<T: Copy>(
@ -398,7 +419,8 @@ pub(super) fn parse_financial_statements(
.and_then(|v| v.instrument_id.clone()) .and_then(|v| v.instrument_id.clone())
.unwrap_or_default(), .unwrap_or_default(),
symbol: instrument 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()), .unwrap_or_else(|| symbol.to_string()),
name: instrument name: instrument
.and_then(|v| v.display_name.clone().or_else(|| v.short_name.clone())) .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( pub(super) fn parse_earnings(
_symbol: &str, symbol: &str,
raw: &RawEarningsResponse, raw: &RawEarningsResponse,
) -> Result<EarningsReport, IdxError> { ) -> Result<EarningsReport, IdxError> {
let mut forecast = Vec::new(); 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()); history.sort_by_key(|row| row.earning_release_date.clone().unwrap_or_default());
Ok(EarningsReport { Ok(EarningsReport {
symbol: symbol.to_string(),
eps_last_year: raw.eps_last_year.unwrap_or_default(), eps_last_year: raw.eps_last_year.unwrap_or_default(),
revenue_last_year: raw.revenue_last_year.unwrap_or_default(), revenue_last_year: raw.revenue_last_year.unwrap_or_default(),
forecast, forecast,
@ -504,6 +527,7 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result<Insight
.instrument_id .instrument_id
.clone() .clone()
.unwrap_or_else(|| symbol.to_string()), .unwrap_or_else(|| symbol.to_string()),
symbol: symbol.to_string(),
summary: insight_overview(item.display_name.as_deref(), positive, negative, neutral), summary: insight_overview(item.display_name.as_deref(), positive, negative, neutral),
highlights, highlights,
risks, risks,
@ -518,7 +542,7 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result<Insight
}) })
} }
pub(super) fn parse_news(raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> { pub(super) fn parse_news(symbol: &str, raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> {
let source = raw let source = raw
.sub_cards .sub_cards
.as_ref() .as_ref()
@ -529,6 +553,7 @@ pub(super) fn parse_news(raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> {
.iter() .iter()
.map(|item| NewsItem { .map(|item| NewsItem {
id: item.id.clone().unwrap_or_default(), id: item.id.clone().unwrap_or_default(),
symbol: symbol.to_string(),
title: item.title.clone().unwrap_or_default(), title: item.title.clone().unwrap_or_default(),
url: item.url.clone().unwrap_or_default(), url: item.url.clone().unwrap_or_default(),
description: item.description.clone().unwrap_or_default(), description: item.description.clone().unwrap_or_default(),
@ -549,12 +574,10 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
.as_ref() .as_ref()
.ok_or_else(|| IdxError::ParseError("no screener data".into()))?; .ok_or_else(|| IdxError::ParseError("no screener data".into()))?;
// Build Quote directly from screener MsnQuote data; default price to 0 if missing
// (do not route through parse_quote which errors on missing price)
let results: Vec<Quote> = quotes let results: Vec<Quote> = quotes
.iter() .iter()
.map(|q| { .filter_map(|q| {
let raw_price = q.price.unwrap_or(0.0); let raw_price = q.price.filter(|price| price.is_finite() && *price > 0.0)?;
let price = round_price(raw_price); let price = round_price(raw_price);
let prev_close = q.price_previous_close.map(round_price); let prev_close = q.price_previous_close.map(round_price);
let change = prev_close let change = prev_close
@ -565,7 +588,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
.symbol .symbol
.as_deref() .as_deref()
.and_then(ticker_from_symbol) .and_then(ticker_from_symbol)
.unwrap_or_default(); .filter(|ticker| !ticker.is_empty())?;
let (week52_position, range_signal) = match (q.price_52w_low, q.price_52w_high) { let (week52_position, range_signal) = match (q.price_52w_low, q.price_52w_high) {
(Some(low), Some(high)) if high > low => { (Some(low), Some(high)) if high > low => {
let pos = (raw_price - low) / (high - low); let pos = (raw_price - low) / (high - low);
@ -580,7 +603,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
} }
_ => (None, None), _ => (None, None),
}; };
Quote { Some(Quote {
symbol: normalized_symbol(&ticker, &ticker), symbol: normalized_symbol(&ticker, &ticker),
price, price,
change, change,
@ -593,7 +616,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
range_signal, range_signal,
prev_close, prev_close,
avg_volume: round_u64(q.average_volume), avg_volume: round_u64(q.average_volume),
} })
}) })
.collect(); .collect();
@ -682,9 +705,11 @@ fn collect_earnings(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
RawNewsFeed, RawScreenerResponse, RawSentiment, parse_news, parse_screener_results, KeyRatios, RawFinancialStatement, RawNewsFeed, RawScreenerResponse, RawSentiment,
parse_financial_statements, parse_fundamentals, parse_news, parse_screener_results,
parse_sentiment, parse_sentiment,
}; };
use crate::error::IdxError;
#[test] #[test]
fn parses_sentiment_fixture_statistics() { fn parses_sentiment_fixture_statistics() {
@ -709,16 +734,46 @@ mod tests {
serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json")) serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json"))
.expect("news fixture should deserialize"); .expect("news fixture should deserialize");
let items = parse_news(&raw).expect("news should parse"); let items = parse_news("BBCA.JK", &raw).expect("news should parse");
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "news-1"); assert_eq!(items[0].id, "news-1");
assert_eq!(items[0].symbol, "BBCA.JK");
assert_eq!(items[0].title, "BCA reports steady growth"); assert_eq!(items[0].title, "BCA reports steady growth");
assert_eq!(items[0].provider, "Contoso News"); assert_eq!(items[0].provider, "Contoso News");
assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z"); assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z");
assert_eq!(items[0].read_time_min, Some(3)); assert_eq!(items[0].read_time_min, Some(3));
} }
#[test]
fn parse_financial_statements_normalizes_instrument_symbol() {
let raw: Vec<RawFinancialStatement> = 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] #[test]
fn parses_screener_fixture_quotes() { fn parses_screener_fixture_quotes() {
let raw: RawScreenerResponse = serde_json::from_str(include_str!( 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].range_signal.as_deref(), Some("upper"));
assert_eq!(quotes[0].avg_volume, Some(10_000_000)); 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<KeyRatios> = 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"
);
}
} }

View file

@ -12,7 +12,7 @@ use crate::api::types::{
}; };
use crate::api::{ use crate::api::{
EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider, EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider,
ProfileProvider, QuoteProvider, SentimentProvider, ProfileProvider, QuoteProvider, ScreenerProvider, SentimentProvider,
}; };
use crate::error::IdxError; use crate::error::IdxError;
@ -34,16 +34,6 @@ impl MsnProvider {
client: MsnClient::new(), client: MsnClient::new(),
} }
} }
pub fn screener(
&self,
filter: &str,
region: &str,
limit: usize,
) -> Result<Vec<Quote>, IdxError> {
let raw = self.client.fetch_screener(filter, region, limit)?;
parse_screener_results(&raw)
}
} }
impl QuoteProvider for MsnProvider { impl QuoteProvider for MsnProvider {
@ -99,6 +89,13 @@ impl InsightsProvider for MsnProvider {
impl NewsProvider for MsnProvider { impl NewsProvider for MsnProvider {
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> { fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
let raw = self.client.fetch_news(symbol, limit)?; 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<Vec<Quote>, IdxError> {
let raw = self.client.fetch_screener(filter, region, limit)?;
parse_screener_results(&raw)
} }
} }

View file

@ -35,6 +35,7 @@ pub(crate) struct MsnQuote {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct KeyRatios { pub(crate) struct KeyRatios {
#[allow(dead_code)]
#[serde(default)] #[serde(default)]
pub(crate) industry_metrics: Vec<IndustryMetric>, pub(crate) industry_metrics: Vec<IndustryMetric>,
#[serde(default)] #[serde(default)]

View file

@ -143,6 +143,8 @@ pub struct StatementSection {
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EarningsReport { pub struct EarningsReport {
#[serde(default)]
pub symbol: String,
pub eps_last_year: f64, pub eps_last_year: f64,
pub revenue_last_year: f64, pub revenue_last_year: f64,
pub forecast: Vec<EarningsData>, pub forecast: Vec<EarningsData>,
@ -183,6 +185,8 @@ pub struct SentimentPeriod {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsightData { pub struct InsightData {
pub id: String, pub id: String,
#[serde(default)]
pub symbol: String,
pub summary: String, pub summary: String,
pub highlights: Vec<String>, pub highlights: Vec<String>,
pub risks: Vec<String>, pub risks: Vec<String>,
@ -193,6 +197,8 @@ pub struct InsightData {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewsItem { pub struct NewsItem {
pub id: String, pub id: String,
#[serde(default)]
pub symbol: String,
pub title: String, pub title: String,
pub url: String, pub url: String,
pub description: String, pub description: String,

View file

@ -1,11 +1,13 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::process::Stdio;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use crate::api::types::{Interval, Period}; use crate::api::types::{Interval, Period};
use crate::curl_impersonate; use crate::curl_impersonate;
use crate::error::IdxError; use crate::error::IdxError;
use crate::runtime;
use super::raw_types::{ChartResponse, QuoteSummaryResponse}; use super::raw_types::{ChartResponse, QuoteSummaryResponse};
@ -48,7 +50,7 @@ impl YahooClient {
} }
fn cookie_jar_path() -> PathBuf { 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<String, IdxError> { pub(super) fn parse_crumb_body(raw: &str) -> Result<String, IdxError> {
@ -132,14 +134,9 @@ impl YahooClient {
// Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar). // 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. // We allow non-zero exit here since 404 still writes the cookie.
let _ = Command::new(binary) let _ = Command::new(binary)
.args([ .args(["--silent", "--cookie-jar", cookie_jar_str, COOKIE_FETCH_URL])
"--silent", .stdout(Stdio::null())
"--cookie-jar", .stderr(Stdio::null())
cookie_jar_str,
COOKIE_FETCH_URL,
"--output",
"/dev/null",
])
.output(); .output();
// Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie). // Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie).
@ -176,36 +173,60 @@ impl YahooClient {
Ok(()) Ok(())
} }
fn retry_rate_limited<T, F>(&self, request: F) -> Result<Result<T, ureq::Error>, IdxError>
where
F: FnMut() -> Result<T, ureq::Error>,
{
Self::retry_rate_limited_with(request, std::thread::sleep, jitter)
}
fn retry_rate_limited_with<T, F, S, J>(
mut request: F,
mut sleeper: S,
mut jitter_fn: J,
) -> Result<Result<T, ureq::Error>, IdxError>
where
F: FnMut() -> Result<T, ureq::Error>,
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( pub(super) fn fetch_chart(
&self, &self,
symbol: &str, symbol: &str,
period: &Period, period: &Period,
interval: &Interval, interval: &Interval,
) -> Result<ChartResponse, IdxError> { ) -> Result<ChartResponse, IdxError> {
let mut wait = Duration::from_millis(250);
for attempt in 0..3 {
let url = Self::chart_url(symbol, period, interval); let url = Self::chart_url(symbol, period, interval);
let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call(); let response = self
.retry_rate_limited(|| self.agent.get(&url).header("User-Agent", USER_AGENT).call())?;
match response { match response {
Ok(ok) => { Ok(ok) => ok
return ok
.into_body() .into_body()
.read_json::<ChartResponse>() .read_json::<ChartResponse>()
.map_err(|e| IdxError::ParseError(e.to_string())); .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(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())),
}
}
Err(IdxError::RateLimited)
} }
pub(super) fn fetch_quote_summary( pub(super) fn fetch_quote_summary(
@ -217,21 +238,21 @@ impl YahooClient {
let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) { let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) {
Ok(header) => header, Ok(header) => header,
Err(err) => { 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!( return Err(IdxError::AuthError(format!(
"failed to parse Yahoo cookies: {err}" "failed to parse Yahoo cookies: {err}"
))); )));
} }
}; };
let url = Self::quote_summary_url(symbol, &crumb); let url = Self::quote_summary_url(symbol, &crumb);
let mut wait = Duration::from_millis(250); let response = self.retry_rate_limited(|| {
for attempt in 0..3 {
let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT); let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT);
if !cookie_header.is_empty() { if !cookie_header.is_empty() {
req = req.header("Cookie", &cookie_header); req = req.header("Cookie", &cookie_header);
} }
let response = req.call(); req.call()
})?;
match response { match response {
Ok(ok) => { Ok(ok) => {
return ok return ok
@ -242,25 +263,18 @@ impl YahooClient {
Err(ureq::Error::StatusCode(401)) => { Err(ureq::Error::StatusCode(401)) => {
if auth_attempt == 0 { if auth_attempt == 0 {
self.clear_crumb()?; self.clear_crumb()?;
break; continue;
} }
return Err(IdxError::Http( return Err(IdxError::Http(
"yahoo quoteSummary returned unauthorized (401)".to_string(), "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)) => { Err(ureq::Error::StatusCode(404)) => {
return Err(IdxError::SymbolNotFound(symbol.to_string())); return Err(IdxError::SymbolNotFound(symbol.to_string()));
} }
Err(e) => return Err(IdxError::Http(e.to_string())), Err(e) => return Err(IdxError::Http(e.to_string())),
} }
} }
}
Err(IdxError::RateLimited) Err(IdxError::RateLimited)
} }
@ -273,6 +287,7 @@ fn jitter() -> Duration {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::YahooClient; use super::YahooClient;
use std::time::Duration;
#[test] #[test]
fn parses_crumb_body_trimmed() { fn parses_crumb_body_trimmed() {
@ -290,4 +305,55 @@ mod tests {
.expect_err("rate limited crumb must fail"); .expect_err("rate limited crumb must fail");
assert!(matches!(rate_limited, crate::error::IdxError::Http(_))); 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)]
);
}
} }

View file

@ -11,7 +11,9 @@ use client::YahooClient;
use map::{parse_fundamentals, parse_quote}; use map::{parse_fundamentals, parse_quote};
use parse::parse_history_with_verbose; 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 { pub struct YahooProvider {
client: YahooClient, client: YahooClient,

View file

@ -10,10 +10,19 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, Idx
parse_quote(symbol, &chart) parse_quote(symbol, &chart)
} }
#[cfg(test)]
pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, IdxError> { pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, 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<Vec<Ohlc>, IdxError> {
let chart: ChartResponse = let chart: ChartResponse =
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; 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( pub(crate) fn parse_fundamentals_from_str(
@ -32,9 +41,9 @@ pub(super) fn parse_history_with_verbose(
) -> Result<Vec<Ohlc>, IdxError> { ) -> Result<Vec<Ohlc>, IdxError> {
let (history, dropped) = parse_history(symbol, chart)?; let (history, dropped) = parse_history(symbol, chart)?;
if dropped > 0 && verbose { if dropped > 0 && verbose {
eprintln!( crate::runtime::warn(format!(
"warning: dropped {dropped} OHLC row(s) from Yahoo response due to missing fields" "dropped {dropped} OHLC row(s) from Yahoo response due to missing fields"
); ));
} }
Ok(history) Ok(history)
} }
@ -43,7 +52,8 @@ pub(super) fn parse_history_with_verbose(
mod tests { mod tests {
use super::{ use super::{
ChartResponse, parse_fundamentals_from_str, parse_history_from_str, 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#"{ const SAMPLE: &str = r#"{
@ -100,6 +110,11 @@ mod tests {
parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed"); parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed");
assert!(!history.is_empty()); 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) let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
.expect("fixture fundamentals parsed"); .expect("fixture fundamentals parsed");
assert_eq!(fundamentals.trailing_pe, Some(25.4)); assert_eq!(fundamentals.trailing_pe, Some(25.4));

View file

@ -8,6 +8,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::IdxError; use crate::error::IdxError;
use crate::runtime;
const CURRENT_SCHEMA_VERSION: u32 = 1; const CURRENT_SCHEMA_VERSION: u32 = 1;
@ -175,9 +176,9 @@ impl Cache {
let entry: CacheEntry<T> = match serde_json::from_str(&raw) { let entry: CacheEntry<T> = match serde_json::from_str(&raw) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
eprintln!( runtime::warn(format!(
"warning: corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}" "corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}"
); ));
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
return Ok(None); return Ok(None);
} }

View file

@ -2,6 +2,7 @@ use clap::{Args, Subcommand};
use crate::cache::Cache; use crate::cache::Cache;
use crate::error::IdxError; use crate::error::IdxError;
use crate::runtime;
#[derive(Debug, Args)] #[derive(Debug, Args)]
#[command(about = "Manage local cache")] #[command(about = "Manage local cache")]
@ -43,7 +44,7 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
let (removed, failed) = cache.clear()?; let (removed, failed) = cache.clear()?;
println!("cleared {removed} files"); println!("cleared {removed} files");
if !failed.is_empty() { if !failed.is_empty() {
eprintln!("warning: failed to remove {} file(s)", failed.len()); runtime::warn(format!("failed to remove {} file(s)", failed.len()));
} }
} }
} }

View file

@ -48,7 +48,7 @@ pub enum Commands {
#[command(about = "Manage local cache")] #[command(about = "Manage local cache")]
Cache(cache::CacheCmd), Cache(cache::CacheCmd),
#[cfg(feature = "ownership")] #[cfg(feature = "ownership")]
#[command(about = "Ownership intelligence (KSEI + Bing)")] #[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
Ownership(ownership::OwnershipCmd), Ownership(ownership::OwnershipCmd),
#[command(about = "Generate shell completions")] #[command(about = "Generate shell completions")]
Completions { shell: Shell }, Completions { shell: Shell },

View file

@ -5,7 +5,6 @@ use std::path::{Path, PathBuf};
use chrono::Utc; use chrono::Utc;
use clap::{Args, Subcommand}; use clap::{Args, Subcommand};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL}; use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use directories::ProjectDirs;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -15,12 +14,18 @@ use crate::error::IdxError;
use crate::output::OutputFormat; use crate::output::OutputFormat;
use crate::output::json; use crate::output::json;
use crate::output::table::format_idr; use crate::output::table::format_idr;
use crate::runtime;
use crate::ownership::types::{ use crate::ownership::types::{
ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource, ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource,
}; };
use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot}; use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot};
#[derive(Debug, Args)] #[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 <pdf-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 <pdf-url>\n idx ownership releases"
)]
pub struct OwnershipCmd { pub struct OwnershipCmd {
#[command(subcommand)] #[command(subcommand)]
pub command: OwnershipCommand, pub command: OwnershipCommand,
@ -28,11 +33,22 @@ pub struct OwnershipCmd {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum OwnershipCommand { 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), 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 <pdf-url-from-discover>\n idx ownership import --file /path/to/ksei.pdf\n idx ownership import --file /path/to/BalanceposEfek20260227.zip"
)]
Import(ImportArgs), 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), Sync(SyncArgs),
/// Show all holders for a ticker (KSEI + Bing combined). /// Show all holders for a ticker (KSEI + Bing combined).
Ticker(TickerArgs), Ticker(TickerArgs),
@ -68,26 +84,26 @@ pub struct DiscoverArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ImportArgs { pub struct ImportArgs {
/// URL to a remote ownership PDF. /// Direct URL to a remote ownership PDF, typically from `ownership discover`.
#[arg(long)] #[arg(long)]
pub url: Option<String>, pub url: Option<String>,
/// Path to local KSEI ownership PDF, ZIP, or TXT file. /// Path to a local ownership file: PDF (primary), ZIP/TXT archive (fallback).
#[arg(long)] #[arg(long)]
pub file: Option<PathBuf>, pub file: Option<PathBuf>,
/// Fetch Bing institutional data for these symbols. /// Fetch Bing institutional data for these symbols.
#[arg(long, value_delimiter = ',')] #[arg(long, value_delimiter = ',')]
pub fetch_bing: Option<Vec<String>>, pub fetch_bing: Option<Vec<String>>,
/// Re-import even if already imported. /// Re-import even if the release SHA-256 has already been seen.
#[arg(long)] #[arg(long)]
pub force: bool, pub force: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct SyncArgs { 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)] #[arg(long)]
pub manifest: Option<String>, pub manifest: Option<String>,
/// 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)] #[arg(long)]
pub force: bool, 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 flow = db::query_bing_flow(&conn, ticker_id)?;
let Some(flow) = flow else { 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::<crate::ownership::types::InstitutionalFlow>::None);
}
println!("No institutional flow data available for {symbol}.");
return Ok(()); return Ok(());
}; };
@ -759,12 +778,12 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
.collect(); .collect();
if !clean.is_empty() { if !clean.is_empty() {
eprintln!( runtime::info(format!(
"info: --fetch-bing requested for {} symbol(s), implementation deferred for Sprint 6", "--fetch-bing requested for {} symbol(s), implementation deferred for Sprint 6",
clean.len() clean.len()
); ));
for symbol in &clean { for symbol in &clean {
eprintln!(" - {symbol}"); runtime::info(format!(" - {symbol}"));
} }
return Err(IdxError::Unsupported( 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)?; let sha256 = sha256_file(&import_input.import_path)?;
if !args.force && db::release_exists(&conn, &sha256)? { 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(()); 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 let as_of_date = holdings
.iter() .iter()
.map(|h| h.report_date) .map(|h| h.report_date)
@ -844,15 +878,28 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
source_url: import_input.source_url, source_url: import_input.source_url,
sha256, sha256,
as_of_date, as_of_date,
row_count: inserted_rows, row_count: holdings.len(),
imported_at: Utc::now().timestamp(), 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!( println!(
"Imported {} rows for {} tickers (as of {}).", "Imported {} rows for {} tickers (as of {}).",
inserted_rows, result.inserted_rows,
ticker_ids.len(), result.ticker_count,
as_of_date.format("%Y-%m-%d") as_of_date.format("%Y-%m-%d")
); );
@ -916,6 +963,16 @@ struct ResolvedImportInput {
format: ImportInputFormat, format: ImportInputFormat,
} }
#[derive(Debug, Serialize)]
struct OwnershipImportResult {
action: &'static str,
inserted_rows: usize,
ticker_count: usize,
as_of_date: Option<String>,
sha256: String,
source_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImportInputFormat { enum ImportInputFormat {
Pdf, Pdf,
@ -972,9 +1029,7 @@ fn detect_local_import_format(path: &Path) -> Result<ImportInputFormat, IdxError
} }
fn cache_pdf_path(url: &str) -> Result<PathBuf, IdxError> { fn cache_pdf_path(url: &str) -> Result<PathBuf, IdxError> {
let dirs = ProjectDirs::from("", "", "idx") let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw");
.ok_or_else(|| IdxError::Io("unable to resolve cache directory".to_string()))?;
let raw_dir = dirs.cache_dir().join("ownership").join("raw");
fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?; fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?;
let mut file_name = url let mut file_name = url

View file

@ -1,6 +1,6 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use clap::{Args, Subcommand}; use clap::{Args, Subcommand, ValueEnum};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use crate::analysis::fundamental::{ use crate::analysis::fundamental::{
@ -9,17 +9,13 @@ use crate::analysis::fundamental::{
}; };
use crate::analysis::signals::{self, Signal, TechnicalSignal}; use crate::analysis::signals::{self, Signal, TechnicalSignal};
use crate::analysis::technical; use crate::analysis::technical;
use crate::api::msn::MsnProvider;
use crate::api::types::{ use crate::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Ohlc, Period, Quote, SentimentData, NewsItem, Ohlc, Period, Quote, SentimentData,
}; };
use crate::api::{ use crate::api::{MarketDataProvider, SelectedProvider, history_provider};
EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider,
ProfileProvider, SentimentProvider, history_provider,
};
use crate::cache::Cache; use crate::cache::Cache;
use crate::config::{HistoryProviderKind, IdxConfig}; use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind};
use crate::error::IdxError; use crate::error::IdxError;
use crate::output::{ use crate::output::{
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, 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_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
render_technical, render_valuation, render_technical, render_valuation,
}; };
use crate::runtime;
struct FundamentalCacheSpec { struct FundamentalCacheSpec {
bucket: String, bucket: String,
@ -53,8 +50,67 @@ const SCREENER_FILTERS: &[&str] = &[
const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"]; const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"];
fn cache_bucket(config: &IdxConfig, key: &str) -> String { fn cache_bucket(provider: ProviderKind, key: &str) -> String {
format!("{}-{key}", config.provider.as_str()) 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<FinancialStatementKind>,
}
#[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)] #[derive(Debug, Args)]
@ -132,10 +188,24 @@ pub enum StocksSubcommand {
}, },
#[command(about = "Get company profile")] #[command(about = "Get company profile")]
Profile { symbol: String }, Profile { symbol: String },
#[command(about = "Get financial statements")] #[command(
Financials { symbol: String }, about = "Get financial statements",
#[command(about = "Get earnings report")] after_help = "Examples:\n idx stocks financials BBCA\n idx stocks financials BBCA --statement income\n idx stocks financials BBCA --statement income,balance"
Earnings { symbol: String }, )]
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")] #[command(about = "Get crowd sentiment")]
Sentiment { symbol: String }, Sentiment { symbol: String },
#[command(about = "Get AI insights")] #[command(about = "Get AI insights")]
@ -168,9 +238,10 @@ pub enum StocksSubcommand {
pub fn handle( pub fn handle(
cmd: &StocksCmd, cmd: &StocksCmd,
config: &IdxConfig, config: &IdxConfig,
provider: &dyn MarketDataProvider, provider: &SelectedProvider,
offline: bool, offline: bool,
no_cache: bool, no_cache: bool,
verbose: bool,
) -> Result<(), IdxError> { ) -> Result<(), IdxError> {
if offline && no_cache { if offline && no_cache {
return Err(IdxError::InvalidInput( return Err(IdxError::InvalidInput(
@ -182,7 +253,7 @@ pub fn handle(
match &cmd.command { match &cmd.command {
StocksSubcommand::Quote { symbols } => { StocksSubcommand::Quote { symbols } => {
let quote_bucket = cache_bucket(config, "quote"); let quote_bucket = cache_bucket(provider.kind(), "quote");
let mut quotes = Vec::new(); let mut quotes = Vec::new();
for sym in symbols.iter().flat_map(|s| s.split(',')) { for sym in symbols.iter().flat_map(|s| s.split(',')) {
let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; let resolved = crate::api::resolve_symbol(sym, &config.exchange)?;
@ -198,7 +269,7 @@ pub fn handle(
continue; continue;
} }
match provider.quote(&resolved) { match provider.market().quote(&resolved) {
Ok(q) => { Ok(q) => {
if !no_cache { if !no_cache {
cache.put(&quote_bucket, &resolved, &q, config.quote_ttl)?; cache.put(&quote_bucket, &resolved, &q, config.quote_ttl)?;
@ -209,9 +280,9 @@ pub fn handle(
if !no_cache if !no_cache
&& let Some(stale) = cache.get_stale(&quote_bucket, &resolved)? && let Some(stale) = cache.get_stale(&quote_bucket, &resolved)?
{ {
eprintln!( runtime::warn(format!(
"warning: network failed, serving stale cache for {resolved}" "network failed, serving stale cache for {resolved}"
); ));
quotes.push(stale); quotes.push(stale);
continue; continue;
} }
@ -229,16 +300,16 @@ pub fn handle(
} => { } => {
let history_mode = history_provider_override.unwrap_or(config.history_provider); let history_mode = history_provider_override.unwrap_or(config.history_provider);
let (history_source, hist_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) if matches!(history_mode, HistoryProviderKind::Auto)
&& history_source != config.provider && history_source != provider.kind()
&& !matches!(config.output, crate::output::OutputFormat::Json) && !matches!(config.output, crate::output::OutputFormat::Json)
{ {
eprintln!( runtime::info(format!(
"info: history provider fallback active ({} -> {})", "history provider fallback active ({} -> {})",
config.provider.as_str(), provider.kind().as_str(),
history_source.as_str() history_source.as_str()
); ));
} }
let history_bucket = format!("{}-history", history_source.as_str()); let history_bucket = format!("{}-history", history_source.as_str());
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
@ -282,7 +353,9 @@ pub fn handle(
&format!("{resolved}-{key}"), &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); return render_history(&resolved, &stale, &config.output);
} }
Err(err) Err(err)
@ -295,16 +368,16 @@ pub fn handle(
} => { } => {
let history_mode = history_provider_override.unwrap_or(config.history_provider); let history_mode = history_provider_override.unwrap_or(config.history_provider);
let (history_source, hist_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) if matches!(history_mode, HistoryProviderKind::Auto)
&& history_source != config.provider && history_source != provider.kind()
&& !matches!(config.output, crate::output::OutputFormat::Json) && !matches!(config.output, crate::output::OutputFormat::Json)
{ {
eprintln!( runtime::info(format!(
"info: history provider fallback active ({} -> {})", "history provider fallback active ({} -> {})",
config.provider.as_str(), provider.kind().as_str(),
history_source.as_str() history_source.as_str()
); ));
} }
let technical_bucket = format!("{}-technical", history_source.as_str()); let technical_bucket = format!("{}-technical", history_source.as_str());
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
@ -333,7 +406,9 @@ pub fn handle(
&& let Some(stale) = && let Some(stale) =
cache.get_stale::<TechnicalReport>(&technical_bucket, &resolved)? cache.get_stale::<TechnicalReport>(&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); return render_technical(&stale, &config.output, config.no_color);
} }
Err(err) Err(err)
@ -344,10 +419,10 @@ pub fn handle(
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let report: GrowthReport = fetch_fundamental_analysis_report( let report: GrowthReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider, provider.market(),
&resolved, &resolved,
FundamentalCacheSpec { FundamentalCacheSpec {
bucket: cache_bucket(config, "growth"), bucket: cache_bucket(provider.kind(), "growth"),
ttl_secs: config.fundamental_ttl, ttl_secs: config.fundamental_ttl,
}, },
offline, offline,
@ -360,10 +435,10 @@ pub fn handle(
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let report: ValuationReport = fetch_fundamental_analysis_report( let report: ValuationReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider, provider.market(),
&resolved, &resolved,
FundamentalCacheSpec { FundamentalCacheSpec {
bucket: cache_bucket(config, "valuation"), bucket: cache_bucket(provider.kind(), "valuation"),
ttl_secs: config.fundamental_ttl, ttl_secs: config.fundamental_ttl,
}, },
offline, offline,
@ -376,10 +451,10 @@ pub fn handle(
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let report: RiskReport = fetch_fundamental_analysis_report( let report: RiskReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider, provider.market(),
&resolved, &resolved,
FundamentalCacheSpec { FundamentalCacheSpec {
bucket: cache_bucket(config, "risk"), bucket: cache_bucket(provider.kind(), "risk"),
ttl_secs: config.fundamental_ttl, ttl_secs: config.fundamental_ttl,
}, },
offline, offline,
@ -392,10 +467,10 @@ pub fn handle(
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let report: FundamentalReport = fetch_fundamental_analysis_report( let report: FundamentalReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider, provider.market(),
&resolved, &resolved,
FundamentalCacheSpec { FundamentalCacheSpec {
bucket: cache_bucket(config, "fundamental"), bucket: cache_bucket(provider.kind(), "fundamental"),
ttl_secs: config.fundamental_ttl, ttl_secs: config.fundamental_ttl,
}, },
offline, offline,
@ -406,10 +481,10 @@ pub fn handle(
} }
StocksSubcommand::Profile { symbol } => { StocksSubcommand::Profile { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(config, "profile-v2"); let bucket = cache_bucket(provider.kind(), "profile-v2");
let profile: CompanyProfile = fetch_msn_with_cache( let profile_provider = provider.profile_provider(&resolved)?;
let profile: CompanyProfile = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
@ -418,16 +493,16 @@ pub fn handle(
}, },
offline, offline,
no_cache, no_cache,
|| MsnProvider::new(false).profile(&resolved), || profile_provider.profile(&resolved),
)?; )?;
render_profile(&profile, &config.output) render_profile(&profile, &config.output)
} }
StocksSubcommand::Financials { symbol } => { StocksSubcommand::Financials { symbol, filters } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(config, "financials"); let bucket = cache_bucket(provider.kind(), "financials");
let financials: FinancialStatements = fetch_msn_with_cache( let financials_provider = provider.financials_provider(&resolved)?;
let mut financials: FinancialStatements = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
@ -436,16 +511,22 @@ pub fn handle(
}, },
offline, offline,
no_cache, 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();
} }
StocksSubcommand::Earnings { symbol } => { let filtered = filter_financial_statements(&financials, filters);
render_financials(&filtered, &config.output)
}
StocksSubcommand::Earnings { symbol, filters } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(config, "earnings"); let bucket = cache_bucket(provider.kind(), "earnings");
let earnings: EarningsReport = fetch_msn_with_cache( let earnings_provider = provider.earnings_provider(&resolved)?;
let mut earnings: EarningsReport = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
@ -454,16 +535,20 @@ pub fn handle(
}, },
offline, offline,
no_cache, 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 } => { StocksSubcommand::Sentiment { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(config, "sentiment"); let bucket = cache_bucket(provider.kind(), "sentiment");
let sentiment: SentimentData = fetch_msn_with_cache( let sentiment_provider = provider.sentiment_provider(&resolved)?;
let sentiment: SentimentData = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
@ -472,16 +557,16 @@ pub fn handle(
}, },
offline, offline,
no_cache, no_cache,
|| MsnProvider::new(false).sentiment(&resolved), || sentiment_provider.sentiment(&resolved),
)?; )?;
render_sentiment(&sentiment, &config.output) render_sentiment(&sentiment, &config.output)
} }
StocksSubcommand::Insights { symbol } => { StocksSubcommand::Insights { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(config, "insights-v2"); let bucket = cache_bucket(provider.kind(), "insights-v2");
let insights: InsightData = fetch_msn_with_cache( let insights_provider = provider.insights_provider(&resolved)?;
let mut insights: InsightData = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
@ -490,17 +575,20 @@ pub fn handle(
}, },
offline, offline,
no_cache, 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) render_insights(&insights, &config.output)
} }
StocksSubcommand::News { symbol, limit } => { StocksSubcommand::News { symbol, limit } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; 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 key = format!("{resolved}-{limit}");
let news: Vec<NewsItem> = fetch_msn_with_cache( let news_provider = provider.news_provider(&resolved)?;
let mut news: Vec<NewsItem> = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &key, key: &key,
@ -509,8 +597,13 @@ pub fn handle(
}, },
offline, offline,
no_cache, 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) render_news(&news, &config.output)
} }
StocksSubcommand::Screen { StocksSubcommand::Screen {
@ -524,11 +617,11 @@ pub fn handle(
// client-side sorting picks the correct top N. // client-side sorting picks the correct top N.
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap"); let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
let fetch_limit = if needs_full_fetch { 500 } else { *limit }; 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 key = format!("{filter}:{region}:{fetch_limit}");
let mut quotes: Vec<Quote> = fetch_msn_with_cache( let screener_provider = provider.screener_provider("screen")?;
let mut quotes: Vec<Quote> = fetch_with_cache(
&cache, &cache,
config.provider,
CacheFetchSpec { CacheFetchSpec {
bucket: &bucket, bucket: &bucket,
key: &key, key: &key,
@ -537,7 +630,7 @@ pub fn handle(
}, },
offline, offline,
no_cache, 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); sort_screener_quotes(&mut quotes, filter);
quotes.truncate(*limit); quotes.truncate(*limit);
@ -551,10 +644,10 @@ pub fn handle(
let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; let resolved = crate::api::resolve_symbol(sym, &config.exchange)?;
match fetch_fundamental_analysis_report( match fetch_fundamental_analysis_report(
&cache, &cache,
provider, provider.market(),
&resolved, &resolved,
FundamentalCacheSpec { FundamentalCacheSpec {
bucket: cache_bucket(config, "fundamental"), bucket: cache_bucket(provider.kind(), "fundamental"),
ttl_secs: config.fundamental_ttl, ttl_secs: config.fundamental_ttl,
}, },
offline, offline,
@ -563,7 +656,9 @@ pub fn handle(
) { ) {
Ok(report) => reports.push(report), Ok(report) => reports.push(report),
Err(err) => { 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); last_error = Some(err);
} }
} }
@ -628,10 +723,10 @@ where
if !no_cache if !no_cache
&& let Some(stale) = cache.get_stale::<T>(cache_spec.bucket, cache_spec.key)? && let Some(stale) = cache.get_stale::<T>(cache_spec.bucket, cache_spec.key)?
{ {
eprintln!( runtime::warn(format!(
"warning: network failed, serving stale cache for {}", "network failed, serving stale cache for {}",
cache_spec.subject cache_spec.subject
); ));
return Ok(stale); return Ok(stale);
} }
Err(err) Err(err)
@ -672,7 +767,9 @@ where
} }
Err(err) => { Err(err) => {
if !no_cache && let Some(stale) = cache.get_stale::<T>(&cache_spec.bucket, resolved)? { if !no_cache && let Some(stale) = cache.get_stale::<T>(&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); return Ok(stale);
} }
Err(err) Err(err)
@ -759,32 +856,69 @@ fn average_last(values: &[f64], period: usize) -> Option<f64> {
Some(values[start..].iter().sum::<f64>() / period as f64) Some(values[start..].iter().sum::<f64>() / period as f64)
} }
fn fetch_msn_with_cache<T, F>( fn filter_financial_statements(
cache: &Cache, financials: &FinancialStatements,
provider: crate::config::ProviderKind, filters: &FinancialsFilterArgs,
cache_spec: CacheFetchSpec<'_>, ) -> FinancialStatements {
offline: bool, let includes = |kind: FinancialStatementKind| {
no_cache: bool, filters.statement.is_empty() || filters.statement.contains(&kind)
fetch_fn: F, };
) -> Result<T, IdxError>
where FinancialStatements {
T: Serialize + DeserializeOwned, instrument: financials.instrument.clone(),
F: FnOnce() -> Result<T, IdxError>, balance_sheet: includes(FinancialStatementKind::Balance)
{ .then(|| financials.balance_sheet.clone())
ensure_msn_provider(cache_spec.subject, provider)?; .flatten(),
fetch_with_cache(cache, cache_spec, offline, no_cache, fetch_fn) 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( fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport {
subject: &str, let filter_rows = |rows: &[crate::api::types::EarningsData]| {
provider: crate::config::ProviderKind, rows.iter()
) -> Result<(), IdxError> { .filter(|row| filters.includes_period(&row.period_type))
if !matches!(provider, crate::config::ProviderKind::Msn) { .cloned()
return Err(IdxError::Unsupported(format!( .collect()
"{subject}: command requires --provider msn" };
)));
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> { 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)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashMap;
use chrono::{Days, NaiveDate}; 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::analysis::signals::Signal;
use crate::api::types::Ohlc; use crate::api::types::{
EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection,
};
use crate::error::IdxError; use crate::error::IdxError;
#[test] #[test]
@ -1016,4 +1158,118 @@ mod tests {
assert!(matches!(err, IdxError::InvalidInput(_))); assert!(matches!(err, IdxError::InvalidInput(_)));
assert!(err.to_string().contains("invalid screener region")); 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);
}
} }

View file

@ -143,14 +143,10 @@ impl IdxConfig {
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true"); cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
} }
if let Ok(v) = std::env::var("IDX_CACHE_QUOTE_TTL") if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? {
&& let Ok(parsed) = v.parse::<u64>()
{
cfg.quote_ttl = parsed; cfg.quote_ttl = parsed;
} }
if let Ok(v) = std::env::var("IDX_CACHE_FUNDAMENTAL_TTL") if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? {
&& let Ok(parsed) = v.parse::<u64>()
{
cfg.fundamental_ttl = parsed; cfg.fundamental_ttl = parsed;
} }
@ -199,6 +195,27 @@ impl IdxConfig {
} }
} }
fn parse_env_ttl(name: &str) -> Result<Option<u64>, 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::<u64>().map_err(|_| {
IdxError::ConfigError(format!(
"invalid {name} value '{raw}': expected a non-negative integer"
))
})?;
Ok(Some(parsed))
}
pub fn default_config_toml() -> String { pub fn default_config_toml() -> String {
"[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() "[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string()
} }

View file

@ -9,6 +9,33 @@ mod output;
#[cfg(feature = "ownership")] #[cfg(feature = "ownership")]
pub mod 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::CommandFactory;
use clap::Parser; use clap::Parser;
use clap_complete::{generate, shells}; use clap_complete::{generate, shells};
@ -27,6 +54,7 @@ fn main() {
fn run() -> Result<(), IdxError> { fn run() -> Result<(), IdxError> {
let cli = Cli::parse(); let cli = Cli::parse();
runtime::set_quiet(cli.quiet);
let config = match IdxConfig::load_with_cli(&cli) { let config = match IdxConfig::load_with_cli(&cli) {
Ok(config) => config, Ok(config) => config,
Err(err) => { Err(err) => {
@ -50,13 +78,16 @@ fn run() -> Result<(), IdxError> {
} }
Commands::Stocks(stocks) => { Commands::Stocks(stocks) => {
let provider = default_provider(config.provider, cli.verbose > 0); let provider = default_provider(config.provider, cli.verbose > 0);
if let Err(err) = cli::stocks::handle( if let Err(err) =
cli::stocks::handle(
stocks, stocks,
&config, &config,
provider.as_ref(), &provider,
cli.offline, cli.offline,
cli.no_cache, cli.no_cache,
) { cli.verbose > 0,
)
{
emit_error(&err, &config.output); emit_error(&err, &config.output);
return Err(err); return Err(err);
} }

View file

@ -557,7 +557,7 @@ fn trend_context(report: &TechnicalReport) -> String {
format_idr(sma50.round() as i64), format_idr(sma50.round() as i64),
format_idr(sma200.round() as i64) format_idr(sma200.round() as i64)
), ),
_ => "Insufficient data".to_string(), _ => "Trend unavailable (need at least 200 daily candles)".to_string(),
} }
} }

View file

@ -159,19 +159,11 @@ pub fn upsert_ticker(conn: &Connection, code: &str, name: Option<&str>) -> Resul
.map_err(|e| IdxError::DatabaseError(e.to_string())) .map_err(|e| IdxError::DatabaseError(e.to_string()))
} }
/// Bulk insert KSEI holdings within a transaction. fn insert_ksei_holdings_rows(conn: &Connection, holdings: &[KseiHolding]) -> Result<usize, IdxError> {
/// 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<usize, IdxError> {
if holdings.is_empty() { if holdings.is_empty() {
return Ok(0); return Ok(0);
} }
conn.execute("BEGIN IMMEDIATE", [])
.map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let mut inserted = 0usize; let mut inserted = 0usize;
for h in holdings { for h in holdings {
let locality = h.locality.map(locality_to_db); let locality = h.locality.map(locality_to_db);
@ -203,12 +195,33 @@ pub fn insert_ksei_holdings(
match changed { match changed {
Ok(n) => inserted += n, Ok(n) => inserted += n,
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<usize, IdxError> {
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) => { Err(err) => {
let _ = conn.execute("ROLLBACK", []); let _ = conn.execute("ROLLBACK", []);
return Err(err); return Err(err);
} }
} };
}
conn.execute("COMMIT", []) conn.execute("COMMIT", [])
.map_err(|e| IdxError::DatabaseError(e.to_string()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
@ -286,6 +299,48 @@ pub fn insert_release(conn: &Connection, release: &OwnershipRelease) -> Result<i
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
/// Insert or replace a complete KSEI release and its rows atomically.
pub fn write_ksei_release(
conn: &Connection,
release: &OwnershipRelease,
holdings: &[KseiHolding],
replace_existing: bool,
) -> Result<usize, IdxError> {
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. /// Check if a release with this SHA-256 already exists.
pub fn release_exists(conn: &Connection, sha256: &str) -> Result<bool, IdxError> { pub fn release_exists(conn: &Connection, sha256: &str) -> Result<bool, IdxError> {
let exists: i64 = conn let exists: i64 = conn
@ -306,15 +361,8 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result<TickerOwne
let ticker = let ticker =
query_ticker(conn, code)?.ok_or_else(|| IdxError::SymbolNotFound(code.to_string()))?; query_ticker(conn, code)?.ok_or_else(|| IdxError::SymbolNotFound(code.to_string()))?;
let ksei_as_of = conn let latest_ksei = latest_ksei_release(conn)?;
.query_row( let ksei_as_of = latest_ksei.as_ref().map(|release| release.as_of_date);
"SELECT MAX(report_date) FROM ksei_holdings WHERE ticker_id = ?1",
params![ticker.id],
|row| row.get::<_, Option<String>>(0),
)
.map_err(|e| IdxError::DatabaseError(e.to_string()))?
.map(|s| parse_iso_date(&s))
.transpose()?;
let bing_as_of = conn let bing_as_of = conn
.query_row( .query_row(
@ -326,20 +374,20 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result<TickerOwne
let mut holders: Vec<HolderRow> = Vec::new(); let mut holders: Vec<HolderRow> = Vec::new();
{ if let Some(latest_ksei) = latest_ksei.as_ref() {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT k.entity_id, COALESCE(e.canonical_name, k.raw_investor_name), "SELECT k.entity_id, COALESCE(e.canonical_name, k.raw_investor_name),
k.investor_type, k.locality, k.total_shares, k.percentage_bps k.investor_type, k.locality, k.total_shares, k.percentage_bps
FROM ksei_holdings k FROM ksei_holdings k
LEFT JOIN entities e ON e.id = k.entity_id 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", ORDER BY k.percentage_bps DESC, k.total_shares DESC",
) )
.map_err(|e| IdxError::DatabaseError(e.to_string()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map(params![ticker.id], |row| { .query_map(params![ticker.id, &latest_ksei.sha256], |row| {
Ok(( Ok((
row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i64>>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@ -368,20 +416,20 @@ pub fn query_ticker_holdings(conn: &Connection, code: &str) -> Result<TickerOwne
} }
} }
{ if let Some(bing_as_of) = bing_as_of.as_ref() {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT b.entity_id, COALESCE(e.canonical_name, b.raw_investor_name), "SELECT b.entity_id, COALESCE(e.canonical_name, b.raw_investor_name),
b.investor_type, COALESCE(b.shares_held, 0), COALESCE(b.pct_ownership_bps, 0), b.signal b.investor_type, COALESCE(b.shares_held, 0), COALESCE(b.pct_ownership_bps, 0), b.signal
FROM bing_holdings b FROM bing_holdings b
LEFT JOIN entities e ON e.id = b.entity_id LEFT JOIN entities e ON e.id = b.entity_id
WHERE b.ticker_id = ?1 WHERE b.ticker_id = ?1 AND b.report_date = ?2 AND b.signal = 'holder'
ORDER BY COALESCE(b.pct_ownership_bps, 0) DESC, COALESCE(b.shares_held, 0) DESC", ORDER BY COALESCE(b.pct_ownership_bps, 0) DESC, COALESCE(b.shares_held, 0) DESC",
) )
.map_err(|e| IdxError::DatabaseError(e.to_string()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map(params![ticker.id], |row| { .query_map(params![ticker.id, bing_as_of], |row| {
Ok(( Ok((
row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i64>>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@ -443,18 +491,18 @@ pub fn query_entity_holdings(
let mut holdings = Vec::new(); let mut holdings = Vec::new();
{ if let Some(latest_ksei) = latest_ksei_release(conn)? {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT t.id, t.code, t.name, t.sector, k.total_shares, k.percentage_bps, k.report_date "SELECT t.id, t.code, t.name, t.sector, k.total_shares, k.percentage_bps, k.report_date
FROM ksei_holdings k FROM ksei_holdings k
JOIN tickers t ON t.id = k.ticker_id 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()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map(params![entity_id], |row| { .query_map(params![entity_id, latest_ksei.sha256], |row| {
Ok(EntityTickerRow { Ok(EntityTickerRow {
ticker: Ticker { ticker: Ticker {
id: row.get(0)?, id: row.get(0)?,
@ -479,11 +527,19 @@ pub fn query_entity_holdings(
{ {
let mut stmt = conn let mut stmt = conn
.prepare( .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 COALESCE(b.shares_held, 0), COALESCE(b.pct_ownership_bps, 0), b.report_date
FROM bing_holdings b 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 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()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
@ -535,55 +591,108 @@ pub fn query_cross_holders(
min_tickers: usize, min_tickers: usize,
limit: usize, limit: usize,
) -> Result<Vec<CrossHolderRow>, IdxError> { ) -> Result<Vec<CrossHolderRow>, IdxError> {
let mut aggregates: std::collections::BTreeMap<i64, (Entity, std::collections::BTreeSet<i64>, i64)> =
std::collections::BTreeMap::new();
if let Some(latest_ksei) = latest_ksei_release(conn)? {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT e.id, e.canonical_name, e.entity_type, e.country, "SELECT e.id, e.canonical_name, e.entity_type, e.country, k.ticker_id, k.percentage_bps
COUNT(DISTINCT u.ticker_id) AS ticker_count, FROM ksei_holdings k
SUM(u.percentage_bps) AS total_bps JOIN entities e ON e.id = k.entity_id
FROM entities e WHERE k.entity_id IS NOT NULL AND k.release_sha256 = ?1",
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()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map( .query_map(params![latest_ksei.sha256], |row| {
params![ Ok((
i64::try_from(min_tickers) Entity {
.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 {
id: row.get(0)?, id: row.get(0)?,
canonical_name: row.get(1)?, canonical_name: row.get(1)?,
entity_type: row.get(2)?, entity_type: row.get(2)?,
country: row.get(3)?, country: row.get(3)?,
}, },
ticker_count: row.get::<_, i64>(4)? as usize, row.get::<_, i64>(4)?,
total_bps: row.get(5)?, 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 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()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let mut out = Vec::new(); 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 { for row in rows {
out.push(row.map_err(|e| IdxError::DatabaseError(e.to_string()))?); 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::<Vec<_>>();
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) 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 let mut stmt = conn
.prepare( .prepare(
"SELECT t.code, k.percentage_bps "SELECT t.code, k.percentage_bps
FROM tickers t FROM tickers t
JOIN ksei_holdings k ON k.ticker_id = t.id JOIN ksei_holdings k ON k.ticker_id = t.id
WHERE k.release_sha256 = ?1
ORDER BY t.code ASC, k.percentage_bps DESC", ORDER BY t.code ASC, k.percentage_bps DESC",
) )
.map_err(|e| IdxError::DatabaseError(e.to_string()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map([], |row| { .query_map(params![latest_ksei.sha256], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
}) })
.map_err(|e| IdxError::DatabaseError(e.to_string()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
@ -1096,11 +1210,48 @@ pub fn db_path(_config: &IdxConfig) -> Result<PathBuf, IdxError> {
} }
} }
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") ProjectDirs::from("", "", "idx")
.map(|dirs| dirs.data_local_dir().join("ownership.db")) .map(|dirs| dirs.data_local_dir().join("ownership.db"))
.ok_or_else(|| IdxError::DatabaseError("unable to resolve ownership db path".to_string())) .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<Option<LatestKseiRelease>, 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<Option<Ticker>, IdxError> { fn query_ticker(conn: &Connection, code: &str) -> Result<Option<Ticker>, IdxError> {
conn.query_row( conn.query_row(
"SELECT id, code, name, sector FROM tickers WHERE code = ?1", "SELECT id, code, name, sector FROM tickers WHERE code = ?1",
@ -1190,7 +1341,7 @@ mod tests {
use crate::ownership::db::{ use crate::ownership::db::{
compute_concentration, ensure_schema, get_ticker_id, insert_bing_holdings, compute_concentration, ensure_schema, get_ticker_id, insert_bing_holdings,
insert_ksei_holdings, insert_release, query_concentration, query_cross_holders, 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::{ use crate::ownership::types::{
BingHolding, FlowSignal, KseiHolding, Locality, OwnershipRelease, 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(); let data = query_ticker_holdings(&conn, "BBCA").unwrap();
assert_eq!(data.ticker.code, "BBCA"); assert_eq!(data.ticker.code, "BBCA");
@ -1322,6 +1481,165 @@ mod tests {
assert_eq!(count, 1); 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] #[test]
fn test_cross_holders_same_entity_three_tickers() { fn test_cross_holders_same_entity_three_tickers() {
let conn = setup(); let conn = setup();
@ -1333,9 +1651,10 @@ mod tests {
.unwrap(); .unwrap();
let eid = conn.last_insert_rowid(); let eid = conn.last_insert_rowid();
let mut holdings = Vec::new();
for (code, bps) in [("BBCA", 1000), ("BBRI", 1100), ("BMRI", 1200)] { for (code, bps) in [("BBCA", 1000), ("BBRI", 1100), ("BMRI", 1200)] {
let tid = upsert_ticker(&conn, code, None).unwrap(); let tid = upsert_ticker(&conn, code, None).unwrap();
let h = KseiHolding { holdings.push(KseiHolding {
id: 0, id: 0,
ticker_id: tid, ticker_id: tid,
entity_id: Some(eid), entity_id: Some(eid),
@ -1349,10 +1668,18 @@ mod tests {
total_shares: 10, total_shares: 10,
percentage_bps: bps, percentage_bps: bps,
report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(),
release_sha256: format!("r-{code}"), release_sha256: "current-release".to_string(),
}; });
insert_ksei_holdings(&conn, &[h]).unwrap();
} }
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(); let rows = query_cross_holders(&conn, 3, 10).unwrap();
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
@ -1360,6 +1687,97 @@ mod tests {
assert_eq!(rows[0].ticker_count, 3); 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] #[test]
fn test_concentration_sorting() { fn test_concentration_sorting() {
let conn = setup(); let conn = setup();
@ -1373,10 +1791,9 @@ mod tests {
(bb, 3000, "B2"), (bb, 3000, "B2"),
]; ];
for (tid, bps, name) in rows { let holdings = rows
insert_ksei_holdings( .into_iter()
&conn, .map(|(tid, bps, name)| KseiHolding {
&[KseiHolding {
id: 0, id: 0,
ticker_id: tid, ticker_id: tid,
entity_id: None, entity_id: None,
@ -1390,11 +1807,18 @@ mod tests {
total_shares: 1, total_shares: 1,
percentage_bps: bps, percentage_bps: bps,
report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(), report_date: NaiveDate::from_ymd_opt(2026, 2, 27).unwrap(),
release_sha256: format!("{tid}-{name}"), release_sha256: "current-release".to_string(),
}], })
) .collect::<Vec<_>>();
.unwrap(); 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(); let by_top1 = query_concentration(&conn, "top1", 10).unwrap();
assert_eq!(by_top1[0].0, "AA"); assert_eq!(by_top1[0].0, "AA");
@ -1406,6 +1830,87 @@ mod tests {
assert_eq!(by_hhi[0].0, "AA"); 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] #[test]
fn test_insert_bing_and_query_empty_db_paths() { fn test_insert_bing_and_query_empty_db_paths() {
let conn = setup(); let conn = setup();

View file

@ -26,12 +26,24 @@ pub fn query_ownership_graph(
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"WITH RECURSIVE "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 ( all_edges AS (
SELECT SELECT
'entity:' || k.entity_id AS from_id, 'entity:' || k.entity_id AS from_id,
'ticker:' || t.code AS to_id 'ticker:' || t.code AS to_id
FROM ksei_holdings k FROM ksei_holdings k
JOIN tickers t ON t.id = k.ticker_id 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 WHERE k.entity_id IS NOT NULL
UNION UNION
@ -40,8 +52,12 @@ pub fn query_ownership_graph(
'entity:' || b.entity_id AS from_id, 'entity:' || b.entity_id AS from_id,
'ticker:' || t.code AS to_id 'ticker:' || t.code AS to_id
FROM bing_holdings b 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 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'
), ),
neighbors AS ( neighbors AS (
SELECT from_id AS a, to_id AS b FROM all_edges 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<String, IdxError> {
fn query_all_edges(conn: &Connection) -> Result<Vec<GraphEdge>, IdxError> { fn query_all_edges(conn: &Connection) -> Result<Vec<GraphEdge>, IdxError> {
let mut out = Vec::new(); 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 let mut stmt = conn
.prepare( .prepare(
@ -215,12 +240,13 @@ fn query_all_edges(conn: &Connection) -> Result<Vec<GraphEdge>, IdxError> {
k.percentage_bps k.percentage_bps
FROM ksei_holdings k FROM ksei_holdings k
JOIN tickers t ON t.id = k.ticker_id 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()))?; .map_err(|e| IdxError::DatabaseError(e.to_string()))?;
let rows = stmt let rows = stmt
.query_map([], |row| { .query_map(params![release_sha], |row| {
Ok(GraphEdge { Ok(GraphEdge {
from: row.get(0)?, from: row.get(0)?,
to: row.get(1)?, to: row.get(1)?,
@ -238,12 +264,21 @@ fn query_all_edges(conn: &Connection) -> Result<Vec<GraphEdge>, IdxError> {
{ {
let mut stmt = conn let mut stmt = conn
.prepare( .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, 'ticker:' || t.code,
COALESCE(b.pct_ownership_bps, 0) COALESCE(b.pct_ownership_bps, 0)
FROM bing_holdings b 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 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()))?; .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 { fn escape_dot(value: &str) -> String {
value.replace('"', "\\\"") 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);
}
}

View file

@ -9,19 +9,92 @@ use std::thread;
use assert_cmd::Command; use assert_cmd::Command;
use predicates::prelude::*; use predicates::prelude::*;
use rusqlite::Connection; use rusqlite::Connection;
use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
fn bin() -> Command { fn bin() -> Command {
let current = std::env::current_exe().expect("current test executable path"); let exe = resolve_idx_binary_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));
Command::new(exe) 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<PathBuf> = 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 { fn test_env_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir); 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"); fs::create_dir_all(&cache_home).expect("create cache dir");
let mut cmd = bin(); let mut cmd = bin();
cmd.current_dir(env!("CARGO_MANIFEST_DIR"));
cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_CONFIG_HOME", &config_home);
cmd.env("XDG_CACHE_HOME", &cache_home); cmd.env("XDG_CACHE_HOME", &cache_home);
cmd cmd
@ -46,6 +120,13 @@ fn test_bin(name: &str) -> Command {
bin_with_root(&root) 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<Vec<u8>>) -> String { fn spawn_single_response_server(content_type: &str, body: impl Into<Vec<u8>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server");
let addr = listener.local_addr().expect("local addr"); let addr = listener.local_addr().expect("local addr");
@ -295,6 +376,43 @@ fn help_works() {
test_bin("help").arg("--help").assert().success(); 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] #[test]
fn version_prints_cargo_version() { fn version_prints_cargo_version() {
test_bin("version") test_bin("version")
@ -349,7 +467,10 @@ fn technical_with_mock_provider_table_contains_expected_rows() {
.success() .success()
.stdout(predicate::str::contains("Technical Analysis for")) .stdout(predicate::str::contains("Technical Analysis for"))
.stdout(predicate::str::contains("RSI (14)")) .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] #[test]
@ -464,6 +585,43 @@ fn msn_financials_with_mock_fixture_table_contains_sections() {
.stdout(predicate::str::contains("Cash Flow")); .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] #[test]
fn msn_earnings_with_mock_fixture_table_is_sectioned_and_formatted() { fn msn_earnings_with_mock_fixture_table_is_sectioned_and_formatted() {
test_bin("msn-earnings-table") 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")); .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] #[test]
fn msn_earnings_with_mock_fixture_json_contains_forecast_and_history() { fn msn_earnings_with_mock_fixture_json_contains_forecast_and_history() {
test_bin("msn-earnings-json") 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"]) .args(["-o", "json", "stocks", "earnings", "BBCA"])
.assert() .assert()
.success() .success()
.stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\""))
.stdout(predicate::str::contains("\"forecast\"")) .stdout(predicate::str::contains("\"forecast\""))
.stdout(predicate::str::contains("\"history\"")) .stdout(predicate::str::contains("\"history\""))
.stdout(predicate::str::contains("Q12026")); .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] #[test]
fn msn_sentiment_with_mock_fixture_table_contains_ranges() { fn msn_sentiment_with_mock_fixture_table_contains_ranges() {
test_bin("msn-sentiment-table") 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"]) .args(["-o", "json", "stocks", "insights", "BBCA"])
.assert() .assert()
.success() .success()
.stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\""))
.stdout(predicate::str::contains( .stdout(predicate::str::contains(
"\"summary\": \"Mixed analyst signals", "\"summary\": \"Mixed analyst signals",
)) ))
@ -580,6 +778,7 @@ fn msn_news_with_mock_fixture_json_contains_provider_and_timestamp() {
.assert() .assert()
.success() .success()
.stdout(predicate::str::contains("\"id\": \"news-1\"")) .stdout(predicate::str::contains("\"id\": \"news-1\""))
.stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\""))
.stdout(predicate::str::contains( .stdout(predicate::str::contains(
"\"title\": \"BCA reports steady growth\"", "\"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] #[test]
fn ownership_discover_lists_fixture_candidates() { fn ownership_discover_lists_fixture_candidates() {
let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json") 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)); .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] #[test]
fn ownership_import_url_duplicate_release_is_skipped() { fn ownership_import_url_duplicate_release_is_skipped() {
let root = test_env_dir("ownership-import-duplicate-release"); 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")); .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] #[test]
fn ownership_import_url_rejects_legacy_above5_pdf_schema() { fn ownership_import_url_rejects_legacy_above5_pdf_schema() {
let root = test_env_dir("ownership-import-above5-unsupported"); 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 LOCAL CP"))
.stdout(predicate::str::contains("KSEI AGGREGATE FOREIGN MF")); .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) bin_with_root(&root)
.args([ .args([
"ownership", "ownership",
@ -1433,6 +1759,31 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
.stderr(predicate::str::contains("warning: network failed")); .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] #[test]
fn cache_namespace_isolated_by_provider() { fn cache_namespace_isolated_by_provider() {
let root = test_env_dir("provider-cache"); let root = test_env_dir("provider-cache");
@ -1502,6 +1853,29 @@ fn invalid_provider_env_honors_json_output() {
.stderr(predicate::str::contains("invalid provider")); .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] #[test]
fn offline_and_no_cache_flags_are_rejected() { fn offline_and_no_cache_flags_are_rejected() {
test_bin("offline-no-cache") 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("\"error\": true"))
.stderr(predicate::str::contains("invalid screener region")); .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",
));
}
}

17
tests/fixtures/chart_bbca_with_gap.json vendored Normal file
View file

@ -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
}
}

View file

@ -31,6 +31,34 @@
"priceToEarningsRatio": 24.1, "priceToEarningsRatio": 24.1,
"priceToBookRatio": 4.2 "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
}
] ]
} }
] ]

View file

@ -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": []
}
]