# ============================================================================= # Project : anomaly-atlas # File : src/anomaly_atlas/validation/artifacts.py # Purpose : Artifact detectors: Roll bounce, staleness, LOCF resampling # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Data src : hfmarketdata.io (sole data source) # Created : 2026-08-12 # Modified : 2026-08-12 # Platform : macOS / Apple Silicon (arm64) # License : All rights reserved (research code) # ============================================================================= """Detectors for the mechanisms that manufacture fake anomalies in bar data. Doctrine (charter §2.1): every candidate anomaly must first be explained by these nulls before it may be called a regularity. Each function is validated on synthetic ground truth (charter §8.1). """ from __future__ import annotations import numpy as np from anomaly_atlas.stats.reversion import autocov1 def roll_spread(returns: np.ndarray) -> float: """Roll (1984) implied effective spread: 2*sqrt(-Cov(r_t, r_{t-1})). In log-return space this is the RELATIVE spread. Returns NaN when the lag-1 autocovariance is non-negative (estimator undefined — typical for momentum or noise-free series). """ cov = autocov1(returns) if not np.isfinite(cov) or cov >= 0.0: return float("nan") return float(2.0 * np.sqrt(-cov)) def bounce_implied_ac1(returns: np.ndarray) -> float: """The lag-1 autocorrelation a pure Roll bounce would produce for this series: -s^2/4 divided by Var(r), with s the Roll implied spread. Because s is estimated FROM the lag-1 autocovariance, this equals the measured AC1 whenever AC1 < 0 — the useful output is the DECOMPOSITION: ``excess_reversion`` reports how much reversion remains after removing the bounce explainable by the observed spread level. """ r = np.asarray(returns, dtype=float) s = roll_spread(r) if not np.isfinite(s): return 0.0 var = r.var() if var == 0.0: return float("nan") return float(-(s**2) / 4.0 / var) def excess_reversion(returns: np.ndarray, rel_spread: float) -> float: """Artifact-adjusted AC1: measured AC1 minus the bounce null implied by an INDEPENDENT spread estimate ``rel_spread`` (e.g. a liquidity-matched spread level, or a quoted/estimated spread from another source). For a pure Roll series with the true spread supplied, this is ≈ 0. A genuinely mean-reverting series keeps a negative excess. """ r = np.asarray(returns, dtype=float) var = r.var() if var == 0.0 or len(r) < 3: return float("nan") from anomaly_atlas.stats.reversion import ac1 bounce_ac1 = -(rel_spread**2) / 4.0 / var return float(ac1(r) - bounce_ac1) def edge_spread( opens: np.ndarray, highs: np.ndarray, lows: np.ndarray, closes: np.ndarray ) -> float: """EDGE relative effective spread (Ardia, Guidotti & Kroencke 2024) from OHLC bars, via the authors' `bidask` implementation. Independent of 1min AC1 (uses O/H/L/C geometry), so it can serve as the independent spread input to `excess_reversion` without circularity. Returns NaN when the estimator is undefined for the sample. """ from bidask import edge try: est = edge( np.asarray(opens, float), np.asarray(highs, float), np.asarray(lows, float), np.asarray(closes, float), ) except Exception: return float("nan") return float(est) if np.isfinite(est) else float("nan") def staleness_ratio(observed_mask: np.ndarray) -> float: """Fraction of grid slots WITHOUT a fresh print (0 = fully fresh).""" m = np.asarray(observed_mask, dtype=bool) if len(m) == 0: return float("nan") return float(1.0 - m.mean()) def locf_fill(values: np.ndarray, observed_mask: np.ndarray) -> np.ndarray: """Last-observation-carried-forward fill of a gridded series. Slots before the first observation keep their original value. This is the (dangerous) join that manufactures stale-price artifacts — it exists here so experiments can measure that artifact explicitly. """ v = np.asarray(values, dtype=float).copy() m = np.asarray(observed_mask, dtype=bool) for t in range(1, len(v)): if not m[t]: v[t] = v[t - 1] return v