Code / src/anomaly_atlas/validation/artifacts.py
src/anomaly_atlas/validation/artifacts.py
120 lines
# =============================================================================
# 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