ohmydata 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {ohmydata-0.2.0 → ohmydata-0.2.2}/CHANGELOG.md +29 -0
  2. {ohmydata-0.2.0 → ohmydata-0.2.2}/PKG-INFO +66 -4
  3. {ohmydata-0.2.0 → ohmydata-0.2.2}/README.md +64 -2
  4. {ohmydata-0.2.0 → ohmydata-0.2.2}/pyproject.toml +2 -2
  5. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/__init__.py +1 -1
  6. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/cli.py +5 -1
  7. ohmydata-0.2.2/src/ohmydata/providers/yfinance/_fundamentals_parsers.py +199 -0
  8. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/client.py +3 -3
  9. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/fundamentals.py +93 -139
  10. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/quality.py +76 -0
  11. ohmydata-0.2.2/src/ohmydata/tools/__init__.py +17 -0
  12. ohmydata-0.2.2/src/ohmydata/tools/cli.py +166 -0
  13. ohmydata-0.2.2/src/ohmydata/tools/drift_audit.py +326 -0
  14. ohmydata-0.2.2/src/ohmydata/tools/universes.py +129 -0
  15. {ohmydata-0.2.0 → ohmydata-0.2.2}/uv.lock +6 -5
  16. {ohmydata-0.2.0 → ohmydata-0.2.2}/.gitignore +0 -0
  17. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/adapters/__init__.py +0 -0
  18. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/adapters/polars.py +0 -0
  19. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/__init__.py +0 -0
  20. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/_vintage_lock.py +0 -0
  21. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/availability.py +0 -0
  22. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/errors.py +0 -0
  23. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/facts.py +0 -0
  24. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/policy.py +0 -0
  25. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/provenance.py +0 -0
  26. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/rate_limit.py +0 -0
  27. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/snapshot.py +0 -0
  28. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/specs.py +0 -0
  29. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/core/vintage.py +0 -0
  30. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/__init__.py +0 -0
  31. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/__init__.py +0 -0
  32. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/artifacts.py +0 -0
  33. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/batch.py +0 -0
  34. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/cli.py +0 -0
  35. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/core_dataset.py +0 -0
  36. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/edgar.py +0 -0
  37. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/edgartools_adapter.py +0 -0
  38. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/endpoints.py +0 -0
  39. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/errors.py +0 -0
  40. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/financials.py +0 -0
  41. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/financials_dataset.py +0 -0
  42. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/http.py +0 -0
  43. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/nport.py +0 -0
  44. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/qualification.py +0 -0
  45. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/sec/qualification_dataset.py +0 -0
  46. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/__init__.py +0 -0
  47. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/client.py +0 -0
  48. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/endpoints.py +0 -0
  49. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/errors.py +0 -0
  50. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/observations/__init__.py +0 -0
  51. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/observations/capture.py +0 -0
  52. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/observations/serialization.py +0 -0
  53. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/__init__.py +0 -0
  54. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/etf_adjusted_bars.py +0 -0
  55. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/etf_index_mapping.py +0 -0
  56. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/etf_pcf_history.py +0 -0
  57. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/index_weight_vintage.py +0 -0
  58. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/lookthrough_bundle.py +0 -0
  59. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/shared.py +0 -0
  60. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/recipes/weighted_dividend_yield.py +0 -0
  61. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/vintage_artifacts.py +0 -0
  62. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/vintage_canonical.py +0 -0
  63. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/vintage_gates.py +0 -0
  64. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/vintage_plane.py +0 -0
  65. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/tushare/vintage_producer.py +0 -0
  66. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/__init__.py +0 -0
  67. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/endpoints.py +0 -0
  68. {ohmydata-0.2.0 → ohmydata-0.2.2}/src/ohmydata/providers/yfinance/errors.py +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 — 2026-09-06
