diff --git a/TODO.md b/TODO.md index f884dca..0a4db20 100644 --- a/TODO.md +++ b/TODO.md @@ -205,6 +205,9 @@ - [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`) +- [x] MSN key-ratios parsing now tolerates stringified non-finite numeric sentinels (`Infinity`, `-Infinity`, `NaN`) as missing data instead of failing the entire fundamentals payload +- [x] Live direct CLI repros on `2026-04-13` confirmed the original failure class on `BUMI`, `ADRO`, and `AIMS`; an opt-in `scripts/live-smoke.sh --group live-nonfinite` group now covers those real ticker paths +- [x] Added `scripts/audit-msn-fundamentals.sh` for a full CLI valuation sweep across the IDX MSN symbol map; keep it as a provider-health audit, not part of the default smoke baseline - [ ] `ownership import --fetch-bing` is still deferred and returns unsupported - [x] Real KSEI PDF import from local file now works again: `ownership import --file /Users/rasyidanakbar/Downloads/ksei_raw_data.pdf` imported `7261` rows for `955` tickers on `2026-03-28`, replacing the previous `6`-row/`1`-ticker failure mode - [x] KSEI parser no longer depends on the old hardcoded column bounds fixture layout; it now reconstructs rows from `mutool` line output and handles the live `DATE + SHARE_CODE` merged segment plus `D`/`A` locality markers diff --git a/docs/SMOKE.md b/docs/SMOKE.md index cffccc7..36962d1 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -15,9 +15,11 @@ scripts/live-smoke.sh scripts/live-smoke.sh --mode full scripts/live-smoke.sh --mode mock scripts/live-smoke.sh --group live-table --group live-json +scripts/live-smoke.sh --group live-nonfinite 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 +scripts/audit-msn-fundamentals.sh --tickers BUMI,ADRO,AIMS ``` ## Modes @@ -35,6 +37,7 @@ scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock - `cache`: cache warm, `--offline`, and stale-cache fallback checks for quote, technical, and MSN `profile` - `routing`: Yahoo/MSN provider routing plus explicit MSN history unsupported behavior - `errors`: JSON error contract and invalid flag/input checks +- `live-nonfinite`: opt-in live MSN fundamentals checks for known non-finite ticker payloads (`BUMI`, `ADRO`, `AIMS`) - `ownership`: safe ownership smoke checks that do not require imported ownership data - `ownership-import`: live discovery/import hardening checks for supported `above1` import plus expected unsupported legacy-family failures @@ -43,7 +46,9 @@ scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock - The runner forces `IDX_OUTPUT=table` as its default environment so table cases stay stable; JSON checks use `-o json` explicitly. - Cache-group warm cases clear the smoke cache before they run so each warm/offline/stale sequence starts clean and stale-cache assertions are not masked by earlier groups. - Use `--bin --no-build` when you want to validate an installed binary instead of the workspace `target/debug/idx` build. +- Use `scripts/audit-msn-fundamentals.sh` for a full CLI valuation sweep across the IDX MSN symbol map. It is intentionally separate from the reusable smoke runner because it is a heavier provider-health audit, not a stable baseline check. - Ownership commands that need imported data are intentionally not part of the baseline runner yet. The current baseline only covers `ownership releases` and the known unsupported `ownership import --fetch-bing`. - `ownership sync` is still primarily covered by fixture-backed CLI tests rather than the reusable smoke runner. - The new `ownership-import` group is intentionally opt-in for explicit `--group ownership-import` runs or `--mode full`; it discovers live URLs first, imports the supported `above1` attachment into the temp DB, then asserts the current `above5` and `investor-type` URLs fail with explicit unsupported-schema UX. +- The new `live-nonfinite` group is intentionally opt-in only. As of `2026-04-13`, the known real repro tickers are `BUMI`, `ADRO`, and `AIMS`. - When a case fails, inspect the per-case log in `tmp/live-smoke/.../logs/` before updating `TODO.md` or `FEATURE_SPEC.md`. diff --git a/scripts/audit-msn-fundamentals.sh b/scripts/audit-msn-fundamentals.sh new file mode 100755 index 0000000..593c8f1 --- /dev/null +++ b/scripts/audit-msn-fundamentals.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -u +set -o pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_PATH="" +WORKDIR="" +TICKERS="" +BUILD=1 + +CONFIG_HOME="" +CACHE_HOME="" +DATA_HOME="" +LOG_DIR="" +SUMMARY_FILE="" + +declare -a BASE_ENV=() +declare -a TICKER_LIST=() + +usage() { + cat <<'EOF' +Usage: scripts/audit-msn-fundamentals.sh [options] + +Run `idx -o json stocks valuation ` across the IDX MSN symbol map. +This is a heavier provider audit, not a default smoke check. + +Options: + --bin Use an existing idx binary instead of target/debug/idx + --no-build Skip cargo build + --workdir Artifact root. Default: tmp/msn-fundamentals-audit/ + --tickers Comma-separated ticker override instead of the full symbol map + --help Show this help + +Examples: + scripts/audit-msn-fundamentals.sh + scripts/audit-msn-fundamentals.sh --tickers BUMI,ADRO,AIMS + scripts/audit-msn-fundamentals.sh --bin ./target/debug/idx --no-build +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --bin) + BIN_PATH="${2:-}" + BUILD=0 + shift 2 + ;; + --no-build) + BUILD=0 + shift + ;; + --workdir) + WORKDIR="${2:-}" + shift 2 + ;; + --tickers) + TICKERS="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + done +} + +prepare_paths() { + if [[ -z "$WORKDIR" ]]; then + WORKDIR="$ROOT_DIR/tmp/msn-fundamentals-audit/$(date +%Y%m%d-%H%M%S)" + fi + + CONFIG_HOME="$WORKDIR/config" + CACHE_HOME="$WORKDIR/cache" + DATA_HOME="$WORKDIR/data" + LOG_DIR="$WORKDIR/logs" + SUMMARY_FILE="$WORKDIR/summary.tsv" + + mkdir -p "$CONFIG_HOME" "$CACHE_HOME" "$DATA_HOME" "$LOG_DIR" + + BASE_ENV=( + "XDG_CONFIG_HOME=$CONFIG_HOME" + "XDG_CACHE_HOME=$CACHE_HOME" + "XDG_DATA_HOME=$DATA_HOME" + "IDX_PROVIDER=msn" + "IDX_HISTORY_PROVIDER=auto" + ) +} + +build_binary() { + if [[ -z "$BIN_PATH" ]]; then + BIN_PATH="$ROOT_DIR/target/debug/idx" + fi + + if (( BUILD )); then + ( + cd "$ROOT_DIR" || exit 1 + cargo build --quiet --bin idx + ) || { + echo "build failed" >&2 + exit 1 + } + fi + + if [[ ! -x "$BIN_PATH" ]]; then + echo "idx binary not found or not executable: $BIN_PATH" >&2 + exit 1 + fi +} + +resolve_tickers() { + local raw_ticker + local old_ifs + + if [[ -n "$TICKERS" ]]; then + old_ifs="$IFS" + IFS=',' + for raw_ticker in $TICKERS; do + raw_ticker="${raw_ticker//[[:space:]]/}" + if [[ -n "$raw_ticker" ]]; then + TICKER_LIST+=("$raw_ticker") + fi + done + IFS="$old_ifs" + else + mapfile -t TICKER_LIST < <(cut -f1 "$ROOT_DIR/src/api/msn/symbol_ids.tsv") + fi + + if [[ ${#TICKER_LIST[@]} -eq 0 ]]; then + echo "no tickers selected" >&2 + exit 1 + fi +} + +run_audit() { + local total="${#TICKER_LIST[@]}" + local index=0 + local passed=0 + local failed=0 + local ticker + local log_file + local message + + printf 'ticker\tstatus\tmessage\tlog\n' >"$SUMMARY_FILE" + + for ticker in "${TICKER_LIST[@]}"; do + index=$((index + 1)) + log_file="$LOG_DIR/${ticker}.log" + + printf '[%04d/%04d] %s\n' "$index" "$total" "$ticker" + + if env "${BASE_ENV[@]}" "$BIN_PATH" -q -o json stocks valuation "$ticker" >"$log_file" 2>&1 \ + && grep -q '"overall_signal"' "$log_file"; then + passed=$((passed + 1)) + printf '%s\tok\t-\t%s\n' "$ticker" "$log_file" >>"$SUMMARY_FILE" + else + failed=$((failed + 1)) + message="$(tr '\n' ' ' <"$log_file" | tr '\t' ' ' | cut -c1-240)" + printf '%s\tfailed\t%s\t%s\n' "$ticker" "$message" "$log_file" >>"$SUMMARY_FILE" + fi + done + + printf '\nresults: passed=%d failed=%d total=%d\n' "$passed" "$failed" "$total" + printf 'summary: %s\n' "$SUMMARY_FILE" + + if (( failed > 0 )); then + exit 1 + fi +} + +main() { + parse_args "$@" + prepare_paths + build_binary + resolve_tickers + run_audit +} + +main "$@" diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 6a6a635..e9a244e 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -61,6 +61,7 @@ Groups: cache deterministic cache/offline/stale-cache checks routing provider routing and explicit unsupported checks errors JSON error contract and invalid-flag checks + live-nonfinite opt-in live MSN fundamentals checks for known non-finite tickers ownership ownership commands that are safe without imported data ownership-import live ownership discovery/import hardening checks @@ -69,6 +70,7 @@ Examples: scripts/live-smoke.sh --mode full scripts/live-smoke.sh --mode mock --group cache scripts/live-smoke.sh --group live-table --group live-json --symbol BBRI + scripts/live-smoke.sh --group live-nonfinite EOF } @@ -198,6 +200,11 @@ register_cases() { add_case "live-json" "news" "0" "$live_env" "-o json $news_args" "" add_case "live-json" "screen" "0" "$live_env" "-o json $screen_args" "" + add_case "live-nonfinite" "valuation-bumi" "0" "$live_env" "-o json stocks valuation BUMI" "\"overall_signal\"" + add_case "live-nonfinite" "valuation-adro" "0" "$live_env" "-o json stocks valuation ADRO" "\"overall_signal\"" + add_case "live-nonfinite" "valuation-aims" "0" "$live_env" "-o json stocks valuation AIMS" "\"overall_signal\"" + add_case "live-nonfinite" "compare-bad-tickers" "0" "$live_env" "-o json stocks compare BUMI,ADRO,AIMS" "\"symbol\": \"BUMI.JK\"" + add_case "mock" "quote-table" "0" "$mock_env" "stocks quote $live_symbol" "" add_case "mock" "quote-json" "0" "$mock_env" "-o json stocks quote $live_symbol" "" add_case "mock" "history-table" "0" "$mock_env" "$history_args" "" diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index 0a7f19b..bfd40c3 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -31,6 +31,10 @@ pub(crate) fn parse_fundamentals_from_str( mod tests { use super::{parse_fundamentals_from_str, parse_quote_from_str}; + fn minimal_quote_raw() -> &'static str { + r#"[{"symbol":"BBCA","marketCap":1215200000000000}]"# + } + #[test] fn parses_quote_fixture_json() { let raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") @@ -59,4 +63,84 @@ mod tests { assert_eq!(fundamentals.earnings_growth, Some(0.121)); assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000)); } + + #[test] + fn parses_fundamentals_with_infinity_string_as_missing_data() { + let raw = r#"[ + { + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "priceToEarningsRatio": "Infinity", + "priceToBookRatio": 4.6, + "roe": 19.8, + "profitMargin": 44.2, + "debtToEquityRatio": 0.75, + "currentRatio": 1.4 + } + ] + } + ]"#; + + let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw())) + .expect("fundamentals parsed"); + + assert_eq!(fundamentals.trailing_pe, None); + assert_eq!(fundamentals.price_to_book, Some(4.6)); + assert_eq!(fundamentals.return_on_equity, Some(0.198)); + } + + #[test] + fn parses_fundamentals_with_negative_infinity_string_as_missing_data() { + let raw = r#"[ + { + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "priceToEarningsRatio": 12.5, + "debtToEquityRatio": "-Infinity", + "roe": 19.8, + "profitMargin": 44.2, + "currentRatio": 1.4 + } + ] + } + ]"#; + + let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw())) + .expect("fundamentals parsed"); + + assert_eq!(fundamentals.trailing_pe, Some(12.5)); + assert_eq!(fundamentals.debt_to_equity, None); + assert_eq!(fundamentals.return_on_equity, Some(0.198)); + } + + #[test] + fn parses_fundamentals_with_nan_string_as_missing_data() { + let raw = r#"[ + { + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "revenueGrowthRate": "NaN", + "earningsGrowthRate": 12.1, + "roe": 19.8, + "profitMargin": 44.2, + "debtToEquityRatio": 0.75, + "currentRatio": 1.4 + } + ] + } + ]"#; + + let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw())) + .expect("fundamentals parsed"); + + assert_eq!(fundamentals.revenue_growth, None); + assert_eq!(fundamentals.earnings_growth, Some(0.121)); + assert_eq!(fundamentals.return_on_equity, Some(0.198)); + } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index 47ed8eb..e379782 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -47,33 +47,45 @@ pub(crate) struct KeyRatios { pub(crate) struct IndustryMetric { pub(crate) year: Option, pub(crate) fiscal_period_type: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) revenue_growth_rate: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) earnings_growth_rate: Option, - #[serde(default, rename = "netIncomeYTDYTDGrowthRate")] + #[serde( + default, + rename = "netIncomeYTDYTDGrowthRate", + deserialize_with = "de_opt_f64_lenient" + )] pub(crate) net_income_ytd_ytd_growth_rate: Option, - #[serde(default, rename = "revenueYTDYTD")] + #[serde( + default, + rename = "revenueYTDYTD", + deserialize_with = "de_opt_f64_lenient" + )] pub(crate) revenue_ytd_ytd: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) net_margin: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) profit_margin: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) roe: Option, - #[serde(default, rename = "roaTTM")] + #[serde(default, rename = "roaTTM", deserialize_with = "de_opt_f64_lenient")] pub(crate) roa_ttm: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) return_on_asset_current: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) debt_to_equity_ratio: Option, #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) current_ratio: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) price_to_earnings_ratio: Option, - #[serde(default, rename = "forwardPriceToEPS")] + #[serde( + default, + rename = "forwardPriceToEPS", + deserialize_with = "de_opt_f64_lenient" + )] pub(crate) forward_price_to_eps: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_opt_f64_lenient")] pub(crate) price_to_book_ratio: Option, } @@ -324,10 +336,23 @@ where Some(NumberLike::F64(_)) => Ok(None), Some(NumberLike::String(raw)) => { let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") { + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("nan") + || trimmed.eq_ignore_ascii_case("infinity") + || trimmed.eq_ignore_ascii_case("-infinity") + { Ok(None) } else { - trimmed.parse::().map(Some).map_err(D::Error::custom) + trimmed + .parse::() + .map_err(D::Error::custom) + .map(|number| { + if number.is_finite() { + Some(number) + } else { + None + } + }) } } None => Ok(None), diff --git a/tests/cli.rs b/tests/cli.rs index d8b21ec..3e5eb35 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -128,6 +128,17 @@ fn fixture_path(name: &str) -> PathBuf { .join(name) } +fn run_success_stdout(cmd: &mut Command) -> String { + let output = cmd.output().expect("run command"); + assert!( + output.status.success(), + "command failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + String::from_utf8(output.stdout).expect("utf8 stdout") +} + fn spawn_single_response_server(content_type: &str, body: impl Into>) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); let addr = listener.local_addr().expect("local addr"); @@ -900,6 +911,112 @@ fn compare_with_mock_provider_table_contains_resolved_symbol() { .stdout(predicate::str::contains("BBCA.JK")); } +#[test] +fn infinity_msn_mock_fundamentals_succeed_for_analysis_commands() { + let fixture = fixture_path("msn_keyratios_infinity.json"); + let fixture_str = fixture + .to_str() + .expect("fixture path should be valid unicode") + .to_string(); + let cases = [ + ( + "growth-infinity-json", + vec!["-o", "json", "stocks", "growth", "BBCA"], + vec!["\"revenue_growth\"", "\"overall_signal\""], + ), + ( + "valuation-infinity-json", + vec!["-o", "json", "stocks", "valuation", "BBCA"], + vec!["\"pe_trailing\": null", "\"overall_signal\""], + ), + ( + "risk-infinity-json", + vec!["-o", "json", "stocks", "risk", "BBCA"], + vec!["\"debt_to_equity\"", "\"overall_signal\""], + ), + ( + "fundamental-infinity-json", + vec!["-o", "json", "stocks", "fundamental", "BBCA"], + vec!["\"symbol\": \"BBCA.JK\"", "\"overall_signal\""], + ), + ( + "compare-infinity-json", + vec!["-o", "json", "stocks", "compare", "BBCA,BBRI"], + vec!["\"symbol\": \"BBCA.JK\"", "\"symbol\": \"BBRI.JK\""], + ), + ]; + + for (name, args, needles) in cases { + let stdout = run_success_stdout( + test_bin(name) + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_MOCK_MSN_KEYRATIOS_FIXTURE", &fixture_str) + .args(args), + ); + + for needle in needles { + assert!( + stdout.contains(needle), + "missing `{needle}` in output: {stdout}" + ); + } + } +} + +#[test] +fn negative_infinity_msn_mock_fundamentals_succeed_for_analysis_commands() { + let fixture = fixture_path("msn_keyratios_negative_infinity.json"); + let fixture_str = fixture + .to_str() + .expect("fixture path should be valid unicode") + .to_string(); + let cases = [ + ( + "growth-negative-infinity-json", + vec!["-o", "json", "stocks", "growth", "BBCA"], + vec!["\"revenue_growth\"", "\"overall_signal\""], + ), + ( + "valuation-negative-infinity-json", + vec!["-o", "json", "stocks", "valuation", "BBCA"], + vec!["\"pe_trailing\": null", "\"overall_signal\""], + ), + ( + "risk-negative-infinity-json", + vec!["-o", "json", "stocks", "risk", "BBCA"], + vec!["\"debt_to_equity\": null", "\"overall_signal\""], + ), + ( + "fundamental-negative-infinity-json", + vec!["-o", "json", "stocks", "fundamental", "BBCA"], + vec!["\"symbol\": \"BBCA.JK\"", "\"overall_signal\""], + ), + ( + "compare-negative-infinity-json", + vec!["-o", "json", "stocks", "compare", "BBCA,BBRI"], + vec!["\"symbol\": \"BBCA.JK\"", "\"symbol\": \"BBRI.JK\""], + ), + ]; + + for (name, args, needles) in cases { + let stdout = run_success_stdout( + test_bin(name) + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_MOCK_MSN_KEYRATIOS_FIXTURE", &fixture_str) + .args(args), + ); + + for needle in needles { + assert!( + stdout.contains(needle), + "missing `{needle}` in output: {stdout}" + ); + } + } +} + #[test] fn config_path_prints_path() { test_bin("config-path") @@ -1460,6 +1577,7 @@ fn ownership_import_force_reimports_existing_release() { #[test] fn ownership_import_url_rejects_legacy_above5_pdf_schema() { let root = test_env_dir("ownership-import-above5-unsupported"); + let data_home = root.join("data"); let fake_mutool_dir = install_fake_mutool( &root, include_str!("fixtures/ksei_above5_stext_excerpt.xml"), @@ -1469,6 +1587,7 @@ fn ownership_import_url_rejects_legacy_above5_pdf_schema() { bin_with_root(&root) .env("PATH", prepend_path(&fake_mutool_dir)) + .env("XDG_DATA_HOME", &data_home) .args(["ownership", "import", "--url", &pdf_url]) .assert() .failure() @@ -1480,6 +1599,7 @@ fn ownership_import_url_rejects_legacy_above5_pdf_schema() { #[test] fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() { let root = test_env_dir("ownership-import-investor-type-unsupported"); + let data_home = root.join("data"); let fake_mutool_dir = install_fake_mutool( &root, include_str!("fixtures/ksei_investor_type_stext_excerpt.xml"), @@ -1489,6 +1609,7 @@ fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() { bin_with_root(&root) .env("PATH", prepend_path(&fake_mutool_dir)) + .env("XDG_DATA_HOME", &data_home) .args(["ownership", "import", "--url", &pdf_url]) .assert() .failure() diff --git a/tests/fixtures/msn_keyratios_infinity.json b/tests/fixtures/msn_keyratios_infinity.json new file mode 100644 index 0000000..1ad8c99 --- /dev/null +++ b/tests/fixtures/msn_keyratios_infinity.json @@ -0,0 +1,19 @@ +[ + { + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "revenueGrowthRate": 8.1, + "earningsGrowthRate": 12.1, + "priceToEarningsRatio": "Infinity", + "priceToBookRatio": 4.6, + "roe": 19.8, + "profitMargin": 44.2, + "debtToEquityRatio": 0.75, + "currentRatio": 1.4, + "roaTTM": 3.79 + } + ] + } +] diff --git a/tests/fixtures/msn_keyratios_negative_infinity.json b/tests/fixtures/msn_keyratios_negative_infinity.json new file mode 100644 index 0000000..3e0fb60 --- /dev/null +++ b/tests/fixtures/msn_keyratios_negative_infinity.json @@ -0,0 +1,19 @@ +[ + { + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "revenueGrowthRate": 8.1, + "earningsGrowthRate": 12.1, + "priceToEarningsRatio": "-Infinity", + "priceToBookRatio": 4.6, + "roe": 19.8, + "profitMargin": 44.2, + "debtToEquityRatio": "-Infinity", + "currentRatio": 1.4, + "roaTTM": 3.79 + } + ] + } +]