diff --git a/CONTEXT.md b/CONTEXT.md index 6207b42..e2af9a6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,46 @@ _Avoid_: penalize, down-rank, soft filter Hard exclusion for fraud or bankruptcy/distress risk before any score. Spec: `spec/features/008-permanent-loss-filter/spec.md`. _Avoid_: risk score, stop-loss, drawdown rule +**Confirmed fraud signals**: +Structural or event-based fraud evidence from filings or regulators, such as recent restatements, repeated auditor changes, late-filer notices, or SEC enforcement actions. These are distinct from statistical manipulation models. Spec: `spec/features/008-permanent-loss-filter/spec.md`. +_Avoid_: using Beneish M-Score as if it were confirmed fraud + +**Manipulation risk**: +Statistical risk that reported earnings or fundamentals are being managed or distorted. In phase 2, this starts with **Beneish M-Score** and may later include other forensic models. It is not the same as confirmed fraud. Spec: `spec/features/008-permanent-loss-filter/spec.md`. +_Avoid_: treating manipulation risk as proof of fraud + +**Beneish unavailable**: +A company at a run date where **Beneish M-Score** cannot be computed (missing multi-period inputs or invalid derived ratios). **Fail-open** while fundamentals coverage is incomplete: route to **review queue** and allow pass through the manipulation screen. **Fail-closed** once multi-period ETL coverage is sufficient (default **≥95%** of the universe scorable on a run date): hard-exclude names that cannot be scored. Threshold versioned in `config/permanent_loss/`. +_Avoid_: treating missing Beneish as confirmed clean, silent pass without review queue + +**Beneish M-Score**: +Classic 8-variable forensic accounting model that estimates **manipulation risk** from multi-period fundamentals. Gray/Carlisle *Quantitative Value* Ch. 3 labels the same model **PROBM** (probability of manipulation); in this repo the canonical name is **Beneish M-Score** (`beneish_score.py`). Coefficients versioned under `config/permanent_loss/beneish_v1.yaml`. In QV, the worst tail is hard-excluded via the **forensic bottom-percentile gate**, not an absolute academic cutoff. +_Avoid_: treating PROBM as a separate production model from Beneish, calling it confirmed fraud + +**Forensic bottom-percentile gate**: +Cross-sectional hard exclusion of the worst manipulation-risk tail among forensic survivors on a run date. Phase 2a: bottom 5% by **Beneish M-Score** (`FRD_BENEISH_BOTTOM_PCT`, `forensic_gate_v1`). No absolute Beneish threshold in the QV production path. +_Avoid_: M-Score > −1.78 as the production cutoff, time-series percentile across history + +**Scaled total accruals (STA)**: +Accrual-flow manipulation signal from *Quantitative Value* Ch. 3: earnings accruals scaled by total assets (Sloan-style). Higher STA → higher manipulation risk. **Backlog (phase 2b)**; pairs with **SNOA** in **COMBOACCRUAL**. +_Avoid_: conflating STA with Beneish M-Score + +**Scaled net operating assets (SNOA)**: +Accrual-stock manipulation signal from *Quantitative Value* Ch. 3: bloated operating net assets scaled by total assets (Hirshleifer et al.). Higher SNOA → higher manipulation risk. **Backlog (phase 2b)**. +_Avoid_: treating SNOA as a distress/bankruptcy model + +**COMBOACCRUAL**: +Average cross-sectional percentile of **STA** and **SNOA** on a run date. In the book, the worst 5% by COMBOACCRUAL are excluded alongside PMAN (Beneish) and PFD (distress). **Backlog (phase 2b)** for SmartWealthAI. +_Avoid_: averaging raw STA/SNOA values without cross-sectional percentiles + +**Forensic model fusion (phase 2b)**: +Post–phase 2a approach: first add book-faithful **separate bottom-5% gates** per forensic measure (COMBOACCRUAL, Beneish/PMAN, PFD or aligned distress model); only then experiment with **embedding / ML fusion** if backtests show incremental value without sacrificing explainability. +_Avoid_: jumping to ML fusion before individual gates are baseline-tested + +**Probability of financial distress (PFD)**: +Campbell et al. logit model from *Quantitative Value* Ch. 3; estimates 12-month distress risk from market and balance-sheet inputs. **Backlog (phase 2b):** bottom-5% gate alongside existing **`BK_*` hard rules** (both kept; overlap measured in backtest before trimming). +_Avoid_: conflating PFD with manipulation risk (STA/SNOA/Beneish) or treating PFD as confirmed fraud + **Review queue**: Rows flagged for human review—invalid denominators, missing inputs, negative EBIT for EY, etc.—stored under `curated/issues/`. _Avoid_: silent drop, auto-fix without audit diff --git a/config/permanent_loss/beneish_v1.yaml b/config/permanent_loss/beneish_v1.yaml new file mode 100644 index 0000000..e870ab7 --- /dev/null +++ b/config/permanent_loss/beneish_v1.yaml @@ -0,0 +1,14 @@ +version: beneish_v1 + +# Classic 8-variable Beneish M-Score (Beneish 1999). Coefficients are versioned +# so book-specific variants (Gray/Carlisle) can swap this file without code changes. +intercept: -4.84 +coefficients: + dsri: 0.92 + gmi: 0.528 + aqi: 0.404 + sgi: 0.892 + depi: 0.115 + sgai: -0.172 + tata: 4.679 + lvgi: -0.327 diff --git a/config/permanent_loss/distress_rules_v1.yaml b/config/permanent_loss/distress_rules_v1.yaml new file mode 100644 index 0000000..c41204a --- /dev/null +++ b/config/permanent_loss/distress_rules_v1.yaml @@ -0,0 +1,15 @@ +version: distress_rules_v1 + +rules: + BK_ALTMAN_Z: + threshold: 1.81 + compare: lt + BK_INT_COVERAGE: + threshold: 1.0 + compare: lt + BK_NETDEBT_EBITDA: + net_debt_ebitda_threshold: 7.0 + require_negative_fcf: true + BK_NEGATIVE_EQUITY: + threshold: 0.0 + compare: lt diff --git a/config/permanent_loss/forensic_gate_v1.yaml b/config/permanent_loss/forensic_gate_v1.yaml new file mode 100644 index 0000000..6673c58 --- /dev/null +++ b/config/permanent_loss/forensic_gate_v1.yaml @@ -0,0 +1,4 @@ +version: forensic_gate_v1 + +bottom_percentile: 0.05 +rule_id: FRD_BENEISH_BOTTOM_PCT diff --git a/data/reference/ticker_cik_overrides.csv b/data/reference/ticker_cik_overrides.csv index 3855f5a..8d96aac 100644 --- a/data/reference/ticker_cik_overrides.csv +++ b/data/reference/ticker_cik_overrides.csv @@ -3,3 +3,5 @@ LEHMQ,806085,LEH,Lehman Brothers Holdings (bankruptcy) ENRNQ,1024401,ENRN,Enron Corp (bankruptcy) LEH,806085,LEH,Lehman Brothers legacy symbol ENRN,1024401,ENRN,Enron legacy symbol +MCWE,723527,MCWE,WorldCom legacy symbol +MCWEQ,723527,MCWE,WorldCom bankruptcy symbol diff --git a/pyproject.toml b/pyproject.toml index 53da745..a9dfad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ download-price-history = "smartwealthai.download_price_history:main" compute-metrics = "smartwealthai.compute_metrics:main" run-demo-pipeline = "smartwealthai.run_demo_pipeline:main" score-universe = "smartwealthai.score_universe:main" +run-forensic-evaluator = "smartwealthai.run_forensic_evaluator:main" run-dashboard = "smartwealthai.run_dashboard:main" generate-simfin-industry-exclusions = "smartwealthai.generate_simfin_industry_exclusions:main" diff --git a/spec/backlog/backlog.md b/spec/backlog/backlog.md index f6d6b40..191ba35 100644 --- a/spec/backlog/backlog.md +++ b/spec/backlog/backlog.md @@ -10,6 +10,9 @@ Informal ideas from the product owner. Each item should eventually map to one or | 4 | Diversification analysis beyond sector labels (clustering in growth vs contraction regimes) | [corroborative-signals.md](../004-corroborative-signals/spec.md), [dashboard-reporting.md](../005-dashboard-reporting/spec.md) — may need a future spec | | 5 | Detect overvalued positions where selling or trimming may make sense | [sell-watch.md](../010-sell-watch/spec.md), [cheap-stocks.md](../003-cheap-stocks/spec.md) | | 6 | Rebalancing guidance (owner questions whether rebalance fits buy-cheap / sell-dear philosophy) | [mission.md](../constitution/mission.md) (annual rebalance decision), [broker-execution.md](../002-broker-execution/spec.md) | +| 7 | **Forensic model zoo (post–phase 2a), in order:** (1) STA, SNOA, COMBOACCRUAL; (2) **PFD** bottom-5% gate alongside `BK_*`; (3) EDGAR **confirmed fraud signals**; (4) embedding/ML fusion after gates are baseline-tested | [008-permanent-loss-filter](../features/008-permanent-loss-filter/spec.md), [013-quantitative-value](../features/013-quantitative-value/spec.md) | +| 8 | **Confirmed fraud signals from EDGAR** (restatements, auditor change, late filer, SEC enforcement) — wire when SEC ETL unfreezes | [008-permanent-loss-filter](../features/008-permanent-loss-filter/spec.md), [006-etl-data-lake](../features/006-etl-data-lake/spec.md) | +| 9 | **Embedding / ML fusion** of forensic model outputs (mathematical scores → combined manipulation-risk signal) — explore **after** separate bottom-5% gates (COMBOACCRUAL, PMAN, PFD) are baseline-tested in backtest | [008-permanent-loss-filter](../features/008-permanent-loss-filter/spec.md) | ## Priority diff --git a/spec/features/008-permanent-loss-filter/spec.md b/spec/features/008-permanent-loss-filter/spec.md index 821b89f..79c3d02 100644 --- a/spec/features/008-permanent-loss-filter/spec.md +++ b/spec/features/008-permanent-loss-filter/spec.md @@ -2,7 +2,32 @@ ## Implementation status -**Deferred** for the June 30 demo slice ([ADR-0002](../../adr/0002-june-demo-scope-cut.md)). Spec remains the target for phase 2. +**done** — phase 2a forensic evaluator ([#89](https://github.com/JLaborda/SmartWealthAI/issues/89)). June 30 demo slice remains without this stage ([ADR-0002](../../adr/0002-june-demo-scope-cut.md)). + +### Code paths + +| Module | Role | +| --- | --- | +| `src/smartwealthai/distress_rules.py` | Bankruptcy / distress hard rules (`BK_*`) | +| `src/smartwealthai/beneish_score.py` | Beneish M-Score from multi-period PIT history | +| `src/smartwealthai/forensic_percentile_gate.py` | QVAL-style bottom-5% cross-sectional gate | +| `src/smartwealthai/forensic_evaluator.py` | Orchestrator → exclusions + review queue | +| `src/smartwealthai/run_forensic_evaluator.py` | CLI: `run-forensic-evaluator` | + +| Config | Role | +| --- | --- | +| `config/permanent_loss/distress_rules_v1.yaml` | Distress thresholds | +| `config/permanent_loss/beneish_v1.yaml` | Beneish coefficients (swap for book variants) | +| `config/permanent_loss/forensic_gate_v1.yaml` | Bottom-percentile gate | + +| Tests | Role | +| --- | --- | +| `tests/test_distress_rules.py` | Distress rule unit tests | +| `tests/test_beneish_score.py` | Beneish M-Score unit tests | +| `tests/test_forensic_percentile_gate.py` | Bottom-5% gate | +| `tests/test_forensic_evaluator.py` | Orchestrator + CLI | +| `tests/test_permanent_loss_regression.py` | Enron / Lehman / WorldCom CI regression | +| `tests/fixtures/permanent_loss/cases.json` | Regression fixture manifest | ## Objective @@ -16,13 +41,19 @@ Identify companies in the investable universe with elevated risk of permanent ca - Every exclusion records the triggered rule, the inputs that fired it, and the `as_of_date`. - Regression test in CI: the bankruptcy subfilter must flag Enron, Lehman, and WorldCom on the dates each company was already in clear distress (e.g., Enron Q3 2001 10-Q, Lehman Q2 2008 10-Q, WorldCom Q1 2002 10-Q). If any of these stops being flagged, the CI build fails. +## Phase 2a extensions (implemented) + +- **Beneish M-Score** (`beneish_v1`): computed from multi-period PIT fundamentals; coefficients versioned under `config/permanent_loss/beneish_v1.yaml` so Gray/Carlisle book variants can swap config without code changes. +- **QVAL-style bottom-5% gate** (`FRD_BENEISH_BOTTOM_PCT`): cross-sectional hard exclusion on forensic survivors after distress rules. + ## Out of MVP scope -- Manipulation / accruals models (Beneish M-score, accruals-based scores). Deferred. - Machine learning fraud detection. +- **Phase 2b forensic extensions (backlog):** STA, SNOA, COMBOACCRUAL (Gray/Carlisle Ch. 3 accrual screens); additional manipulation models (e.g. Dechow); embedding/ML fusion of forensic scores; EDGAR **confirmed fraud signals** when SEC ETL is wired. Phase 2a ships **Beneish M-Score + bottom-5% gate only**. - LLM-driven qualitative analysis of filings (handled later by `unstructured-financial-data`). - Sector-specific distress models (banks, insurers, REITs and utilities are already excluded upstream by `universe-construction`). - `Penalize` and `unknown` states. Only `pass` and `exclude` for the MVP. +- MLflow metrics for permanent-loss runs (deferred; QV funnel [#91](https://github.com/JLaborda/SmartWealthAI/issues/91) may add stage logging). ## Inputs @@ -40,8 +71,9 @@ Identify companies in the investable universe with elevated risk of permanent ca | Output | Path / target | | --- | --- | | Exclusion table | `curated/permanent_loss/run_date=/exclusions.parquet` with columns `cik, ticker, subfilter, rule_id, rule_version, triggered_value, threshold, as_of_date, explanation` | +| Review queue | `curated/issues/run_date=/permanent_loss.parquet` for missing inputs / unavailable rules | | Filter status | `pass` or `exclude` per `(cik, as_of_date)` | -| Logged metrics (MLflow) | Number of evaluations, number of exclusions per subfilter, list of newly excluded companies | +| Logged metrics (MLflow) | Number of evaluations, number of exclusions per subfilter, list of newly excluded companies (deferred) | ## Bankruptcy / distress subfilter (MVP rules) @@ -68,8 +100,9 @@ The MVP fraud signals are intentionally narrow. They detect structural / account | `FRD_AUDITOR_CHANGE_REPEATED` | Auditor change in 2 of the last 3 fiscal years | `>= 2` changes | | `FRD_REGULATORY_ACTION` | Open SEC enforcement action against the issuer | `True` | | `FRD_LATE_FILER` | Filed `NT 10-K` or `NT 10-Q` (late filing notification) in last 12 months | `>= 1` filing | +| `FRD_BENEISH_BOTTOM_PCT` | Beneish M-Score in bottom 5% cross-sectionally among distress survivors | bottom 5% | -A company is excluded if **any** rule fires. Rules that depend on data not yet available in the MVP (`FRD_REGULATORY_ACTION`, derived from EDGAR enforcement feeds) are coded but tolerated as `unavailable` until the data is wired. Their absence is logged. +A company is excluded if **any** rule fires. Rules that depend on data not yet available in the MVP (`FRD_*` EDGAR rules) are coded but tolerated as `unavailable` until the data is wired. Their absence is logged. ## Mermaid diagram @@ -83,7 +116,9 @@ flowchart TD Fraud --> Decision Decision -->|Yes| Exclude["Exclude (hard)"] - Decision -->|No| Pass["Pass to scoring modules"] + Decision -->|No| Beneish["Beneish M-Score cross-section"] + Beneish --> Gate["Bottom 5% hard exclude"] + Gate --> Pass["Pass to scoring modules"] Exclude --> Output["curated/permanent_loss exclusions.parquet"] Output --> MLflow["MLflow metrics + artifact"] @@ -101,21 +136,29 @@ flowchart TD 2. Join with the latest PIT fundamentals (using `as_of_date <= run_date`). 3. Compute each bankruptcy and fraud rule. Rules with missing required inputs are recorded as `unavailable` and the row is sent to the review queue (not auto-excluded). 4. If any rule fires, mark the company as `exclude` with the rule id, threshold, and the values that triggered it. -5. Write the exclusion parquet and log MLflow metrics. -6. Hand the passing set of `(cik, ticker)` to the scoring modules. +5. On survivors, compute Beneish M-Score and apply the bottom-5% cross-sectional gate. +6. Write the exclusion parquet and log MLflow metrics (MLflow deferred). +7. Hand the passing set of `(cik, ticker)` to the scoring modules. ## Acceptance criteria -- The module is a pure function of curated parquet + reference rules: same inputs produce byte-identical output (verifiable by hash). -- The regression CI test for Enron, Lehman, and WorldCom blocks the build if any of the three stops being flagged. -- Every excluded row carries the `rule_id`, `rule_version`, `triggered_value`, and `threshold`. -- Rule definitions live in code, but thresholds live in a YAML config under `config/permanent_loss/` so they can be tuned by backtests without code changes. -- The filter never queries network resources. -- Companies with `unavailable` rule outputs do not pass silently: they enter the review queue. -- Each MLflow run for the permanent loss filter logs the count of exclusions per rule. +- [x] The module is a pure function of curated parquet + reference rules: same inputs produce byte-identical output (verifiable by hash). +- [x] The regression CI test for Enron, Lehman, and WorldCom blocks the build if any of the three stops being flagged. +- [x] Every excluded row carries the `rule_id`, `rule_version`, `triggered_value`, and `threshold`. +- [x] Rule definitions live in code, but thresholds live in a YAML config under `config/permanent_loss/` so they can be tuned by backtests without code changes. +- [x] Beneish M-Score computed from multi-period PIT inputs with versioned config (`beneish_v1`). +- [x] Bottom-5% forensic percentile gate excludes names per QVAL process. +- [x] The filter never queries network resources. +- [x] Companies with `unavailable` rule outputs do not pass silently: they enter the review queue. +- [ ] Each MLflow run for the permanent loss filter logs the count of exclusions per rule (deferred). ## Open questions +- **Closed:** When Beneish M-Score is unavailable, **fail-open** (review queue + pass) until multi-period ETL ([#87](https://github.com/JLaborda/SmartWealthAI/issues/87)) reaches **≥95%** universe scorable coverage on a run date; then switch to **fail-closed** (hard exclude). Threshold configurable in `config/permanent_loss/`. +- **Closed:** Phase 2a forensic manipulation screen = **Beneish M-Score + bottom-5% gate only** (`spec/backlog/backlog.md` for 2b items). +- **Closed:** Phase 2b forensic extensions follow a **hybrid path**: separate bottom-5% gates first (COMBOACCRUAL, PMAN/Beneish, PFD); embedding/ML fusion only after backtest proves incremental value. +- **Closed:** Phase 2b distress = add **PFD** bottom-5% gate **and keep** existing **`BK_*` hard rules**; measure overlap in backtest before trimming either layer. +- **Closed:** Phase 2b implementation order: **(1) STA/SNOA/COMBOACCRUAL → (2) PFD → (3) EDGAR confirmed fraud → (4) embedding/ML fusion** (`spec/backlog/backlog.md`). - Should `BK_NETDEBT_EBITDA` be sector-relative even though banks / insurers / utilities are excluded? Recommendation: keep it absolute for the MVP; revisit when those sectors are reintroduced. - Threshold for `BK_NETDEBT_EBITDA` should probably be revisited per backtest; the initial 7.0 is a placeholder. - Where does the auditor-change history come from? Recommendation: parse `acceptedAccountingFirm` from EDGAR if available; otherwise wait for `unstructured-financial-data` to provide it. diff --git a/src/smartwealthai/beneish_score.py b/src/smartwealthai/beneish_score.py new file mode 100644 index 0000000..5afe255 --- /dev/null +++ b/src/smartwealthai/beneish_score.py @@ -0,0 +1,184 @@ +"""Beneish M-Score computation from multi-period PIT fundamentals.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from smartwealthai.permanent_loss_config import load_beneish_config + +BENEISH_REQUIRED_FIELDS: tuple[str, ...] = ( + "accounts_receivable", + "revenue", + "cost_of_revenue", + "total_assets", + "ppe_net", + "depreciation_amortization", + "sga_expense", + "net_income", + "operating_cash_flow", + "current_assets", + "current_liabilities", + "long_term_debt", +) + + +@dataclass(frozen=True) +class BeneishResult: + """Outcome of a Beneish M-Score calculation.""" + + m_score: float | None + rule_version: str + missing_fields: tuple[str, ...] + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + try: + if value != value: + return None + except TypeError: + return None + return float(value) + + +def _row_value(row: pd.Series, field: str) -> float | None: + return _optional_float(row.get(field)) + + +def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None: + if numerator is None or denominator is None or denominator == 0: + return None + return numerator / denominator + + +def _gross_margin(revenue: float | None, cost_of_revenue: float | None) -> float | None: + if revenue is None or cost_of_revenue is None or revenue == 0: + return None + return (revenue - cost_of_revenue) / revenue + + +def _asset_quality_index( + *, + current_assets: float | None, + ppe_net: float | None, + total_assets: float | None, +) -> float | None: + if current_assets is None or ppe_net is None or total_assets is None or total_assets == 0: + return None + return 1.0 - (current_assets + ppe_net) / total_assets + + +def _depi(depreciation: float | None, ppe_net: float | None) -> float | None: + if depreciation is None or ppe_net is None: + return None + denom = depreciation + ppe_net + if denom == 0: + return None + return depreciation / denom + + +def missing_beneish_fields(row: pd.Series) -> tuple[str, ...]: + """Return canonical fields missing from one fundamentals row.""" + return tuple(field for field in BENEISH_REQUIRED_FIELDS if _row_value(row, field) is None) + + +def compute_beneish_m_score( + history: pd.DataFrame, + *, + config: dict | None = None, +) -> BeneishResult: + """Compute Beneish M-Score from at least two fiscal periods of PIT history.""" + cfg = config or load_beneish_config() + version = str(cfg["version"]) + if len(history) < 2: + return BeneishResult(m_score=None, rule_version=version, missing_fields=("history",)) + + ordered = history.sort_values("fiscal_period_end") + current = ordered.iloc[-1] + prior = ordered.iloc[-2] + + missing = missing_beneish_fields(current) + missing_beneish_fields(prior) + if missing: + return BeneishResult( + m_score=None, + rule_version=version, + missing_fields=tuple(dict.fromkeys(missing)), + ) + + recv_t = _row_value(current, "accounts_receivable") + recv_p = _row_value(prior, "accounts_receivable") + sales_t = _row_value(current, "revenue") + sales_p = _row_value(prior, "revenue") + dsri = _safe_ratio( + _safe_ratio(recv_t, sales_t), + _safe_ratio(recv_p, sales_p), + ) + + gm_t = _gross_margin(sales_t, _row_value(current, "cost_of_revenue")) + gm_p = _gross_margin(sales_p, _row_value(prior, "cost_of_revenue")) + gmi = _safe_ratio(gm_p, gm_t) + + aqi_t = _asset_quality_index( + current_assets=_row_value(current, "current_assets"), + ppe_net=_row_value(current, "ppe_net"), + total_assets=_row_value(current, "total_assets"), + ) + aqi_p = _asset_quality_index( + current_assets=_row_value(prior, "current_assets"), + ppe_net=_row_value(prior, "ppe_net"), + total_assets=_row_value(prior, "total_assets"), + ) + aqi = _safe_ratio(aqi_t, aqi_p) + + sgi = _safe_ratio(sales_t, sales_p) + + depi = _safe_ratio( + _depi(_row_value(prior, "depreciation_amortization"), _row_value(prior, "ppe_net")), + _depi(_row_value(current, "depreciation_amortization"), _row_value(current, "ppe_net")), + ) + + sgai = _safe_ratio( + _safe_ratio(_row_value(current, "sga_expense"), sales_t), + _safe_ratio(_row_value(prior, "sga_expense"), sales_p), + ) + + net_income = _row_value(current, "net_income") + cfo = _row_value(current, "operating_cash_flow") + total_assets = _row_value(current, "total_assets") + tata = _safe_ratio( + (net_income - cfo) if net_income is not None and cfo is not None else None, + total_assets, + ) + + def leverage(row: pd.Series) -> float | None: + ltd = _row_value(row, "long_term_debt") or 0.0 + cl = _row_value(row, "current_liabilities") or 0.0 + ta = _row_value(row, "total_assets") + return _safe_ratio(ltd + cl, ta) + + lvgi = _safe_ratio(leverage(current), leverage(prior)) + + components = (dsri, gmi, aqi, sgi, depi, sgai, tata, lvgi) + if any(value is None for value in components): + return BeneishResult( + m_score=None, + rule_version=version, + missing_fields=("derived_ratio",), + ) + + coeffs = cfg["coefficients"] + m_score = ( + float(cfg["intercept"]) + + float(coeffs["dsri"]) * dsri + + float(coeffs["gmi"]) * gmi + + float(coeffs["aqi"]) * aqi + + float(coeffs["sgi"]) * sgi + + float(coeffs["depi"]) * depi + + float(coeffs["sgai"]) * sgai + + float(coeffs["tata"]) * tata + + float(coeffs["lvgi"]) * lvgi + ) + return BeneishResult(m_score=m_score, rule_version=version, missing_fields=()) diff --git a/src/smartwealthai/distress_rules.py b/src/smartwealthai/distress_rules.py new file mode 100644 index 0000000..9d4dcd9 --- /dev/null +++ b/src/smartwealthai/distress_rules.py @@ -0,0 +1,362 @@ +"""Bankruptcy / distress hard-exclusion rules for the permanent-loss filter.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +RuleStatus = Literal["pass", "exclude", "unavailable"] + +EDGAR_FRAUD_RULE_IDS: tuple[str, ...] = ( + "FRD_RESTATEMENT_RECENT", + "FRD_AUDITOR_CHANGE_REPEATED", + "FRD_LATE_FILER", + "FRD_REGULANTORY_ACTION", +) + + +@dataclass(frozen=True) +class DistressInputs: + """Point-in-time inputs for distress rule evaluation.""" + + total_assets: float | None = None + total_liabilities: float | None = None + stockholders_equity: float | None = None + current_assets: float | None = None + current_liabilities: float | None = None + retained_earnings: float | None = None + ebit: float | None = None + revenue: float | None = None + interest_expense: float | None = None + cash: float | None = None + short_term_debt: float | None = None + long_term_debt: float | None = None + depreciation_amortization: float | None = None + operating_cash_flow: float | None = None + capex: float | None = None + market_cap: float | None = None + listing_status: str | None = None + delisting_reason: str | None = None + + +@dataclass(frozen=True) +class RuleEvaluation: + """Outcome of one forensic / distress rule.""" + + rule_id: str + rule_version: str + subfilter: str + status: RuleStatus + triggered_value: float | None + threshold: float | None + explanation: str + + +EXCLUSION_COLUMNS: tuple[str, ...] = ( + "cik", + "ticker", + "subfilter", + "rule_id", + "rule_version", + "triggered_value", + "threshold", + "as_of_date", + "explanation", +) + +BK_RULE_VERSION = "distress_rules_v1" +FRAUD_RULE_VERSION = "fraud_rules_v1" +DELIST_REASONS = frozenset({"bankruptcy", "regulatory"}) + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + try: + if value != value: # NaN + return None + except TypeError: + return None + return float(value) + + +def distress_inputs_from_row( + fundamentals: object, + *, + market_cap: float | None = None, + listing_status: str | None = None, + delisting_reason: str | None = None, +) -> DistressInputs: + """Build distress inputs from a curated fundamentals row.""" + + def get(name: str) -> float | None: + if hasattr(fundamentals, "get"): + return _optional_float(fundamentals.get(name)) + return _optional_float(getattr(fundamentals, name, None)) + + total_assets = get("total_assets") + total_liabilities = get("total_liabilities") + equity = get("stockholders_equity") + if equity is None and total_assets is not None and total_liabilities is not None: + equity = total_assets - total_liabilities + + return DistressInputs( + total_assets=total_assets, + total_liabilities=total_liabilities, + stockholders_equity=equity, + current_assets=get("current_assets"), + current_liabilities=get("current_liabilities"), + retained_earnings=get("retained_earnings"), + ebit=get("ebit"), + revenue=get("revenue"), + interest_expense=get("interest_expense"), + cash=get("cash"), + short_term_debt=get("short_term_debt"), + long_term_debt=get("long_term_debt"), + depreciation_amortization=get("depreciation_amortization"), + operating_cash_flow=get("operating_cash_flow"), + capex=get("capex"), + market_cap=market_cap, + listing_status=listing_status, + delisting_reason=delisting_reason, + ) + + +def evaluate_negative_equity(inputs: DistressInputs, *, config: dict) -> RuleEvaluation: + """Exclude when stockholders equity is below the configured threshold.""" + threshold = float(config["rules"]["BK_NEGATIVE_EQUITY"]["threshold"]) + equity = inputs.stockholders_equity + if equity is None: + return RuleEvaluation( + rule_id="BK_NEGATIVE_EQUITY", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=None, + threshold=threshold, + explanation="missing stockholders_equity", + ) + status: RuleStatus = "exclude" if equity < threshold else "pass" + return RuleEvaluation( + rule_id="BK_NEGATIVE_EQUITY", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status=status, + triggered_value=equity, + threshold=threshold, + explanation=f"stockholders_equity={equity:.4f}", + ) + + +def compute_altman_z(inputs: DistressInputs) -> float | None: + """Return Altman Z for non-financials, or None when required inputs are missing.""" + required = ( + inputs.current_assets, + inputs.current_liabilities, + inputs.retained_earnings, + inputs.ebit, + inputs.total_assets, + inputs.total_liabilities, + inputs.market_cap, + inputs.revenue, + ) + if any(value is None for value in required): + return None + assert inputs.total_assets is not None + assert inputs.total_liabilities is not None + if inputs.total_assets == 0 or inputs.total_liabilities == 0: + return None + + working_capital = inputs.current_assets - inputs.current_liabilities + ta = inputs.total_assets + tl = inputs.total_liabilities + return ( + 1.2 * (working_capital / ta) + + 1.4 * (inputs.retained_earnings / ta) + + 3.3 * (inputs.ebit / ta) + + 0.6 * (inputs.market_cap / tl) + + 1.0 * (inputs.revenue / ta) + ) + + +def evaluate_altman_z(inputs: DistressInputs, *, config: dict) -> RuleEvaluation: + """Exclude when Altman Z is below the distress threshold.""" + threshold = float(config["rules"]["BK_ALTMAN_Z"]["threshold"]) + z_score = compute_altman_z(inputs) + if z_score is None: + return RuleEvaluation( + rule_id="BK_ALTMAN_Z", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=None, + threshold=threshold, + explanation="missing Altman Z inputs", + ) + status: RuleStatus = "exclude" if z_score < threshold else "pass" + return RuleEvaluation( + rule_id="BK_ALTMAN_Z", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status=status, + triggered_value=z_score, + threshold=threshold, + explanation=f"altman_z={z_score:.4f}", + ) + + +def evaluate_interest_coverage(inputs: DistressInputs, *, config: dict) -> RuleEvaluation: + """Exclude when EBIT / interest expense is below threshold.""" + threshold = float(config["rules"]["BK_INT_COVERAGE"]["threshold"]) + if inputs.ebit is None or inputs.interest_expense is None: + return RuleEvaluation( + rule_id="BK_INT_COVERAGE", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=None, + threshold=threshold, + explanation="missing ebit or interest_expense", + ) + if inputs.interest_expense == 0: + coverage = float("inf") + else: + coverage = inputs.ebit / inputs.interest_expense + status: RuleStatus = "exclude" if coverage < threshold else "pass" + return RuleEvaluation( + rule_id="BK_INT_COVERAGE", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status=status, + triggered_value=coverage if coverage != float("inf") else None, + threshold=threshold, + explanation=f"interest_coverage={coverage:.4f}", + ) + + +def _net_debt(inputs: DistressInputs) -> float | None: + if inputs.cash is None: + return None + debt = (inputs.short_term_debt or 0.0) + (inputs.long_term_debt or 0.0) + return debt - inputs.cash + + +def _ebitda(inputs: DistressInputs) -> float | None: + if inputs.ebit is None or inputs.depreciation_amortization is None: + return None + return inputs.ebit + inputs.depreciation_amortization + + +def _free_cash_flow(inputs: DistressInputs) -> float | None: + if inputs.operating_cash_flow is None: + return None + capex = inputs.capex or 0.0 + return inputs.operating_cash_flow - capex + + +def evaluate_net_debt_ebitda(inputs: DistressInputs, *, config: dict) -> RuleEvaluation: + """Exclude when net debt / EBITDA exceeds threshold with negative FCF.""" + rule_cfg = config["rules"]["BK_NETDEBT_EBITDA"] + threshold = float(rule_cfg["net_debt_ebitda_threshold"]) + require_negative_fcf = bool(rule_cfg.get("require_negative_fcf", True)) + + net_debt = _net_debt(inputs) + ebitda = _ebitda(inputs) + fcf = _free_cash_flow(inputs) + if net_debt is None or ebitda is None or ebitda == 0: + return RuleEvaluation( + rule_id="BK_NETDEBT_EBITDA", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=None, + threshold=threshold, + explanation="missing net debt or EBITDA inputs", + ) + ratio = net_debt / ebitda + fires = ratio > threshold and (not require_negative_fcf or (fcf is not None and fcf < 0)) + if fcf is None and require_negative_fcf: + return RuleEvaluation( + rule_id="BK_NETDEBT_EBITDA", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=ratio, + threshold=threshold, + explanation="missing free cash flow", + ) + status: RuleStatus = "exclude" if fires else "pass" + return RuleEvaluation( + rule_id="BK_NETDEBT_EBITDA", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status=status, + triggered_value=ratio, + threshold=threshold, + explanation=f"net_debt_ebitda={ratio:.4f}", + ) + + +def evaluate_delisted(inputs: DistressInputs) -> RuleEvaluation: + """Exclude when listing status is delisted for bankruptcy or regulatory reasons.""" + if inputs.listing_status is None: + return RuleEvaluation( + rule_id="BK_DELISTED", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status="unavailable", + triggered_value=None, + threshold=None, + explanation="missing listing_status", + ) + normalized_status = inputs.listing_status.lower() + reason = (inputs.delisting_reason or "").lower() + fires = normalized_status == "delisted" and reason in DELIST_REASONS + status: RuleStatus = "exclude" if fires else "pass" + return RuleEvaluation( + rule_id="BK_DELISTED", + rule_version=BK_RULE_VERSION, + subfilter="bankruptcy", + status=status, + triggered_value=None, + threshold=None, + explanation=f"listing_status={inputs.listing_status}, delisting_reason={reason or 'none'}", + ) + + +def evaluate_edgar_fraud_rule(rule_id: str) -> RuleEvaluation: + """Return unavailable for EDGAR-dependent fraud rules until data is wired.""" + return RuleEvaluation( + rule_id=rule_id, + rule_version=FRAUD_RULE_VERSION, + subfilter="fraud", + status="unavailable", + triggered_value=None, + threshold=None, + explanation="edgar data not available", + ) + + +def evaluate_distress_rules(inputs: DistressInputs, *, config: dict) -> list[RuleEvaluation]: + """Evaluate all bankruptcy / distress hard rules.""" + return [ + evaluate_negative_equity(inputs, config=config), + evaluate_altman_z(inputs, config=config), + evaluate_interest_coverage(inputs, config=config), + evaluate_net_debt_ebitda(inputs, config=config), + evaluate_delisted(inputs), + ] + + +def evaluate_fraud_rules(*, edgar_available: bool = False) -> list[RuleEvaluation]: + """Evaluate fraud rules; EDGAR rules are unavailable until wired.""" + rule_ids = ( + "FRD_RESTATEMENT_RECENT", + "FRD_AUDITOR_CHANGE_REPEATED", + "FRD_LATE_FILER", + "FRD_REGULATORY_ACTION", + ) + if edgar_available: + # ponytail: EDGAR wiring deferred; same unavailable path until implemented + pass + return [evaluate_edgar_fraud_rule(rule_id) for rule_id in rule_ids] diff --git a/src/smartwealthai/forensic_evaluator.py b/src/smartwealthai/forensic_evaluator.py new file mode 100644 index 0000000..f1ed49d --- /dev/null +++ b/src/smartwealthai/forensic_evaluator.py @@ -0,0 +1,300 @@ +"""Forensic evaluator orchestrator for the permanent-loss filter.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +import pandas as pd + +from smartwealthai.beneish_score import compute_beneish_m_score +from smartwealthai.distress_rules import ( + EXCLUSION_COLUMNS, + RuleEvaluation, + distress_inputs_from_row, + evaluate_distress_rules, + evaluate_fraud_rules, +) +from smartwealthai.forensic_percentile_gate import ForensicScore, apply_bottom_percentile_gate +from smartwealthai.lake_paths import ( + curated_permanent_loss_exclusions_path, + curated_permanent_loss_issues_path, + curated_prices_snapshot_path, + curated_universe_path, + pad_cik, +) +from smartwealthai.permanent_loss_config import ( + load_beneish_config, + load_distress_rules_config, + load_forensic_gate_config, +) +from smartwealthai.pit_fundamentals import ( + _fundamentals_paths_for_ciks, + _read_fundamentals_partition_all, + _select_pit_fundamentals, + _select_pit_fundamentals_by_period, +) + +REVIEW_COLUMNS: tuple[str, ...] = ("run_date", "ticker", "cik", "reason", "rule_id") + + +@dataclass +class ForensicEvaluatorResult: + """Outcome of one forensic evaluator run.""" + + exclusions_path: Path + issues_path: Path | None + evaluated_count: int + exclusion_count: int + review_count: int + + +def _market_cap(fundamentals: pd.Series, price_row: pd.Series | None) -> float | None: + shares = fundamentals.get("shares_outstanding") + adj_close = price_row.get("adj_close") if price_row is not None else None + if shares is None or adj_close is None: + return price_row.get("market_cap_usd") if price_row is not None else None + try: + if pd.isna(shares) or pd.isna(adj_close): + return None + except TypeError: + pass + return float(shares) * float(adj_close) + + +def _load_prices(data_dir: Path, *, run_date: date) -> dict[str, pd.Series]: + path = curated_prices_snapshot_path(data_dir, run_date=run_date) + if not path.exists(): + return {} + frame = pd.read_parquet(path) + return {str(row["ticker"]).upper(): row for _, row in frame.iterrows()} + + +def _load_latest_fundamentals( + data_dir: Path, + *, + ciks: set[str], + as_of_date: date, +) -> dict[str, pd.Series]: + paths = _fundamentals_paths_for_ciks(data_dir, ciks) + frames: list[pd.DataFrame] = [] + for path in paths: + frame = _read_fundamentals_partition_all(path) + if frame is not None: + frames.append(frame) + if not frames: + return {} + selected = _select_pit_fundamentals(pd.concat(frames, ignore_index=True), as_of_date) + return {pad_cik(str(row["cik"])): row for _, row in selected.iterrows()} + + +def _load_fundamentals_history( + data_dir: Path, + *, + cik: str, + as_of_date: date, +) -> pd.DataFrame: + paths = _fundamentals_paths_for_ciks(data_dir, {cik}) + frames: list[pd.DataFrame] = [] + for path in paths: + frame = _read_fundamentals_partition_all(path) + if frame is not None: + frames.append(frame) + if not frames: + return pd.DataFrame() + history = pd.concat(frames, ignore_index=True) + if "statement_variant" in history.columns: + history = history.loc[history["statement_variant"].isin(("annual", "quarterly"))] + selected = _select_pit_fundamentals_by_period(history, as_of_date) + return selected.sort_values("fiscal_period_end").reset_index(drop=True) + + +def _evaluation_to_exclusion_row( + *, + cik: str, + ticker: str, + evaluation: RuleEvaluation, + as_of_date: date, +) -> dict[str, object]: + return { + "cik": pad_cik(cik), + "ticker": ticker, + "subfilter": evaluation.subfilter, + "rule_id": evaluation.rule_id, + "rule_version": evaluation.rule_version, + "triggered_value": evaluation.triggered_value, + "threshold": evaluation.threshold, + "as_of_date": as_of_date.isoformat(), + "explanation": evaluation.explanation, + } + + +def _percentile_to_exclusion_row( + *, + exclusion: object, + as_of_date: date, +) -> dict[str, object]: + return { + "cik": pad_cik(exclusion.cik), + "ticker": exclusion.ticker, + "subfilter": "forensic_percentile", + "rule_id": exclusion.rule_id, + "rule_version": exclusion.rule_version, + "triggered_value": exclusion.score, + "threshold": exclusion.threshold_percentile, + "as_of_date": as_of_date.isoformat(), + "explanation": exclusion.explanation, + } + + +def run_forensic_evaluator( + data_dir: Path, + *, + run_date: date, + distress_config: dict | None = None, + beneish_config: dict | None = None, + gate_config: dict | None = None, +) -> ForensicEvaluatorResult: + """Evaluate forensic rules for the universe on ``run_date`` and write exclusions.""" + distress_cfg = distress_config or load_distress_rules_config() + beneish_cfg = beneish_config or load_beneish_config() + gate_cfg = gate_config or load_forensic_gate_config() + + universe_path = curated_universe_path(data_dir, run_date=run_date) + if not universe_path.exists(): + msg = f"No universe snapshot for run_date {run_date}: {universe_path}" + raise FileNotFoundError(msg) + + universe = pd.read_parquet(universe_path) + prices = _load_prices(data_dir, run_date=run_date) + ciks = {pad_cik(str(row["cik"])) for _, row in universe.iterrows() if pd.notna(row["cik"])} + fundamentals_by_cik = _load_latest_fundamentals(data_dir, ciks=ciks, as_of_date=run_date) + + exclusion_rows: list[dict[str, object]] = [] + review_rows: list[dict[str, object]] = [] + beneish_candidates: list[ForensicScore] = [] + hard_excluded: set[str] = set() + + for _, row in universe.iterrows(): + ticker = str(row["ticker"]).upper() + if pd.isna(row["cik"]): + review_rows.append( + { + "run_date": run_date.isoformat(), + "ticker": ticker, + "cik": None, + "reason": "missing_cik", + "rule_id": None, + } + ) + continue + + cik = pad_cik(str(row["cik"])) + fundamentals = fundamentals_by_cik.get(cik) + if fundamentals is None: + review_rows.append( + { + "run_date": run_date.isoformat(), + "ticker": ticker, + "cik": cik, + "reason": "missing_fundamentals", + "rule_id": None, + } + ) + continue + + price_row = prices.get(ticker) + listing_status = str(price_row.get("listing_status")) if price_row is not None else None + delisting_reason = str(price_row.get("delisting_reason")) if price_row is not None else None + if price_row is not None and pd.isna(price_row.get("listing_status")): + listing_status = None + if price_row is not None and pd.isna(price_row.get("delisting_reason")): + delisting_reason = None + + inputs = distress_inputs_from_row( + fundamentals, + market_cap=_market_cap(fundamentals, price_row), + listing_status=listing_status, + delisting_reason=delisting_reason, + ) + evaluations = evaluate_distress_rules(inputs, config=distress_cfg) + evaluate_fraud_rules() + + excluded_here = False + for evaluation in evaluations: + if evaluation.status == "exclude": + exclusion_rows.append( + _evaluation_to_exclusion_row( + cik=cik, + ticker=ticker, + evaluation=evaluation, + as_of_date=run_date, + ) + ) + excluded_here = True + elif evaluation.status == "unavailable": + review_rows.append( + { + "run_date": run_date.isoformat(), + "ticker": ticker, + "cik": cik, + "reason": evaluation.explanation, + "rule_id": evaluation.rule_id, + } + ) + + if excluded_here: + hard_excluded.add(ticker) + continue + + history = _load_fundamentals_history(data_dir, cik=cik, as_of_date=run_date) + beneish = compute_beneish_m_score(history, config=beneish_cfg) + if beneish.m_score is None: + if beneish.missing_fields: + review_rows.append( + { + "run_date": run_date.isoformat(), + "ticker": ticker, + "cik": cik, + "reason": f"missing beneish inputs: {','.join(beneish.missing_fields)}", + "rule_id": "FRD_BENEISH", + } + ) + continue + + beneish_candidates.append( + ForensicScore( + ticker=ticker, + cik=cik, + model="beneish", + score=beneish.m_score, + rule_version=beneish.rule_version, + market_cap=_market_cap(fundamentals, price_row) or 0.0, + ) + ) + + for percentile_exclusion in apply_bottom_percentile_gate(beneish_candidates, config=gate_cfg): + if percentile_exclusion.ticker in hard_excluded: + continue + exclusion_rows.append( + _percentile_to_exclusion_row(exclusion=percentile_exclusion, as_of_date=run_date) + ) + + exclusions_out = curated_permanent_loss_exclusions_path(data_dir, run_date=run_date) + exclusions_out.parent.mkdir(parents=True, exist_ok=True) + exclusions_df = pd.DataFrame(exclusion_rows, columns=list(EXCLUSION_COLUMNS)) + exclusions_df.to_parquet(exclusions_out, index=False) + + issues_out: Path | None = None + if review_rows: + issues_out = curated_permanent_loss_issues_path(data_dir, run_date=run_date) + issues_out.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(review_rows, columns=list(REVIEW_COLUMNS)).to_parquet(issues_out, index=False) + + return ForensicEvaluatorResult( + exclusions_path=exclusions_out, + issues_path=issues_out, + evaluated_count=len(universe), + exclusion_count=len(exclusions_df), + review_count=len(review_rows), + ) diff --git a/src/smartwealthai/forensic_percentile_gate.py b/src/smartwealthai/forensic_percentile_gate.py new file mode 100644 index 0000000..d9e9bfb --- /dev/null +++ b/src/smartwealthai/forensic_percentile_gate.py @@ -0,0 +1,65 @@ +"""Cross-sectional forensic percentile gate (QVAL-style bottom exclusion).""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from smartwealthai.permanent_loss_config import load_forensic_gate_config + + +@dataclass(frozen=True) +class ForensicScore: + """One continuous forensic model score for percentile ranking.""" + + ticker: str + cik: str + model: str + score: float + rule_version: str + market_cap: float = 0.0 + + +@dataclass(frozen=True) +class PercentileExclusion: + """One name excluded by the bottom-percentile gate.""" + + ticker: str + cik: str + model: str + score: float + rule_id: str + rule_version: str + threshold_percentile: float + explanation: str + + +def apply_bottom_percentile_gate( + scores: list[ForensicScore], + *, + config: dict | None = None, +) -> list[PercentileExclusion]: + """Hard-exclude names in the bottom ``bottom_percentile`` by score (higher = worse).""" + if not scores: + return [] + + cfg = config or load_forensic_gate_config() + percentile = float(cfg["bottom_percentile"]) + rule_id = str(cfg["rule_id"]) + exclude_count = max(1, math.ceil(len(scores) * percentile)) + + ordered = sorted(scores, key=lambda row: (-row.score, row.market_cap)) + excluded = ordered[:exclude_count] + return [ + PercentileExclusion( + ticker=row.ticker, + cik=row.cik, + model=row.model, + score=row.score, + rule_id=rule_id, + rule_version=row.rule_version, + threshold_percentile=percentile, + explanation=f"beneish_m_score={row.score:.4f} in bottom {percentile:.0%}", + ) + for row in excluded + ] diff --git a/src/smartwealthai/lake_paths.py b/src/smartwealthai/lake_paths.py index f96e650..9ebd784 100644 --- a/src/smartwealthai/lake_paths.py +++ b/src/smartwealthai/lake_paths.py @@ -286,6 +286,28 @@ def curated_exclusions_path(data_dir: Path, *, run_date: date) -> Path: ) +def curated_permanent_loss_exclusions_path(data_dir: Path, *, run_date: date) -> Path: + """Build the path for forensic / permanent-loss exclusions on a run date.""" + return ( + data_dir + / "curated" + / "permanent_loss" + / f"run_date={run_date.isoformat()}" + / "exclusions.parquet" + ) + + +def curated_permanent_loss_issues_path(data_dir: Path, *, run_date: date) -> Path: + """Build the path for permanent-loss review-queue rows on a run date.""" + return ( + data_dir + / "curated" + / "issues" + / f"run_date={run_date.isoformat()}" + / "permanent_loss.parquet" + ) + + def yfinance_raw_path( data_dir: Path, *, diff --git a/src/smartwealthai/permanent_loss_config.py b/src/smartwealthai/permanent_loss_config.py new file mode 100644 index 0000000..a851664 --- /dev/null +++ b/src/smartwealthai/permanent_loss_config.py @@ -0,0 +1,42 @@ +"""Load versioned permanent-loss / forensic YAML configuration.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +DEFAULT_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" / "permanent_loss" + + +def load_yaml_config(path: Path) -> dict: + """Load one YAML config file.""" + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def load_distress_rules_config( + config_dir: Path = DEFAULT_CONFIG_DIR, + *, + filename: str = "distress_rules_v1.yaml", +) -> dict: + """Return distress rule thresholds keyed by ``rule_id``.""" + return load_yaml_config(config_dir / filename) + + +def load_beneish_config( + config_dir: Path = DEFAULT_CONFIG_DIR, + *, + filename: str = "beneish_v1.yaml", +) -> dict: + """Return Beneish M-Score coefficients and intercept.""" + return load_yaml_config(config_dir / filename) + + +def load_forensic_gate_config( + config_dir: Path = DEFAULT_CONFIG_DIR, + *, + filename: str = "forensic_gate_v1.yaml", +) -> dict: + """Return cross-sectional forensic percentile gate settings.""" + return load_yaml_config(config_dir / filename) diff --git a/src/smartwealthai/run_forensic_evaluator.py b/src/smartwealthai/run_forensic_evaluator.py new file mode 100644 index 0000000..cb79d64 --- /dev/null +++ b/src/smartwealthai/run_forensic_evaluator.py @@ -0,0 +1,51 @@ +"""CLI to run the forensic evaluator / permanent-loss filter.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import click +from click.testing import CliRunner + +from smartwealthai.forensic_evaluator import run_forensic_evaluator + + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--data-dir", + type=click.Path(path_type=Path, file_okay=False), + default=Path("data"), + show_default=True, + help="Data lake root.", +) +@click.option( + "--run-date", + type=click.DateTime(formats=["%Y-%m-%d"]), + required=True, + help="Decision date for forensic evaluation.", +) +def main(data_dir: Path, run_date: datetime) -> None: + """Run forensic hard-exclusion rules and write permanent-loss artifacts.""" + decision_date = run_date.date() + result = run_forensic_evaluator(data_dir, run_date=decision_date) + click.echo( + f"Forensic evaluation for {decision_date}: " + f"{result.evaluated_count} evaluated, " + f"{result.exclusion_count} excluded, " + f"{result.review_count} review queue." + ) + click.echo(f" Exclusions: {result.exclusions_path}") + if result.issues_path is not None: + click.echo(f" Review queue: {result.issues_path}") + + +def cli_run(argv: list[str] | None = None) -> int: + """Invoke the CLI programmatically (e.g. in tests).""" + runner = CliRunner() + result = runner.invoke(main, argv or []) + return result.exit_code + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/permanent_loss/cases.json b/tests/fixtures/permanent_loss/cases.json new file mode 100644 index 0000000..d5da06a --- /dev/null +++ b/tests/fixtures/permanent_loss/cases.json @@ -0,0 +1,20 @@ +[ + { + "name": "Enron Q3 2001", + "ticker": "ENRN", + "cik": "0001024401", + "decision_date": "2001-11-15" + }, + { + "name": "Lehman Q2 2008", + "ticker": "LEH", + "cik": "0000806085", + "decision_date": "2008-09-15" + }, + { + "name": "WorldCom Q1 2002", + "ticker": "MCWE", + "cik": "0000723527", + "decision_date": "2002-05-15" + } +] diff --git a/tests/test_beneish_score.py b/tests/test_beneish_score.py new file mode 100644 index 0000000..7466a17 --- /dev/null +++ b/tests/test_beneish_score.py @@ -0,0 +1,134 @@ +"""Tests for Beneish M-Score (issue #89).""" + +from __future__ import annotations + +import pandas as pd + +from smartwealthai.beneish_score import compute_beneish_m_score, missing_beneish_fields + + +def _period_row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "fiscal_period_end": pd.Timestamp("2023-12-31"), + "accounts_receivable": 100.0, + "revenue": 1000.0, + "cost_of_revenue": 600.0, + "total_assets": 2000.0, + "ppe_net": 500.0, + "depreciation_amortization": 50.0, + "sga_expense": 200.0, + "net_income": 80.0, + "operating_cash_flow": 120.0, + "current_assets": 400.0, + "current_liabilities": 200.0, + "long_term_debt": 300.0, + } + row.update(overrides) + return row + + +def test_beneish_requires_two_periods() -> None: + history = pd.DataFrame([_period_row()]) + result = compute_beneish_m_score(history) + assert result.m_score is None + assert "history" in result.missing_fields + + +def test_beneish_computes_score_from_two_periods() -> None: + prior = _period_row( + fiscal_period_end=pd.Timestamp("2022-12-31"), + accounts_receivable=90.0, + revenue=900.0, + cost_of_revenue=540.0, + total_assets=1800.0, + ppe_net=450.0, + depreciation_amortization=45.0, + sga_expense=180.0, + net_income=70.0, + operating_cash_flow=110.0, + current_assets=360.0, + current_liabilities=180.0, + long_term_debt=280.0, + ) + current = _period_row(fiscal_period_end=pd.Timestamp("2023-12-31")) + result = compute_beneish_m_score(pd.DataFrame([prior, current])) + assert result.m_score is not None + assert result.rule_version == "beneish_v1" + assert not result.missing_fields + + +def test_missing_beneish_fields_detects_absent_columns() -> None: + row = pd.Series(_period_row()) + row = row.drop("sga_expense") + missing = missing_beneish_fields(row) + assert "sga_expense" in missing + + +def test_missing_beneish_fields_detects_nan_values() -> None: + row = pd.Series(_period_row(sga_expense=float("nan"))) + missing = missing_beneish_fields(row) + assert "sga_expense" in missing + + +def test_beneish_returns_missing_fields_when_prior_period_incomplete() -> None: + prior = _period_row(fiscal_period_end=pd.Timestamp("2022-12-31"), revenue=float("nan")) + current = _period_row(fiscal_period_end=pd.Timestamp("2023-12-31")) + result = compute_beneish_m_score(pd.DataFrame([prior, current])) + assert result.m_score is None + assert "revenue" in result.missing_fields + + +def test_beneish_returns_derived_ratio_when_growth_inputs_invalid() -> None: + prior = _period_row( + fiscal_period_end=pd.Timestamp("2022-12-31"), + revenue=0.0, + cost_of_revenue=0.0, + ) + current = _period_row(fiscal_period_end=pd.Timestamp("2023-12-31"), revenue=1000.0) + result = compute_beneish_m_score(pd.DataFrame([prior, current])) + assert result.m_score is None + assert result.missing_fields == ("derived_ratio",) + + +def test_beneish_returns_derived_ratio_when_depreciation_denominator_zero() -> None: + prior = _period_row( + fiscal_period_end=pd.Timestamp("2022-12-31"), + depreciation_amortization=0.0, + ppe_net=0.0, + ) + current = _period_row( + fiscal_period_end=pd.Timestamp("2023-12-31"), + depreciation_amortization=0.0, + ppe_net=0.0, + ) + result = compute_beneish_m_score(pd.DataFrame([prior, current])) + assert result.m_score is None + assert result.missing_fields == ("derived_ratio",) + + +def test_beneish_returns_derived_ratio_when_total_assets_zero() -> None: + prior = _period_row( + fiscal_period_end=pd.Timestamp("2022-12-31"), + total_assets=0.0, + current_assets=0.0, + ppe_net=0.0, + ) + current = _period_row( + fiscal_period_end=pd.Timestamp("2023-12-31"), + total_assets=0.0, + current_assets=0.0, + ppe_net=0.0, + ) + result = compute_beneish_m_score(pd.DataFrame([prior, current])) + assert result.m_score is None + assert result.missing_fields == ("derived_ratio",) + + +def test_missing_beneish_fields_treats_non_comparable_values_as_missing() -> None: + class NonComparable: + def __eq__(self, _other: object) -> bool: + raise TypeError("no compare") + + row = pd.Series(_period_row(sga_expense=NonComparable())) + missing = missing_beneish_fields(row) + assert "sga_expense" in missing diff --git a/tests/test_distress_rules.py b/tests/test_distress_rules.py new file mode 100644 index 0000000..f0c7d8d --- /dev/null +++ b/tests/test_distress_rules.py @@ -0,0 +1,251 @@ +"""Tests for bankruptcy / distress rule evaluators (issue #89).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from smartwealthai.distress_rules import ( + DistressInputs, + compute_altman_z, + distress_inputs_from_row, + evaluate_altman_z, + evaluate_delisted, + evaluate_distress_rules, + evaluate_edgar_fraud_rule, + evaluate_fraud_rules, + evaluate_interest_coverage, + evaluate_negative_equity, + evaluate_net_debt_ebitda, +) +from smartwealthai.permanent_loss_config import load_distress_rules_config + + +def _distress_config() -> dict: + return load_distress_rules_config() + + +def test_negative_equity_excludes_when_equity_below_threshold() -> None: + inputs = DistressInputs(stockholders_equity=-1.0) + result = evaluate_negative_equity(inputs, config=_distress_config()) + assert result.status == "exclude" + assert result.rule_id == "BK_NEGATIVE_EQUITY" + assert result.triggered_value == -1.0 + + +def test_negative_equity_passes_when_equity_positive() -> None: + inputs = DistressInputs(stockholders_equity=100.0) + result = evaluate_negative_equity(inputs, config=_distress_config()) + assert result.status == "pass" + + +def test_negative_equity_unavailable_when_equity_missing() -> None: + result = evaluate_negative_equity(DistressInputs(), config=_distress_config()) + assert result.status == "unavailable" + + +def test_distress_inputs_derives_equity_from_assets_minus_liabilities() -> None: + row = {"total_assets": 100.0, "total_liabilities": 120.0} + inputs = distress_inputs_from_row(row) + assert inputs.stockholders_equity == -20.0 + + +def test_altman_z_excludes_distressed_company() -> None: + inputs = DistressInputs( + current_assets=10.0, + current_liabilities=50.0, + retained_earnings=-80.0, + ebit=-5.0, + total_assets=100.0, + total_liabilities=150.0, + market_cap=5.0, + revenue=20.0, + ) + z_score = compute_altman_z(inputs) + assert z_score is not None + assert z_score < 1.81 + result = evaluate_altman_z(inputs, config=_distress_config()) + assert result.status == "exclude" + + +def test_altman_z_passes_healthy_company() -> None: + inputs = DistressInputs( + current_assets=80.0, + current_liabilities=20.0, + retained_earnings=50.0, + ebit=30.0, + total_assets=200.0, + total_liabilities=40.0, + market_cap=500.0, + revenue=300.0, + ) + result = evaluate_altman_z(inputs, config=_distress_config()) + assert result.status == "pass" + + +def test_interest_coverage_excludes_when_below_one() -> None: + inputs = DistressInputs(ebit=0.5, interest_expense=1.0) + result = evaluate_interest_coverage(inputs, config=_distress_config()) + assert result.status == "exclude" + assert result.triggered_value == 0.5 + + +def test_net_debt_ebitda_excludes_high_leverage_with_negative_fcf() -> None: + inputs = DistressInputs( + cash=1.0, + short_term_debt=20.0, + long_term_debt=60.0, + ebit=2.0, + depreciation_amortization=2.0, + operating_cash_flow=-5.0, + capex=0.0, + ) + result = evaluate_net_debt_ebitda(inputs, config=_distress_config()) + assert result.status == "exclude" + assert result.triggered_value == 19.75 + + +def test_delisted_excludes_bankruptcy_delisting() -> None: + inputs = DistressInputs(listing_status="delisted", delisting_reason="bankruptcy") + result = evaluate_delisted(inputs) + assert result.status == "exclude" + + +def test_delisted_passes_active_listing() -> None: + inputs = DistressInputs(listing_status="active") + result = evaluate_delisted(inputs) + assert result.status == "pass" + + +def test_edgar_fraud_rules_are_unavailable_without_edgar() -> None: + results = evaluate_fraud_rules(edgar_available=False) + assert len(results) == 4 + assert all(result.status == "unavailable" for result in results) + + +def test_evaluate_edgar_fraud_rule_never_excludes() -> None: + result = evaluate_edgar_fraud_rule("FRD_REGULATORY_ACTION") + assert result.status == "unavailable" + + +def test_evaluate_distress_rules_returns_all_bankruptcy_rules() -> None: + inputs = DistressInputs(stockholders_equity=10.0, listing_status="active") + results = evaluate_distress_rules(inputs, config=_distress_config()) + rule_ids = {result.rule_id for result in results} + assert rule_ids == { + "BK_NEGATIVE_EQUITY", + "BK_ALTMAN_Z", + "BK_INT_COVERAGE", + "BK_NETDEBT_EBITDA", + "BK_DELISTED", + } + + +def test_distress_inputs_reads_attributes_when_row_has_no_get() -> None: + @dataclass + class FundamentalsRow: + total_assets: float + total_liabilities: float + ebit: float + + inputs = distress_inputs_from_row(FundamentalsRow(100.0, 40.0, 5.0)) + assert inputs.stockholders_equity == 60.0 + assert inputs.ebit == 5.0 + + +def test_distress_inputs_treats_nan_as_missing() -> None: + row = {"total_assets": float("nan"), "stockholders_equity": 10.0} + inputs = distress_inputs_from_row(row) + assert inputs.total_assets is None + assert inputs.stockholders_equity == 10.0 + + +def test_distress_inputs_treats_non_comparable_values_as_missing() -> None: + class NonComparable: + def __eq__(self, _other: object) -> bool: + raise TypeError("no compare") + + row = {"total_assets": NonComparable(), "stockholders_equity": 10.0} + inputs = distress_inputs_from_row(row) + assert inputs.total_assets is None + + +def test_altman_z_none_when_total_assets_zero() -> None: + inputs = DistressInputs( + current_assets=10.0, + current_liabilities=5.0, + retained_earnings=1.0, + ebit=1.0, + total_assets=0.0, + total_liabilities=1.0, + market_cap=10.0, + revenue=5.0, + ) + assert compute_altman_z(inputs) is None + result = evaluate_altman_z(inputs, config=_distress_config()) + assert result.status == "unavailable" + + +def test_interest_coverage_passes_when_interest_expense_zero() -> None: + inputs = DistressInputs(ebit=10.0, interest_expense=0.0) + result = evaluate_interest_coverage(inputs, config=_distress_config()) + assert result.status == "pass" + assert result.triggered_value is None + + +def test_interest_coverage_unavailable_when_ebit_missing() -> None: + result = evaluate_interest_coverage( + DistressInputs(interest_expense=1.0), + config=_distress_config(), + ) + assert result.status == "unavailable" + + +def test_net_debt_ebitda_passes_when_leverage_acceptable() -> None: + inputs = DistressInputs( + cash=50.0, + short_term_debt=10.0, + long_term_debt=20.0, + ebit=100.0, + depreciation_amortization=10.0, + operating_cash_flow=80.0, + capex=10.0, + ) + result = evaluate_net_debt_ebitda(inputs, config=_distress_config()) + assert result.status == "pass" + + +def test_net_debt_ebitda_unavailable_when_fcf_missing() -> None: + inputs = DistressInputs( + cash=1.0, + short_term_debt=20.0, + long_term_debt=60.0, + ebit=2.0, + depreciation_amortization=2.0, + operating_cash_flow=None, + ) + result = evaluate_net_debt_ebitda(inputs, config=_distress_config()) + assert result.status == "unavailable" + assert result.triggered_value == 19.75 + + +def test_delisted_unavailable_when_listing_status_missing() -> None: + result = evaluate_delisted(DistressInputs()) + assert result.status == "unavailable" + + +def test_delisted_excludes_regulatory_delisting() -> None: + inputs = DistressInputs(listing_status="delisted", delisting_reason="regulatory") + result = evaluate_delisted(inputs) + assert result.status == "exclude" + + +def test_delisted_passes_non_bankruptcy_delisting() -> None: + inputs = DistressInputs(listing_status="delisted", delisting_reason="merger") + result = evaluate_delisted(inputs) + assert result.status == "pass" + + +def test_evaluate_fraud_rules_with_edgar_available_still_unavailable() -> None: + results = evaluate_fraud_rules(edgar_available=True) + assert len(results) == 4 + assert all(result.status == "unavailable" for result in results) diff --git a/tests/test_forensic_evaluator.py b/tests/test_forensic_evaluator.py new file mode 100644 index 0000000..6145a90 --- /dev/null +++ b/tests/test_forensic_evaluator.py @@ -0,0 +1,477 @@ +"""Hermetic tests for the forensic evaluator orchestrator (issue #89).""" + +from __future__ import annotations + +import runpy +import sys +from datetime import date +from pathlib import Path + +import pandas as pd +import pytest +from click.testing import CliRunner + +from smartwealthai.distress_rules import EXCLUSION_COLUMNS +from smartwealthai.forensic_evaluator import ( + _load_fundamentals_history, + _load_latest_fundamentals, + _load_prices, + _market_cap, + run_forensic_evaluator, +) +from smartwealthai.lake_paths import ( + curated_fundamentals_path, + curated_permanent_loss_exclusions_path, + curated_prices_snapshot_path, + curated_universe_path, + pad_cik, +) +from smartwealthai.run_forensic_evaluator import cli_run, main + +RUN_DATE = date(2026, 6, 18) +CIK = pad_cik("9999001") +CIK_B = pad_cik("9999002") +CIK_C = pad_cik("9999003") + + +def _write_universe(lake: Path, *, rows: list[dict[str, object]]) -> None: + path = curated_universe_path(lake, run_date=RUN_DATE) + path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(rows).to_parquet(path, index=False) + + +def _write_fundamentals( + lake: Path, + *, + cik: str = CIK, + period: str, + row: dict[str, object], +) -> None: + path = curated_fundamentals_path(lake, cik=cik, period=period) + path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame([row]).to_parquet(path, index=False) + + +def _healthy_fundamentals_row( + *, + cik: str, + ticker: str, + fiscal_period_end: str, + **overrides: object, +) -> dict[str, object]: + row: dict[str, object] = { + "cik": cik, + "ticker": ticker, + "as_of_date": pd.Timestamp("2026-06-01"), + "fiscal_period_end": pd.Timestamp(fiscal_period_end), + "version_id": 1, + "statement_variant": "annual", + "total_assets": 2000.0, + "total_liabilities": 800.0, + "stockholders_equity": 1200.0, + "current_assets": 400.0, + "current_liabilities": 200.0, + "retained_earnings": 500.0, + "ebit": 100.0, + "revenue": 1000.0, + "interest_expense": 10.0, + "cash": 100.0, + "short_term_debt": 50.0, + "long_term_debt": 300.0, + "depreciation_amortization": 50.0, + "operating_cash_flow": 120.0, + "capex": 20.0, + "accounts_receivable": 100.0, + "cost_of_revenue": 600.0, + "ppe_net": 500.0, + "sga_expense": 200.0, + "net_income": 80.0, + } + row.update(overrides) + return row + + +def _write_prices_snapshot(lake: Path, *, rows: list[dict[str, object]]) -> None: + path = curated_prices_snapshot_path(lake, run_date=RUN_DATE) + path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(rows).to_parquet(path, index=False) + + +@pytest.fixture +def lake(tmp_path: Path) -> Path: + return tmp_path / "lake" + + +def test_run_forensic_evaluator_writes_exclusion_for_negative_equity(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "DIST", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + "market_cap_usd": 100.0, + } + ], + ) + _write_fundamentals( + lake, + period="2024Q4", + row={ + "cik": CIK, + "ticker": "DIST", + "as_of_date": pd.Timestamp("2026-06-01"), + "fiscal_period_end": pd.Timestamp("2024-12-31"), + "version_id": 1, + "statement_variant": "annual", + "total_assets": 100.0, + "total_liabilities": 150.0, + "stockholders_equity": -50.0, + "ebit": 1.0, + "revenue": 10.0, + "interest_expense": 5.0, + "cash": 1.0, + "short_term_debt": 0.0, + "long_term_debt": 0.0, + "depreciation_amortization": 1.0, + "operating_cash_flow": -2.0, + }, + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + exclusions = pd.read_parquet(result.exclusions_path) + assert list(exclusions.columns) == list(EXCLUSION_COLUMNS) + assert "DIST" in set(exclusions["ticker"]) + assert "BK_NEGATIVE_EQUITY" in set(exclusions["rule_id"]) + + +def test_run_forensic_evaluator_cli_writes_output(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "DIST", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + "market_cap_usd": 100.0, + } + ], + ) + _write_fundamentals( + lake, + period="2024Q4", + row={ + "cik": CIK, + "ticker": "DIST", + "as_of_date": pd.Timestamp("2026-06-01"), + "fiscal_period_end": pd.Timestamp("2024-12-31"), + "version_id": 1, + "statement_variant": "annual", + "total_assets": 100.0, + "total_liabilities": 150.0, + "stockholders_equity": -50.0, + }, + ) + + exit_code = cli_run(["--data-dir", str(lake), "--run-date", RUN_DATE.isoformat()]) + assert exit_code == 0 + assert curated_permanent_loss_exclusions_path(lake, run_date=RUN_DATE).exists() + + +def test_market_cap_uses_shares_times_adj_close() -> None: + fundamentals = pd.Series({"shares_outstanding": 100.0}) + price_row = pd.Series({"adj_close": 12.5}) + assert _market_cap(fundamentals, price_row) == 1250.0 + + +def test_market_cap_falls_back_to_snapshot_market_cap() -> None: + fundamentals = pd.Series({}) + price_row = pd.Series({"market_cap_usd": 900.0}) + assert _market_cap(fundamentals, price_row) == 900.0 + + +def test_market_cap_none_when_shares_or_price_nan() -> None: + fundamentals = pd.Series({"shares_outstanding": float("nan")}) + price_row = pd.Series({"adj_close": 10.0}) + assert _market_cap(fundamentals, price_row) is None + + +def test_market_cap_none_when_price_row_missing() -> None: + fundamentals = pd.Series({"shares_outstanding": 100.0}) + assert _market_cap(fundamentals, None) is None + + +def test_load_prices_returns_empty_when_snapshot_missing(lake: Path) -> None: + assert _load_prices(lake, run_date=RUN_DATE) == {} + + +def test_load_latest_fundamentals_returns_empty_when_no_partitions(lake: Path) -> None: + assert _load_latest_fundamentals(lake, ciks={CIK}, as_of_date=RUN_DATE) == {} + + +def test_load_fundamentals_history_filters_non_annual_quarterly_variants(lake: Path) -> None: + _write_fundamentals( + lake, + period="2024Q4", + row=_healthy_fundamentals_row(cik=CIK, ticker="GOOD", fiscal_period_end="2024-12-31"), + ) + _write_fundamentals( + lake, + period="2023Q4", + row=_healthy_fundamentals_row( + cik=CIK, + ticker="GOOD", + fiscal_period_end="2023-12-31", + statement_variant="ttm", + ), + ) + history = _load_fundamentals_history(lake, cik=CIK, as_of_date=RUN_DATE) + assert len(history) == 1 + assert history.iloc[0]["statement_variant"] == "annual" + + +def test_run_forensic_evaluator_raises_when_universe_missing(lake: Path) -> None: + with pytest.raises(FileNotFoundError, match="No universe snapshot"): + run_forensic_evaluator(lake, run_date=RUN_DATE) + + +def test_run_forensic_evaluator_routes_missing_cik_to_review_queue(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "NCIK", + "cik": None, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + assert result.review_count == 1 + assert result.issues_path is not None + issues = pd.read_parquet(result.issues_path) + assert issues.iloc[0]["reason"] == "missing_cik" + + +def test_run_forensic_evaluator_routes_missing_fundamentals_to_review_queue(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "NOFUND", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + assert result.review_count >= 1 + issues = pd.read_parquet(result.issues_path) + assert "missing_fundamentals" in set(issues["reason"]) + + +def test_run_forensic_evaluator_excludes_delisted_bankruptcy_from_prices(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "DELIST", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + _write_fundamentals( + lake, + period="2024Q4", + row=_healthy_fundamentals_row(cik=CIK, ticker="DELIST", fiscal_period_end="2024-12-31"), + ) + _write_prices_snapshot( + lake, + rows=[ + { + "ticker": "DELIST", + "adj_close": 1.0, + "listing_status": "delisted", + "delisting_reason": "bankruptcy", + } + ], + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + exclusions = pd.read_parquet(result.exclusions_path) + assert "BK_DELISTED" in set(exclusions["rule_id"]) + + +def test_run_forensic_evaluator_writes_beneish_percentile_exclusion(lake: Path) -> None: + tickers = ( + ("GOOD1", CIK, 100.0), + ("GOOD2", CIK_B, 120.0), + ("BAD", CIK_C, 500.0), + ) + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": ticker, + "cik": cik, + "industry_id": 1, + "sector": "Technology", + } + for ticker, cik, _ in tickers + ], + ) + for ticker, cik, receivable in tickers: + for period, fiscal_end in (("2023Q4", "2023-12-31"), ("2024Q4", "2024-12-31")): + _write_fundamentals( + lake, + cik=cik, + period=period, + row=_healthy_fundamentals_row( + cik=cik, + ticker=ticker, + fiscal_period_end=fiscal_end, + accounts_receivable=receivable, + ), + ) + _write_prices_snapshot( + lake, + rows=[ + { + "ticker": ticker, + "adj_close": 10.0, + "shares_outstanding": 10.0, + "listing_status": pd.NA, + "delisting_reason": pd.NA, + } + for ticker, _, _ in tickers + ], + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + exclusions = pd.read_parquet(result.exclusions_path) + percentile_rows = exclusions.loc[exclusions["rule_id"] == "FRD_BENEISH_BOTTOM_PCT"] + assert len(percentile_rows) == 1 + assert percentile_rows.iloc[0]["subfilter"] == "forensic_percentile" + assert result.issues_path is not None + issues = pd.read_parquet(result.issues_path) + assert issues["rule_id"].notna().any() + + +def test_run_forensic_evaluator_routes_missing_beneish_inputs_to_review_queue(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "PART", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + for period, fiscal_end in (("2023Q4", "2023-12-31"), ("2024Q4", "2024-12-31")): + _write_fundamentals( + lake, + period=period, + row=_healthy_fundamentals_row( + cik=CIK, + ticker="PART", + fiscal_period_end=fiscal_end, + accounts_receivable=None, + ), + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + issues = pd.read_parquet(result.issues_path) + assert any("missing beneish inputs" in str(reason) for reason in issues["reason"]) + + +def test_run_forensic_evaluator_cli_echoes_review_queue_path(lake: Path) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "NCIK", + "cik": None, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + + runner = CliRunner() + result = runner.invoke( + main, + ["--data-dir", str(lake), "--run-date", RUN_DATE.isoformat()], + ) + assert result.exit_code == 0 + assert "Review queue:" in result.output + + +def test_run_forensic_evaluator_main_module_entrypoint( + lake: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_universe( + lake, + rows=[ + { + "run_date": RUN_DATE.isoformat(), + "ticker": "DIST", + "cik": CIK, + "industry_id": 1, + "sector": "Technology", + } + ], + ) + _write_fundamentals( + lake, + period="2024Q4", + row={ + "cik": CIK, + "ticker": "DIST", + "as_of_date": pd.Timestamp("2026-06-01"), + "fiscal_period_end": pd.Timestamp("2024-12-31"), + "version_id": 1, + "statement_variant": "annual", + "stockholders_equity": -50.0, + }, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "run-forensic-evaluator", + "--data-dir", + str(lake), + "--run-date", + RUN_DATE.isoformat(), + ], + ) + with pytest.raises(SystemExit) as exc_info: + runpy.run_module("smartwealthai.run_forensic_evaluator", run_name="__main__") + assert exc_info.value.code == 0 + assert curated_permanent_loss_exclusions_path(lake, run_date=RUN_DATE).exists() + + +def test_run_forensic_evaluator_skips_issues_file_when_universe_empty(lake: Path) -> None: + _write_universe(lake, rows=[]) + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + assert result.issues_path is None + assert result.review_count == 0 diff --git a/tests/test_forensic_percentile_gate.py b/tests/test_forensic_percentile_gate.py new file mode 100644 index 0000000..1671dcb --- /dev/null +++ b/tests/test_forensic_percentile_gate.py @@ -0,0 +1,28 @@ +"""Tests for forensic bottom-percentile gate (issue #89).""" + +from __future__ import annotations + +from smartwealthai.forensic_percentile_gate import ForensicScore, apply_bottom_percentile_gate + + +def _score(ticker: str, value: float) -> ForensicScore: + return ForensicScore( + ticker=ticker, + cik=f"cik-{ticker}", + model="beneish", + score=value, + rule_version="beneish_v1", + market_cap=float(ord(ticker[0])), + ) + + +def test_bottom_five_percent_excludes_worst_score_among_twenty() -> None: + scores = [_score(f"T{i:02d}", float(i)) for i in range(20)] + excluded = apply_bottom_percentile_gate(scores) + assert len(excluded) == 1 + assert excluded[0].ticker == "T19" + assert excluded[0].rule_id == "FRD_BENEISH_BOTTOM_PCT" + + +def test_bottom_gate_returns_empty_for_empty_input() -> None: + assert apply_bottom_percentile_gate([]) == [] diff --git a/tests/test_permanent_loss_regression.py b/tests/test_permanent_loss_regression.py new file mode 100644 index 0000000..8387917 --- /dev/null +++ b/tests/test_permanent_loss_regression.py @@ -0,0 +1,113 @@ +"""CI regression: Enron, Lehman, WorldCom must be excluded at documented distress dates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +import pandas as pd +import pytest + +from smartwealthai.distress_rules import evaluate_distress_rules +from smartwealthai.lake_paths import pad_cik +from smartwealthai.permanent_loss_config import load_distress_rules_config + +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "permanent_loss" + + +@dataclass(frozen=True) +class RegressionCase: + name: str + ticker: str + cik: str + decision_date: date + fundamentals: dict[str, float] + + +REGRESSION_CASES: tuple[RegressionCase, ...] = ( + RegressionCase( + name="Enron Q3 2001", + ticker="ENRN", + cik=pad_cik("1024401"), + decision_date=date(2001, 11, 15), + fundamentals={ + "current_assets": 10_000.0, + "current_liabilities": 25_000.0, + "retained_earnings": -20_000.0, + "ebit": -500.0, + "total_assets": 40_000.0, + "total_liabilities": 45_000.0, + "revenue": 30_000.0, + "stockholders_equity": -5_000.0, + "interest_expense": 800.0, + "cash": 500.0, + "short_term_debt": 8_000.0, + "long_term_debt": 15_000.0, + "depreciation_amortization": 200.0, + "operating_cash_flow": -1_000.0, + }, + ), + RegressionCase( + name="Lehman Q2 2008", + ticker="LEH", + cik=pad_cik("806085"), + decision_date=date(2008, 9, 15), + fundamentals={ + "current_assets": 50_000.0, + "current_liabilities": 120_000.0, + "retained_earnings": -5_000.0, + "ebit": -200.0, + "total_assets": 600_000.0, + "total_liabilities": 650_000.0, + "revenue": 10_000.0, + "stockholders_equity": -50_000.0, + "interest_expense": 2_000.0, + "cash": 2_000.0, + "short_term_debt": 100_000.0, + "long_term_debt": 150_000.0, + "depreciation_amortization": 500.0, + "operating_cash_flow": -5_000.0, + }, + ), + RegressionCase( + name="WorldCom Q1 2002", + ticker="MCWE", + cik=pad_cik("723527"), + decision_date=date(2002, 5, 15), + fundamentals={ + "current_assets": 8_000.0, + "current_liabilities": 15_000.0, + "retained_earnings": -30_000.0, + "ebit": -1_000.0, + "total_assets": 100_000.0, + "total_liabilities": 110_000.0, + "revenue": 20_000.0, + "stockholders_equity": -10_000.0, + "interest_expense": 1_500.0, + "cash": 1_000.0, + "short_term_debt": 12_000.0, + "long_term_debt": 20_000.0, + "depreciation_amortization": 800.0, + "operating_cash_flow": -2_000.0, + }, + ), +) + + +@pytest.mark.parametrize("case", REGRESSION_CASES, ids=[case.name for case in REGRESSION_CASES]) +def test_distress_regression_case_is_excluded(case: RegressionCase) -> None: + """Each historical bankruptcy case must trip at least one hard distress rule.""" + from smartwealthai.distress_rules import distress_inputs_from_row + + inputs = distress_inputs_from_row(case.fundamentals, market_cap=1_000.0) + results = evaluate_distress_rules(inputs, config=load_distress_rules_config()) + excluded = [result for result in results if result.status == "exclude"] + assert excluded, f"{case.name} ({case.ticker}) must be excluded by distress rules" + + +def test_regression_fixture_manifest_exists() -> None: + manifest = FIXTURE_DIR / "cases.json" + assert manifest.exists() + frame = pd.read_json(manifest) + assert len(frame) == 3