4
+
5
+ - Enhanced `yfinance` fundamentals pipeline with institutional Forward P/E calibration and GAAP distortion detection:
6
+ - **Institutional FY1 Forward P/E Calibration**:
7
+ - Calibrated `forward_pe` anchored to current fiscal year (`0y`) sell-side consensus instead of out-year (+1y).
8
+ - Preserved raw provider-native values as `raw_forward_pe` and `raw_forward_eps` alongside `forward_pe_source` (`"FY1_CONSENSUS"` or `"RAW_FALLBACK"`).
9
+ - Implemented defensive price derivation and currency/share-class unit mismatch protection for ADRs (ratio guard between 0.25 and 3.0) to safely fall back to raw values.
10
+ - **GAAP vs. Non-GAAP Distortion Detection**:
11
+ - Integrated Non-GAAP core operating consensus from `info.get("epsCurrentYear")` (`eps_current_year`).
12
+ - Added algorithmic relative deviation calculation (`gaap_diff_pct = |eps_0y - eps_current_year| / min`) and distortion flagging (`has_gaap_distortion` when `gaap_diff_pct > 0.25`).
13
+ - Protects algorithmic screeners and downstream valuation pipelines from spin-off, M&A, and accounting windfall false-value traps (e.g. `GEV`).
14
+
15
+ ## 0.2.1 — 2026-09-04
16
+
17
+ - Upgraded official `yfinance` provider lock from `1.5.1` to `1.7.0` (`ohmydata[yfinance]`):
18
+ - Solved `curl_cffi >= 0.16` compatibility issues and improved session robustness.
19
+ - Runtime version assertion updated to `EXPECTED_YFINANCE_VERSION = "1.7.0"`.
20
+ - Built permanent, reusable **Zero-Drift Audit Engine** (`ohmydata.tools.drift_audit` and CLI command `omd audit-drift`):
21
+ - Exact calendar date-set alignment, missing/extra session detection, and column-by-column numeric delta auditing (`open`, `high`, `low`, `close`, `adj_close`, `volume`).
22
+ - Automated Markdown summary and JSON machine-readable audit receipt generation.
23
+ - Supports `--raw-only` for bit-exact unadjusted bar verification and configurable tolerances (`--abs-tolerance`, `--rel-tolerance`).
24
+ - Registered canonical **`r10a0` Multi-Asset ETF Benchmark Universe** (`ohmydata.tools.universes`):
25
+ - 13 unique US ETFs across `equity_risk`, `sector_cyclicals`, and `defensive` clusters (Cluster Variant v3) and macro regime states (`risk_on`, `risk_off`).
26
+ - Audited 10+ years of history (2015-01-01 to 2026-09-01, 38,116 daily bars): achieved **100% bit-exact zero-drift on raw OHLCV** (0 diffs across all columns).
27
+ - Established provider governance policy in `AGENTS.md`:
28
+ - Strict `repair=False, auto_adjust=False, actions=True, keepna=True` invariant for canonical ingestion and backtesting pipelines.
29
+ - Bounded domain QC anomaly checks in `quality.py` (`check_ohlc_anomalies`, `check_price_jump_anomalies`, `check_volume_anomalies`).
30
+ - Mandated `omd audit-drift` gate for all future provider upgrades.
31
+
3
32
  ## 0.2.0 — 2026-09-04
4
33
 
5
34
  - Added official `yfinance` market data provider locked against `yfinance==1.5.1` baseline (`ohmydata[yfinance]`):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ohmydata
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Financial and alternative market data ingestion SDK.
5
5
  Author: OMD contributors
6
6
  Requires-Python: <3.13,>=3.11
@@ -21,11 +21,13 @@ Requires-Dist: pandas<3.0,>=2.0; extra == 'vintage-plane'
21
21
  Requires-Dist: pyarrow<24.0,>=23.0.1; extra == 'vintage-plane'
22
22
  Provides-Extra: yfinance
23
23
  Requires-Dist: pandas<3.0,>=2.0; extra == 'yfinance'
24
- Requires-Dist: yfinance==1.5.1; extra == 'yfinance'
24
+ Requires-Dist: yfinance==1.7.0; extra == 'yfinance'
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # Oh My Data (OMD)
28
28
 
29
+ [English](README.md) | [中文说明](README_zh.md)
30
+
29
31
  `ohmydata` is an offline-first market-data ingestion SDK and CLI. Provider endpoint
30
32
  adapters accept already initialized official-compatible clients; credentials
31
33
  are never loaded by this library.
