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