diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc index d323765..72dc724 100644 --- a/.cursor/rules/git-conventions.mdc +++ b/.cursor/rules/git-conventions.mdc @@ -58,8 +58,9 @@ Examples: `feat/add-google-auth`, `fix/PROJ-456-handle-null-responses`. 1. **Protected branches:** `main` (or `master`) — no direct commits; merge via PR. 2. **Feature work:** branch from `main`, commit with conventional messages, rebase on `origin/main` before push. -3. **Pull requests:** target `main`; clear title/body; tests green; resolve conflicts; prefer squash merge when appropriate. -4. **Releases:** `release/vX.Y.Z` from `main`, merge via PR, then tag on `main` (e.g. `v1.0.0`). +3. **Before every commit:** run `make format` (ruff format) and include any formatting changes in the same commit or an immediate `style:` commit — never commit unformatted Python. +4. **Pull requests:** target `main`; clear title/body; tests green; resolve conflicts; prefer squash merge when appropriate. +5. **Releases:** `release/vX.Y.Z` from `main`, merge via PR, then tag on `main` (e.g. `v1.0.0`). ## Examples diff --git a/AGENTS.md b/AGENTS.md index 701b97b..88dfd4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ poetry install poetry run pytest ``` +**Before every commit:** run `make format` (see `.cursor/rules/git-conventions.mdc`). + ### MLflow (demo pipeline runs) `mlflow-skinny` is a Poetry dependency (`poetry install` is enough for logging). No local MLflow UI server is required for #61. diff --git a/CONTEXT.md b/CONTEXT.md index e2af9a6..96b6ebb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -65,19 +65,19 @@ Classic 8-variable forensic accounting model that estimates **manipulation risk* _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. +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`) and bottom 5% by **COMBOACCRUAL** (`FRD_COMBOACCRUAL_BOTTOM_PCT`). No absolute academic cutoffs 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**. +Accrual-flow manipulation signal from *Quantitative Value* Ch. 3: `(net income - operating cash flow) / total assets`. Higher STA → higher manipulation risk. Implemented in `accrual_scores.py`; 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)**. +Accrual-stock manipulation signal from *Quantitative Value* Ch. 3: `(operating assets - operating liabilities) / lagged total assets` (falls back to current total assets when prior period missing). Higher SNOA → higher manipulation risk. Implemented in `accrual_scores.py`. _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. +Average cross-sectional percentile of **STA** and **SNOA** on a run date. Bottom 5% hard-excluded via `FRD_COMBOACCRUAL_BOTTOM_PCT` (`comboaccrual_gate_v1.yaml`). _Avoid_: averaging raw STA/SNOA values without cross-sectional percentiles **Forensic model fusion (phase 2b)**: diff --git a/config/permanent_loss/accrual_v1.yaml b/config/permanent_loss/accrual_v1.yaml new file mode 100644 index 0000000..b30c3a0 --- /dev/null +++ b/config/permanent_loss/accrual_v1.yaml @@ -0,0 +1 @@ +version: accrual_v1 diff --git a/config/permanent_loss/comboaccrual_gate_v1.yaml b/config/permanent_loss/comboaccrual_gate_v1.yaml new file mode 100644 index 0000000..4aacd44 --- /dev/null +++ b/config/permanent_loss/comboaccrual_gate_v1.yaml @@ -0,0 +1,5 @@ +version: comboaccrual_gate_v1 + +bottom_percentile: 0.05 +rule_id: FRD_COMBOACCRUAL_BOTTOM_PCT +score_label: comboaccrual diff --git a/config/permanent_loss/forensic_gate_v1.yaml b/config/permanent_loss/forensic_gate_v1.yaml index 6673c58..46188ee 100644 --- a/config/permanent_loss/forensic_gate_v1.yaml +++ b/config/permanent_loss/forensic_gate_v1.yaml @@ -2,3 +2,4 @@ version: forensic_gate_v1 bottom_percentile: 0.05 rule_id: FRD_BENEISH_BOTTOM_PCT +score_label: beneish_m_score diff --git a/spec/backlog/backlog.md b/spec/backlog/backlog.md index 191ba35..d79f6c9 100644 --- a/spec/backlog/backlog.md +++ b/spec/backlog/backlog.md @@ -10,7 +10,7 @@ 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) | +| 7 | **Forensic model zoo (post–phase 2a), in order:** (1) **PFD** bottom-5% gate alongside `BK_*`; (2) EDGAR **confirmed fraud signals**; (3) 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) | diff --git a/spec/features/008-permanent-loss-filter/spec.md b/spec/features/008-permanent-loss-filter/spec.md index 79c3d02..6a14076 100644 --- a/spec/features/008-permanent-loss-filter/spec.md +++ b/spec/features/008-permanent-loss-filter/spec.md @@ -10,6 +10,7 @@ | --- | --- | | `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/accrual_scores.py` | STA, SNOA, and COMBOACCRUAL cross-sectional scores | | `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` | @@ -18,12 +19,15 @@ | --- | --- | | `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 | +| `config/permanent_loss/accrual_v1.yaml` | STA / SNOA metric version | +| `config/permanent_loss/forensic_gate_v1.yaml` | Beneish bottom-percentile gate | +| `config/permanent_loss/comboaccrual_gate_v1.yaml` | COMBOACCRUAL 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_accrual_scores.py` | STA / SNOA / COMBOACCRUAL 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 | @@ -44,12 +48,14 @@ Identify companies in the investable universe with elevated risk of permanent ca ## 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. +- **STA** and **SNOA** (`accrual_v1`): scaled total accruals and scaled net operating assets from latest PIT fundamentals (SNOA uses prior-period total assets when available). +- **COMBOACCRUAL** (`FRD_COMBOACCRUAL_BOTTOM_PCT`): average cross-sectional percentile of STA and SNOA; bottom-5% hard exclusion among distress survivors. +- **QVAL-style bottom-5% gate** (`FRD_BENEISH_BOTTOM_PCT`): cross-sectional hard exclusion on Beneish survivors after distress rules. ## Out of MVP scope - 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**. +- **Phase 2b forensic extensions (backlog):** PFD (Campbell distress logit); additional manipulation models (e.g. Dechow); embedding/ML fusion of forensic scores; EDGAR **confirmed fraud signals** when SEC ETL is wired. - 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. @@ -101,6 +107,7 @@ The MVP fraud signals are intentionally narrow. They detect structural / account | `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% | +| `FRD_COMBOACCRUAL_BOTTOM_PCT` | COMBOACCRUAL (avg STA/SNOA percentiles) in bottom 5% cross-sectionally | bottom 5% | 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. @@ -116,8 +123,8 @@ flowchart TD Fraud --> Decision Decision -->|Yes| Exclude["Exclude (hard)"] - Decision -->|No| Beneish["Beneish M-Score cross-section"] - Beneish --> Gate["Bottom 5% hard exclude"] + Decision -->|No| Manipulation["Beneish + COMBOACCRUAL cross-section"] + Manipulation --> Gate["Bottom 5% hard exclude per model"] Gate --> Pass["Pass to scoring modules"] Exclude --> Output["curated/permanent_loss exclusions.parquet"] @@ -136,7 +143,7 @@ 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. On survivors, compute Beneish M-Score and apply the bottom-5% cross-sectional gate. +5. On survivors, compute Beneish M-Score and STA/SNOA; build COMBOACCRUAL cross-sectional scores; apply bottom-5% gates for Beneish and COMBOACCRUAL. 6. Write the exclusion parquet and log MLflow metrics (MLflow deferred). 7. Hand the passing set of `(cik, ticker)` to the scoring modules. @@ -147,7 +154,9 @@ flowchart TD - [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] STA and SNOA computed from PIT fundamentals with versioned config (`accrual_v1`). +- [x] COMBOACCRUAL bottom-5% gate excludes names per QVAL process (`FRD_COMBOACCRUAL_BOTTOM_PCT`). +- [x] Bottom-5% forensic percentile gate excludes names per QVAL process (Beneish). - [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). @@ -155,10 +164,10 @@ flowchart TD ## 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 2a forensic manipulation screen = **Beneish M-Score + STA/SNOA/COMBOACCRUAL bottom-5% gates** (`spec/backlog/backlog.md` for 2b items). +- **Closed:** Phase 2b forensic extensions follow a **hybrid path**: separate bottom-5% gates first (PFD next); 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`). +- **Closed:** Phase 2b implementation order: **(1) PFD → (2) EDGAR confirmed fraud → (3) 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/accrual_scores.py b/src/smartwealthai/accrual_scores.py new file mode 100644 index 0000000..039aeb5 --- /dev/null +++ b/src/smartwealthai/accrual_scores.py @@ -0,0 +1,165 @@ +"""STA, SNOA, and COMBOACCRUAL forensic accrual metrics (QVAL Ch. 3).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from smartwealthai.forensic_percentile_gate import ForensicScore +from smartwealthai.permanent_loss_config import load_accrual_config + +STA_REQUIRED_FIELDS: tuple[str, ...] = ("net_income", "operating_cash_flow", "total_assets") +SNOA_REQUIRED_FIELDS: tuple[str, ...] = ( + "total_assets", + "cash", + "total_liabilities", + "short_term_debt", + "long_term_debt", +) + + +@dataclass(frozen=True) +class AccrualMetrics: + """Point-in-time STA and SNOA for one issuer.""" + + sta: float | None + snoa: 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 missing_accrual_fields(row: pd.Series) -> tuple[str, ...]: + """Return canonical fields missing for STA or SNOA on one row.""" + missing = [field for field in STA_REQUIRED_FIELDS if _row_value(row, field) is None] + missing.extend(field for field in SNOA_REQUIRED_FIELDS if _row_value(row, field) is None) + return tuple(dict.fromkeys(missing)) + + +def compute_sta(row: pd.Series) -> float | None: + """Scaled total accruals: (net income - operating cash flow) / total assets.""" + net_income = _row_value(row, "net_income") + operating_cash_flow = _row_value(row, "operating_cash_flow") + total_assets = _row_value(row, "total_assets") + if net_income is None or operating_cash_flow is None: + return None + return _safe_ratio(net_income - operating_cash_flow, total_assets) + + +def compute_snoa(row: pd.Series, *, lagged_total_assets: float | None = None) -> float | None: + """Scaled net operating assets per Gray/Carlisle (QVAL Ch. 3).""" + total_assets = _row_value(row, "total_assets") + cash = _row_value(row, "cash") + total_liabilities = _row_value(row, "total_liabilities") + short_term_debt = _row_value(row, "short_term_debt") or 0.0 + long_term_debt = _row_value(row, "long_term_debt") or 0.0 + if total_assets is None or cash is None or total_liabilities is None: + return None + + operating_assets = total_assets - cash + operating_liabilities = total_liabilities - short_term_debt - long_term_debt + denominator = lagged_total_assets if lagged_total_assets is not None else total_assets + return _safe_ratio(operating_assets - operating_liabilities, denominator) + + +def compute_accrual_metrics( + row: pd.Series, + *, + prior_row: pd.Series | None = None, + config: dict | None = None, +) -> AccrualMetrics: + """Compute STA and SNOA for the latest PIT fundamentals row.""" + cfg = config or load_accrual_config() + version = str(cfg["version"]) + missing = missing_accrual_fields(row) + if missing: + return AccrualMetrics(sta=None, snoa=None, rule_version=version, missing_fields=missing) + + lagged_assets = _row_value(prior_row, "total_assets") if prior_row is not None else None + sta = compute_sta(row) + snoa = compute_snoa(row, lagged_total_assets=lagged_assets) + if sta is None or snoa is None: + return AccrualMetrics( + sta=sta, + snoa=snoa, + rule_version=version, + missing_fields=("derived_ratio",), + ) + return AccrualMetrics(sta=sta, snoa=snoa, rule_version=version, missing_fields=()) + + +def _percentile_rank_higher_is_worse(values: dict[str, float]) -> dict[str, float]: + """Map ticker -> percentile in [0, 1]; higher raw value -> higher percentile.""" + if not values: + return {} + if len(values) == 1: + return {next(iter(values)): 0.5} + + ordered = sorted(values.items(), key=lambda item: item[1]) + ranks: dict[str, float] = {} + index = 0 + count = len(ordered) + while index < count: + end = index + while end + 1 < count and ordered[end + 1][1] == ordered[index][1]: + end += 1 + avg_rank = (index + end) / 2.0 + percentile = avg_rank / (count - 1) + for offset in range(index, end + 1): + ranks[ordered[offset][0]] = percentile + index = end + 1 + return ranks + + +def build_comboaccrual_forensic_scores( + candidates: list[tuple[str, str, AccrualMetrics, float]], +) -> list[ForensicScore]: + """Build COMBOACCRUAL scores as the average STA/SNOA cross-sectional percentiles.""" + scored: list[tuple[str, str, float, str, float]] = [] + sta_values: dict[str, float] = {} + snoa_values: dict[str, float] = {} + + for ticker, cik, metrics, market_cap in candidates: + if metrics.sta is None or metrics.snoa is None: + continue + sta_values[ticker] = metrics.sta + snoa_values[ticker] = metrics.snoa + scored.append((ticker, cik, market_cap, metrics.rule_version, 0.0)) + + if not scored: + return [] + + sta_percentiles = _percentile_rank_higher_is_worse(sta_values) + snoa_percentiles = _percentile_rank_higher_is_worse(snoa_values) + return [ + ForensicScore( + ticker=ticker, + cik=cik, + model="comboaccrual", + score=(sta_percentiles[ticker] + snoa_percentiles[ticker]) / 2.0, + rule_version=rule_version, + market_cap=market_cap, + ) + for ticker, cik, market_cap, rule_version, _ in scored + ] diff --git a/src/smartwealthai/forensic_evaluator.py b/src/smartwealthai/forensic_evaluator.py index f1ed49d..f97e847 100644 --- a/src/smartwealthai/forensic_evaluator.py +++ b/src/smartwealthai/forensic_evaluator.py @@ -8,6 +8,11 @@ import pandas as pd +from smartwealthai.accrual_scores import ( + AccrualMetrics, + build_comboaccrual_forensic_scores, + compute_accrual_metrics, +) from smartwealthai.beneish_score import compute_beneish_m_score from smartwealthai.distress_rules import ( EXCLUSION_COLUMNS, @@ -25,7 +30,9 @@ pad_cik, ) from smartwealthai.permanent_loss_config import ( + load_accrual_config, load_beneish_config, + load_comboaccrual_gate_config, load_distress_rules_config, load_forensic_gate_config, ) @@ -110,6 +117,12 @@ def _load_fundamentals_history( return selected.sort_values("fiscal_period_end").reset_index(drop=True) +def _prior_fundamentals_row(history: pd.DataFrame) -> pd.Series | None: + if len(history) < 2: + return None + return history.sort_values("fiscal_period_end").iloc[-2] + + def _evaluation_to_exclusion_row( *, cik: str, @@ -154,12 +167,16 @@ def run_forensic_evaluator( run_date: date, distress_config: dict | None = None, beneish_config: dict | None = None, + accrual_config: dict | None = None, gate_config: dict | None = None, + comboaccrual_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() + accrual_cfg = accrual_config or load_accrual_config() gate_cfg = gate_config or load_forensic_gate_config() + combo_cfg = comboaccrual_gate_config or load_comboaccrual_gate_config() universe_path = curated_universe_path(data_dir, run_date=run_date) if not universe_path.exists(): @@ -174,6 +191,7 @@ def run_forensic_evaluator( exclusion_rows: list[dict[str, object]] = [] review_rows: list[dict[str, object]] = [] beneish_candidates: list[ForensicScore] = [] + accrual_candidates: list[tuple[str, str, AccrualMetrics, float]] = [] hard_excluded: set[str] = set() for _, row in universe.iterrows(): @@ -248,6 +266,28 @@ def run_forensic_evaluator( continue history = _load_fundamentals_history(data_dir, cik=cik, as_of_date=run_date) + prior_row = _prior_fundamentals_row(history) + market_cap = _market_cap(fundamentals, price_row) or 0.0 + + accrual = compute_accrual_metrics( + fundamentals, + prior_row=prior_row, + config=accrual_cfg, + ) + if accrual.sta is None or accrual.snoa is None: + if accrual.missing_fields: + review_rows.append( + { + "run_date": run_date.isoformat(), + "ticker": ticker, + "cik": cik, + "reason": f"missing accrual inputs: {','.join(accrual.missing_fields)}", + "rule_id": "FRD_COMBOACCRUAL", + } + ) + else: + accrual_candidates.append((ticker, cik, accrual, market_cap)) + beneish = compute_beneish_m_score(history, config=beneish_cfg) if beneish.m_score is None: if beneish.missing_fields: @@ -269,7 +309,7 @@ def run_forensic_evaluator( model="beneish", score=beneish.m_score, rule_version=beneish.rule_version, - market_cap=_market_cap(fundamentals, price_row) or 0.0, + market_cap=market_cap, ) ) @@ -280,6 +320,14 @@ def run_forensic_evaluator( _percentile_to_exclusion_row(exclusion=percentile_exclusion, as_of_date=run_date) ) + combo_scores = build_comboaccrual_forensic_scores(accrual_candidates) + for percentile_exclusion in apply_bottom_percentile_gate(combo_scores, config=combo_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)) diff --git a/src/smartwealthai/forensic_percentile_gate.py b/src/smartwealthai/forensic_percentile_gate.py index d9e9bfb..8ac4d90 100644 --- a/src/smartwealthai/forensic_percentile_gate.py +++ b/src/smartwealthai/forensic_percentile_gate.py @@ -46,6 +46,7 @@ def apply_bottom_percentile_gate( cfg = config or load_forensic_gate_config() percentile = float(cfg["bottom_percentile"]) rule_id = str(cfg["rule_id"]) + score_label = str(cfg.get("score_label", "score")) exclude_count = max(1, math.ceil(len(scores) * percentile)) ordered = sorted(scores, key=lambda row: (-row.score, row.market_cap)) @@ -59,7 +60,7 @@ def apply_bottom_percentile_gate( rule_id=rule_id, rule_version=row.rule_version, threshold_percentile=percentile, - explanation=f"beneish_m_score={row.score:.4f} in bottom {percentile:.0%}", + explanation=f"{score_label}={row.score:.4f} in bottom {percentile:.0%}", ) for row in excluded ] diff --git a/src/smartwealthai/permanent_loss_config.py b/src/smartwealthai/permanent_loss_config.py index a851664..5a519f1 100644 --- a/src/smartwealthai/permanent_loss_config.py +++ b/src/smartwealthai/permanent_loss_config.py @@ -40,3 +40,21 @@ def load_forensic_gate_config( ) -> dict: """Return cross-sectional forensic percentile gate settings.""" return load_yaml_config(config_dir / filename) + + +def load_accrual_config( + config_dir: Path = DEFAULT_CONFIG_DIR, + *, + filename: str = "accrual_v1.yaml", +) -> dict: + """Return STA / SNOA accrual metric version metadata.""" + return load_yaml_config(config_dir / filename) + + +def load_comboaccrual_gate_config( + config_dir: Path = DEFAULT_CONFIG_DIR, + *, + filename: str = "comboaccrual_gate_v1.yaml", +) -> dict: + """Return COMBOACCRUAL bottom-percentile gate settings.""" + return load_yaml_config(config_dir / filename) diff --git a/tests/test_accrual_scores.py b/tests/test_accrual_scores.py new file mode 100644 index 0000000..9f00b9d --- /dev/null +++ b/tests/test_accrual_scores.py @@ -0,0 +1,87 @@ +"""Tests for STA, SNOA, and COMBOACCRUAL accrual metrics (issue #89).""" + +from __future__ import annotations + +import pandas as pd + +from smartwealthai.accrual_scores import ( + build_comboaccrual_forensic_scores, + compute_accrual_metrics, + compute_snoa, + compute_sta, + missing_accrual_fields, +) + + +def _fundamentals_row(**overrides: object) -> pd.Series: + row: dict[str, object] = { + "net_income": 80.0, + "operating_cash_flow": 120.0, + "total_assets": 2000.0, + "cash": 100.0, + "total_liabilities": 800.0, + "short_term_debt": 50.0, + "long_term_debt": 300.0, + } + row.update(overrides) + return pd.Series(row) + + +def test_compute_sta_is_net_income_minus_cfo_over_assets() -> None: + row = _fundamentals_row() + assert compute_sta(row) == (80.0 - 120.0) / 2000.0 + + +def test_compute_snoa_uses_operating_assets_minus_liabilities() -> None: + row = _fundamentals_row() + operating_assets = 2000.0 - 100.0 + operating_liabilities = 800.0 - 50.0 - 300.0 + assert compute_snoa(row) == (operating_assets - operating_liabilities) / 2000.0 + + +def test_compute_snoa_uses_lagged_total_assets_when_provided() -> None: + row = _fundamentals_row() + operating_assets = 2000.0 - 100.0 + operating_liabilities = 800.0 - 50.0 - 300.0 + assert ( + compute_snoa(row, lagged_total_assets=1600.0) + == (operating_assets - operating_liabilities) / 1600.0 + ) + + +def test_missing_accrual_fields_detects_required_columns() -> None: + row = _fundamentals_row(cash=None) + missing = missing_accrual_fields(row) + assert "cash" in missing + + +def test_compute_accrual_metrics_returns_sta_and_snoa() -> None: + current = _fundamentals_row() + prior = _fundamentals_row(total_assets=1800.0) + result = compute_accrual_metrics(current, prior_row=prior) + assert result.sta is not None + assert result.snoa is not None + assert result.rule_version == "accrual_v1" + assert not result.missing_fields + + +def test_build_comboaccrual_forensic_scores_averages_percentiles() -> None: + candidates = [ + ( + "LOW", + "cik-low", + compute_accrual_metrics(_fundamentals_row(net_income=50.0, operating_cash_flow=120.0)), + 1.0, + ), + ( + "HIGH", + "cik-high", + compute_accrual_metrics(_fundamentals_row(net_income=500.0, operating_cash_flow=120.0)), + 2.0, + ), + ] + scores = build_comboaccrual_forensic_scores(candidates) + assert len(scores) == 2 + by_ticker = {row.ticker: row.score for row in scores} + assert by_ticker["HIGH"] > by_ticker["LOW"] + assert all(row.model == "comboaccrual" for row in scores) diff --git a/tests/test_forensic_evaluator.py b/tests/test_forensic_evaluator.py index 6145a90..5916d1d 100644 --- a/tests/test_forensic_evaluator.py +++ b/tests/test_forensic_evaluator.py @@ -401,6 +401,47 @@ def test_run_forensic_evaluator_routes_missing_beneish_inputs_to_review_queue(la assert any("missing beneish inputs" in str(reason) for reason in issues["reason"]) +def test_run_forensic_evaluator_writes_comboaccrual_percentile_exclusion(lake: Path) -> None: + tickers = ( + ("LOW", CIK, 80.0, 120.0), + ("MID", CIK_B, 120.0, 110.0), + ("HIGH", CIK_C, 500.0, 100.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, net_income, operating_cash_flow 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, + net_income=net_income, + operating_cash_flow=operating_cash_flow, + ), + ) + + result = run_forensic_evaluator(lake, run_date=RUN_DATE) + exclusions = pd.read_parquet(result.exclusions_path) + combo_rows = exclusions.loc[exclusions["rule_id"] == "FRD_COMBOACCRUAL_BOTTOM_PCT"] + assert len(combo_rows) == 1 + assert combo_rows.iloc[0]["subfilter"] == "forensic_percentile" + + def test_run_forensic_evaluator_cli_echoes_review_queue_path(lake: Path) -> None: _write_universe( lake,