@@ -40,8 +42,9 @@ uv run python -c "import ohmydata; print(ohmydata.__version__)"
40
42
  ```
41
43
 
42
44
  The core has no runtime dependencies. Install `ohmydata[tushare]` for the
43
- Pandas-backed Tushare adapter, `ohmydata[sec-cli]` for the SEC N-PORT batch CLI,
44
- or `ohmydata[sec-financials]` for company 10-K/10-Q financial statements and Parquet
45
+ Pandas-backed Tushare adapter, `ohmydata[yfinance]` for US/global market data and
46
+ fundamentals, `ohmydata[sec-cli]` for the SEC N-PORT batch CLI, or
47
+ `ohmydata[sec-financials]` for company 10-K/10-Q financial statements and Parquet
45
48
  dataset writer. Provider tests use fake clients and never call a network.
46
49
 
47
50
  ## Core Architecture (offline & immutable)
@@ -503,6 +506,65 @@ preserve native line item labels and concepts (`concept`, `label`, `value_native
503
506
  beside standardized XBRL categories (`standard_concept`) for cross-company
504
507
  quantitative comparisons.
505
508
 
509
+ ### yfinance (US & Global Market Data, Fundamentals, and Zero-Drift Audit)
510
+
511
+ Install `ohmydata[yfinance]` to access normalized market data, valuation ratios,
512
+ and financial statements with strict version pinning (`yfinance==1.7.0`):
513
+
514
+ ```python
515
+ from ohmydata.providers.yfinance import (
516
+ YFinanceAdjustmentMode,
517
+ YFinanceBatchPolicy,
518
+ YFinanceClient,
519
+ YFinanceDailyBarsRequest,
520
+ YFinanceFundamentalsRequest,
521
+ YFinanceRepairPolicy,
522
+ )
523
+
524
+ client = YFinanceClient()
525
+
526
+ # 1. Fetch normalized daily bars (OHLCV) with repair isolation
527
+ bars_req = YFinanceDailyBarsRequest(
528
+ symbols=("SPY", "QQQ", "^VIX"),
529
+ start_date="2024-01-01",
530
+ end_date_exclusive="2024-02-01",
531
+ adjustment_mode=YFinanceAdjustmentMode.RAW_WITH_ADJ_CLOSE,
532
+ batch_policy=YFinanceBatchPolicy.STRICT,
533
+ repair_policy=YFinanceRepairPolicy.PER_SYMBOL,
534
+ )
535
+ bars_result = client.fetch_daily_bars(bars_req)
536
+ df = bars_result.dataframe
537
+
538
+ # 2. Fetch fundamentals with institutional FY1 Forward P/E calibration
539
+ fund_req = YFinanceFundamentalsRequest(
540
+ symbols=("NVDA", "GEV"),
541
+ include_financials=True,
542
+ include_valuation=True,
543
+ include_estimates=True,
544
+ )
545
+ fund_result = client.fetch_fundamentals(fund_req)
546
+
547
+ nvda = fund_result.records["NVDA"]
548
+ # Forward P/E is calibrated to current year consensus (FY1 0y.avg) rather than out-year (+1y)
549
+ print("NVDA Calibrated FPE:", nvda.valuation.forward_pe, nvda.valuation.forward_pe_source)
550
+ print("NVDA Raw Yahoo FPE:", nvda.valuation.raw_forward_pe)
551
+
552
+ gev = fund_result.records["GEV"]
553
+ # GAAP vs Non-GAAP accounting distortion detection (flags one-off windfalls > 25%)
554
+ if gev.estimates.has_gaap_distortion:
555
+ print(f"GEV GAAP distortion flagged! Gap: {gev.estimates.gaap_diff_pct * 100:.1f}%")
556
+ ```
557
+
558
+ #### Zero-Drift Audit CLI (`omd audit-drift`)
559
+
560
+ Audit 10+ years of historical data against the 13-ETF `r10a0` benchmark universe before
561
+ any provider upgrade:
562
+
563
+ ```bash
564
+ # Strict unadjusted market bar zero-drift gate (must be bit-exact 0.0 error)
565
+ uv run omd audit-drift --universe r10a0 --baseline-dir <old_version_dir> --target-dir <new_version_dir> --raw-only
566
+ ```
567
+
506
568
  ## Dataframe Adapters (Polars & Pandas)
507
569
 
508
570
  The optional `ohmydata[polars]` extra provides explicit, eager representation
@@ -1,5 +1,7 @@
1
1
  # Oh My Data (OMD)
2
2
 
3
+ [English](README.md) | [中文说明](README_zh.md)
4
+
3
5
  `ohmydata` is an offline-first market-data ingestion SDK and CLI. Provider endpoint
4
6
  adapters accept already initialized official-compatible clients; credentials
5
7
  are never loaded by this library.
@@ -14,8 +16,9 @@ uv run python -c "import ohmydata; print(ohmydata.__version__)"
14
16
  ```
15
17
 
16
18
  The core has no runtime dependencies. Install `ohmydata[tushare]` for the
17
- Pandas-backed Tushare adapter, `ohmydata[sec-cli]` for the SEC N-PORT batch CLI,
18
- or `ohmydata[sec-financials]` for company 10-K/10-Q financial statements and Parquet
19
+ Pandas-backed Tushare adapter, `ohmydata[yfinance]` for US/global market data and
20
+ fundamentals, `ohmydata[sec-cli]` for the SEC N-PORT batch CLI, or
21
+ `ohmydata[sec-financials]` for company 10-K/10-Q financial statements and Parquet
19
22
  dataset writer. Provider tests use fake clients and never call a network.
20
23
 
21
24
  ## Core Architecture (offline & immutable)
@@ -477,6 +480,65 @@ preserve native line item labels and concepts (`concept`, `label`, `value_native
477
480
  beside standardized XBRL categories (`standard_concept`) for cross-company
478
481
  quantitative comparisons.
479
482
 
483
+ ### yfinance (US & Global Market Data, Fundamentals, and Zero-Drift Audit)
484
+
485
+ Install `ohmydata[yfinance]` to access normalized market data, valuation ratios,
486
+ and financial statements with strict version pinning (`yfinance==1.7.0`):
487
+
488
+ ```python
489
+ from ohmydata.providers.yfinance import (
490
+ YFinanceAdjustmentMode,
491
+ YFinanceBatchPolicy,
492
+ YFinanceClient,
493
+ YFinanceDailyBarsRequest,
494
+ YFinanceFundamentalsRequest,
495
+ YFinanceRepairPolicy,
496
+ )
497
+
498
+ client = YFinanceClient()
499
+
500
+ # 1. Fetch normalized daily bars (OHLCV) with repair isolation
501
+ bars_req = YFinanceDailyBarsRequest(
502
+ symbols=("SPY", "QQQ", "^VIX"),
503
+ start_date="2024-01-01",
504
+ end_date_exclusive="2024-02-01",
505
+ adjustment_mode=YFinanceAdjustmentMode.RAW_WITH_ADJ_CLOSE,
506
+ batch_policy=YFinanceBatchPolicy.STRICT,
507
+ repair_policy=YFinanceRepairPolicy.PER_SYMBOL,
508
+ )
509
+ bars_result = client.fetch_daily_bars(bars_req)
510
+ df = bars_result.dataframe
511
+
512
+ # 2. Fetch fundamentals with institutional FY1 Forward P/E calibration
513
+ fund_req = YFinanceFundamentalsRequest(
514
+ symbols=("NVDA", "GEV"),
515
+ include_financials=True,
516
+ include_valuation=True,
517
+ include_estimates=True,
518
+ )
519
+ fund_result = client.fetch_fundamentals(fund_req)
520
+
521
+ nvda = fund_result.records["NVDA"]
522
+ # Forward P/E is calibrated to current year consensus (FY1 0y.avg) rather than out-year (+1y)
523
+ print("NVDA Calibrated FPE:", nvda.valuation.forward_pe, nvda.valuation.forward_pe_source)
524
+ print("NVDA Raw Yahoo FPE:", nvda.valuation.raw_forward_pe)
525
+
526
+ gev = fund_result.records["GEV"]
527
+ # GAAP vs Non-GAAP accounting distortion detection (flags one-off windfalls > 25%)
528
+ if gev.estimates.has_gaap_distortion:
529
+ print(f"GEV GAAP distortion flagged! Gap: {gev.estimates.gaap_diff_pct * 100:.1f}%")
530
+ ```
531
+
532
+ #### Zero-Drift Audit CLI (`omd audit-drift`)
533
+
534
+ Audit 10+ years of historical data against the 13-ETF `r10a0` benchmark universe before
535
+ any provider upgrade:
536
+
537
+ ```bash
538
+ # Strict unadjusted market bar zero-drift gate (must be bit-exact 0.0 error)
539
+ uv run omd audit-drift --universe r10a0 --baseline-dir <old_version_dir> --target-dir <new_version_dir> --raw-only
540
+ ```
541
+
480
542
  ## Dataframe Adapters (Polars & Pandas)
481
543
 
482
544
  The optional `ohmydata[polars]` extra provides explicit, eager representation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ohmydata"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Financial and alternative market data ingestion SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.13"
@@ -16,7 +16,7 @@ omd = "ohmydata.cli:main"
16
16
 
17
17
  [project.optional-dependencies]
18
18
  tushare = ["pandas>=2.0,<3.0", "tushare>=1.4,<2.0"]
19
- yfinance = ["pandas>=2.0,<3.0", "yfinance==1.5.1"]
19
+ yfinance = ["pandas>=2.0,<3.0", "yfinance==1.7.0"]
20
20
  polars = [
21
21
  "pandas>=2.0,<3.0",
22
22
  "polars>=1.38.1,<2.0",
@@ -1,5 +1,5 @@
1
1
  """Provisional offline-only Oh My Data package scaffold."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -7,18 +7,22 @@ import sys
7
7
 
8
8
  from .core.errors import OhMyDataError
9
9
  from .providers.sec.cli import add_nport_commands, run
10
+ from .tools.cli import add_audit_commands, run_audit
10
11
 
11
12
 
12
13
  def main(argv: list[str] | None = None) -> int:
13
14
  parser = argparse.ArgumentParser(prog="omd")
14
15
  commands = parser.add_subparsers(dest="command", required=True)
15
16
  add_nport_commands(commands)
17
+ add_audit_commands(commands)
16
18
  args = parser.parse_args(argv)
17
19
  try:
18
20
  if args.command == "sec":
19
21
  return run(args)
22
+ if args.command == "audit-drift":
23
+ return run_audit(args)
20
24
  except OhMyDataError:
21
- print("SEC operation failed", file=sys.stderr)
25
+ print("OMD operation failed", file=sys.stderr)
22
26
  return 2
23
27
  except ValueError as exc:
24
28
  print(str(exc), file=sys.stderr)
@@ -0,0 +1,199 @@
1
+ """Internal helper functions for parsing financial statements, valuation, and analyst estimates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+
11
+ def _safe_float(val: Any) -> float | None:
12
+ """Convert arbitrary numeric value to float or None."""
13
+ try:
14
+ if val is None or pd.isna(val):
15
+ return None
16
+ f = float(val)
17
+ return f if pd.notna(f) else None
18
+ except (ValueError, TypeError):
19
+ return None
20
+
21
+
22
+ def extract_quarterly_pair(series: pd.Series | None) -> tuple[float | None, float | None]:
23
+ """Return (latest_quarter, same_quarter_last_year) from a quarterly series."""
24
+ if series is None or series.empty:
25
+ return (None, None)
26
+ cleaned = pd.to_numeric(series, errors="coerce").dropna()
27
+ if len(cleaned) < 5:
28
+ # If at least 1 quarter exists, return latest, but prev_year is None
29
+ latest = _safe_float(cleaned.iloc[0]) if len(cleaned) >= 1 else None
30
+ return (latest, None)
31
+ return (_safe_float(cleaned.iloc[0]), _safe_float(cleaned.iloc[4]))
32
+
33
+
34
+ def extract_metric_pair(
35
+ stmt: pd.DataFrame | None, candidate_keys: list[str]
36
+ ) -> tuple[float | None, float | None]:
37
+ """Search for metric rows across aliases and extract (latest, prev_year)."""
38
+ if stmt is None or stmt.empty:
39
+ return (None, None)
40
+ # Search index names ignoring case and whitespace
41
+ norm_index = {str(idx).strip().lower(): idx for idx in stmt.index}
42
+ for candidate in candidate_keys:
43
+ cand_key = candidate.strip().lower()
44
+ if cand_key in norm_index:
45
+ row_key = norm_index[cand_key]
46
+ return extract_quarterly_pair(stmt.loc[row_key])
47
+ return (None, None)
48
+
49
+
50
+ def extract_report_date(
51
+ info: dict[str, Any] | None, income_stmt: pd.DataFrame | None
52
+ ) -> datetime.date | None:
53
+ """Extract report date from mostRecentQuarter or statement columns, rejecting epoch 0."""
54
+ info = info or {}
55
+ candidates: list[datetime.date] = []
56
+
57
+ mrq = info.get("mostRecentQuarter")
58
+ if mrq is not None:
59
+ try:
60
+ if isinstance(mrq, (int, float)) and mrq > 0:
61
+ dt = datetime.datetime.fromtimestamp(mrq, tz=datetime.UTC).date()
62
+ candidates.append(dt)
63
+ elif isinstance(mrq, str):
64
+ dt = pd.to_datetime(mrq).date()
65
+ candidates.append(dt)
66
+ except (ValueError, TypeError, OSError):
67
+ pass
68
+
69
+ if income_stmt is not None and not income_stmt.empty:
70
+ for col in income_stmt.columns:
71
+ try:
72
+ dt = pd.to_datetime(col).date()
73
+ candidates.append(dt)
74
+ break
75
+ except (ValueError, TypeError, OSError):
76
+ pass
77
+
78
+ for dt in candidates:
79
+ # Must be after 1990-01-01 and not epoch 1970-01-01
80
+ if dt.year >= 1990:
81
+ return dt
82
+
83
+ return None
84
+
85
+
86
+ def extract_estimates_horizons(
87
+ df_est: pd.DataFrame | None,
88
+ ) -> tuple[float | None, float | None, float | None, float | None]:
89
+ """Extract (current_q, next_q, current_y, next_y) from estimate DataFrame."""
90
+ if df_est is None or df_est.empty:
91
+ return (None, None, None, None)
92
+
93
+ # Clean index
94
+ df_clean = df_est.copy()
95
+ df_clean.index = [str(idx).strip().lower() for idx in df_clean.index]
96
+
97
+ # Find avg / mean column
98
+ target_col = None
99
+ for col in df_clean.columns:
100
+ c_low = str(col).lower()
101
+ if "avg" in c_low or "mean" in c_low:
102
+ target_col = col
103
+ break
104
+ if target_col is None and len(df_clean.columns) > 0:
105
+ target_col = df_clean.columns[0]
106
+
107
+ if target_col is None:
108
+ return (None, None, None, None)
109
+
110
+ series = pd.to_numeric(df_clean[target_col], errors="coerce")
111
+
112
+ def _get_val(keys: list[str]) -> float | None:
113
+ for k in keys:
114
+ if k in series.index:
115
+ val = series.loc[k]
116
+ return _safe_float(val)
117
+ return None
118
+
119
+ cq = _get_val(["0q", "current quarter", "currentq"])
120
+ nq = _get_val(["+1q", "next quarter", "nextq", "1q"])
121
+ cy = _get_val(["0y", "current year", "currenty"])
122
+ ny = _get_val(["+1y", "next year", "nexty", "1y"])
123
+ return (cq, nq, cy, ny)
124
+
125
+
126
+ def derive_implied_price(
127
+ raw_forward_pe: float | None,
128
+ raw_forward_eps: float | None,
129
+ trailing_pe: float | None,
130
+ deps_latest: float | None,
131
+ market_cap: float | None,
132
+ shares_outstanding: float | None,
133
+ ) -> float | None:
134
+ """Derive implied market price defensively across priority cascade."""
135
+ if (
136
+ raw_forward_pe is not None
137
+ and raw_forward_eps is not None
138
+ and raw_forward_pe > 0
139
+ and raw_forward_eps > 0
140
+ ):
141
+ return raw_forward_pe * raw_forward_eps
142
+ if trailing_pe is not None and deps_latest is not None and trailing_pe > 0 and deps_latest > 0:
143
+ return trailing_pe * deps_latest
144
+ if market_cap is not None and shares_outstanding is not None and shares_outstanding > 0:
145
+ return market_cap / shares_outstanding
146
+ return None
147
+
148
+
149
+ def calibrate_forward_pe(
150
+ implied_price: float | None,
151
+ raw_forward_pe: float | None,
152
+ raw_forward_eps: float | None,
153
+ eps_0y: float | None,
154
+ ) -> tuple[float | None, float | None, str | None]:
155
+ """Calibrate institutional FY1 Forward P/E against sell-side consensus with ADR protection.
156
+
157
+ Returns:
158
+ tuple of (calibrated_forward_pe, calibrated_forward_eps, forward_pe_source)
159
+ """
160
+ calibrated_fpe = raw_forward_pe
161
+ calibrated_feps = raw_forward_eps
162
+ fpe_source = "RAW_FALLBACK" if raw_forward_pe is not None else None
163
+
164
+ if implied_price is not None and eps_0y is not None and eps_0y > 0:
165
+ is_mismatched = False
166
+ if raw_forward_eps is not None and raw_forward_eps > 0:
167
+ ratio = eps_0y / raw_forward_eps
168
+ if ratio > 3.0 or ratio < 0.25:
169
+ is_mismatched = True
170
+
171
+ if not is_mismatched:
172
+ calibrated_fpe = round(implied_price / eps_0y, 4)
173
+ calibrated_feps = round(eps_0y, 4)
174
+ fpe_source = "FY1_CONSENSUS"
175
+
176
+ return (calibrated_fpe, calibrated_feps, fpe_source)
177
+
178
+
179
+ def detect_gaap_distortion(
180
+ eps_0y: float | None,
181
+ eps_current_year: float | None,
182
+ ) -> tuple[float | None, bool]:
183
+ """Detect relative GAAP vs Non-GAAP consensus divergence (e.g. spin-offs, M&A windfalls).
184
+
185
+ Returns:
186
+ tuple of (gaap_diff_pct, has_gaap_distortion)
187
+ """
188
+ gaap_diff_pct = None
189
+ has_gaap_distortion = False
190
+
191
+ if eps_0y is not None and eps_current_year is not None and eps_0y > 0 and eps_current_year > 0:
192
+ denom = min(eps_0y, eps_current_year)
193
+ if denom > 0:
194
+ gap = abs(eps_0y - eps_current_year) / denom
195
+ gaap_diff_pct = round(gap, 4)
196
+ if gap > 0.25:
197
+ has_gaap_distortion = True
198
+
199
+ return (gaap_diff_pct, has_gaap_distortion)
@@ -37,11 +37,11 @@ from .fundamentals import (
37
37
  )
38
38
  from .quality import evaluate_symbol_outcomes, validate_daily_bars_dataframe
39
39
 
40
- EXPECTED_YFINANCE_VERSION = "1.5.1"
40
+ EXPECTED_YFINANCE_VERSION = "1.7.0"
41
41
 
42
42
 
43
43
  def assert_yfinance_version(module: Any = None) -> None:
44
- """Assert that yfinance is installed and matches the reviewed 1.5.1 baseline."""
44
+ """Assert that yfinance is installed and matches the reviewed 1.7.0 baseline."""
45
45
  if module is None:
46
46
  try:
47
47
  module = importlib.import_module("yfinance")
@@ -54,7 +54,7 @@ def assert_yfinance_version(module: Any = None) -> None:
54
54
  if actual_version != EXPECTED_YFINANCE_VERSION:
55
55
  raise YFinanceVersionMismatchError(
56
56
  f"yfinance version mismatch: expected {EXPECTED_YFINANCE_VERSION!r}, got {actual_version!r}. "
57
- "v0.2.0 is locked against yfinance==1.5.1."
57
+ f"OMD is locked against yfinance=={EXPECTED_YFINANCE_VERSION}."
58
58
  )
59
59
 
60
60