Перейти к содержимому
Все документы библиотеки

Проверка качества данных OHLCV и поиск аномалий и дрейфа

Код Machine Learning for Trading

Сводка

В этой тетради показан процесс проверки качества ежедневных данных US по акциям OHLCV. Структурные проверки охватывают пропуски, дубликаты, хронологический порядок, согласованность цен, отрицательные значения, устаревшие цены и экстремальные доходности. Затем структурная проверка отделяется от выявления аномалий: выбросы доходности можно отмечать методами MAD, Z-score или IQR; всплески объёма выявляются скользящими сравнениями, а неизменные цены могут указывать на устаревание данных. В тетради также измеряется сдвиг распределения с помощью индекса стабильности популяции и проверяются пропуски при загрузке, дубликаты и возможные корпоративные действия.

В примерах подчёркивается, что прошедший проверку валидатор нужно тестировать на намеренно внесённых ошибках, а одинаковый порог для разных шкал выбросов не задаёт равнозначные границы. PSI зависит от разбиения на интервалы, которое может некорректно обрабатывать совпадающие значения и наблюдения за пределами исходных экстремумов. Флаги корпоративных действий сверяются с данными о сплитах и дивидендах, показывая, что пороговые предупреждения нужно рассматривать в контексте. Использованная историческая панель Wiki/Quandl заканчивается в 2018; детектор аномалий формирует очередь для проверки, но не доказывает, что отмеченные наблюдения ошибочны.

Ключевые идеи

  • Перед использованием рыночных данных проверяй связи OHLCV, пропуски, дубликаты, порядок, устаревшие цены и экстремальные движения.
  • Тестируй валидаторы на известных ошибках: одного прохождения чистых данных недостаточно, чтобы подтвердить работу проверок.
  • MAD, Z-score и IQR используют разные оценки масштаба, поэтому общий порог задаёт разные границы доходности.
  • Результаты PSI зависят от разбиения на интервалы и могут не учитывать наблюдения за пределами исходного диапазона.
  • Предупреждения о корпоративных действиях нужно рассматривать в контексте: их могут вызвать реальные сплиты, дивиденды и обычные крупные движения.

Теги

Полный текст
# 13_data_quality_framework.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Data Quality Framework: Validation, Anomalies, and Remediation
#
# **Docker image**: `ml4t`
#
# ## Purpose
# Demonstrate the four pillars of a production data-quality pipeline applied to US equities
# daily OHLCV: structural validation (OHLC invariants, nulls, duplicates), anomaly detection
# (return outliers, volume spikes, price staleness), distribution drift (PSI), and ingestion
# hygiene (gaps, duplicates, corporate-action detection). All checks come from the
# `ml4t.data.validation` and `ml4t.data.anomaly` modules.
#
# ## Learning Objectives
# - Run `OHLCVValidator` and read its issue report, including on data with injected faults.
# - See why MAD, Z-score and IQR flag different numbers of events at the same threshold.
# - Compute the Population Stability Index, and see what its binning discards.
# - Score a corporate-action detector against the split and dividend columns of the same file.
#
# ## Book reference
# Chapter 2, §2.3 (data quality framework). Downstream chapters that consume the cleaned panel:
# `14_point_in_time_validation` (bitemporal hygiene), `15_survivorship_bias_detection`,
# `17_complete_pipeline`.
#
# ## Prerequisites
# - Quandl/Wiki US equities parquet materialized under `ML4T_DATA_PATH` (the legacy dataset;
#   ends 2018-03-27).
# - Loader `data.load_us_equities`.
# - Library packages `ml4t.data.validation` and `ml4t.data.anomaly`.

# %%
"""Data Quality Framework: validation, anomaly detection, and remediation."""

import logging
from datetime import datetime
from pathlib import Path

import numpy as np
import plotly.graph_objects as go
import polars as pl
import structlog
from ml4t.data.anomaly import (
    AnomalyManager,
    PriceStalenessDetector,
    ReturnOutlierDetector,
    VolumeSpikeDetector,
)
from ml4t.data.anomaly.config import (
    AnomalyConfig,
    PriceStalenessConfig,
    ReturnOutlierConfig,
    VolumeSpikeConfig,
)
from ml4t.data.validation import OHLCVValidator

from data import load_us_equities
from utils.paths import get_output_dir
from utils.style import COLORS, show_plotly_with_alt

# %% [markdown]
# ### Quieting the library, not the interpreter
#
# The anomaly and validation libraries log a line per symbol per detector through structlog,
# which bypasses stdlib logging, so the cells below would print the library's debug stream
# rather than their own results. Filtering structlog at WARNING fixes that and leaves
# Python's own warnings alone. A blanket `warnings.filterwarnings("ignore")` would also do
# it, and would hide the warnings this notebook needs to see: a plotting call that drops its
# alt text warns, and under Papermill that warning is the only sign it happened.

# %%
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING))


def _to_date(value: object) -> object:
    """Normalize a polars timestamp scalar to a datetime.date for printing."""
    return value.date() if hasattr(value, "date") else value


# %% [markdown]
# ### Declared parameters
#
# Every threshold each detector uses is declared here rather than written into the call that
# uses it. Two of them are worth reading before the sections that consume them.
#
# `OUTLIER_THRESHOLD` is passed unchanged to three detectors that use it as a multiplier on
# three different scale estimates, so the same number produces three different cutoffs in
# return space. Section 2 measures those cutoffs rather than leaving the comparison implicit.
#
# `SYMBOLS` holds five large-cap tickers as the legacy Wiki/Quandl vintage writes them, which
# means FB rather than META.
#
# `CORPORATE_ACTION_THRESHOLD` is the overnight return above which a move is treated as a
# candidate corporate action. The Wiki/Quandl panel carries the actual split ratios and
# ex-dividend amounts in its own columns, so Section 4 scores the threshold against them
# instead of eyeballing the flagged dates.

# %% tags=["parameters"]
SYMBOLS = ["AAPL", "MSFT", "GOOGL", "NVDA", "FB"]
DEMO_SYMBOL = "AAPL"

# Structural validation.
MAX_RETURN_THRESHOLD = 0.5  # flag a daily move larger than this
STALENESS_DAYS_VALIDATOR = 5  # flag this many identical prices in a row

# Anomaly detection.
OUTLIER_THRESHOLD = 3.0  # multiplier on each method's scale estimate
OUTLIER_MIN_SAMPLES = 20
MAD_NORMAL_SCALE = 0.6745  # MAD of a standard normal; rescales MAD to a sigma-like unit
VOLUME_WINDOW = 20
VOLUME_THRESHOLD = 3.0
STALENESS_DAYS_DETECTOR = 3

# Drift.
PSI_BINS = 10
PSI_MODERATE = 0.1  # below this, no material change
PSI_SIGNIFICANT = 0.25  # above this, act

# Hygiene.
MAX_GAP_DAYS = 5  # a longer gap than any holiday weekend produces
CORPORATE_ACTION_THRESHOLD = 0.25  # overnight return treated as a candidate action

# %%
OUTPUT_DIR = get_output_dir(2, "quality")

# %% [markdown]
# ## Load Sample Data
#
# The pre-2018 Wiki/Quandl US equities panel. The validation and anomaly detectors operate on
# any DataFrame carrying `timestamp / open / high / low / close / volume`.

# %%
wiki_df = load_us_equities()
print(
    f"US equities loaded: {len(wiki_df):,} rows; "
    f"{wiki_df['timestamp'].min()} to {wiki_df['timestamp'].max()}"
)

datasets = {
    symbol: (
        wiki_df.lazy()
        .filter(pl.col("symbol") == symbol)
        .select(["timestamp", "symbol", "open", "high", "low", "close", "volume"])
        .collect()
    )
    for symbol in SYMBOLS
}
missing = [s for s, df in datasets.items() if df.is_empty()]
if missing:
    raise RuntimeError(f"Symbols missing from Wiki/Quandl dataset: {missing}")

per_symbol_rows = pl.DataFrame(
    {
        "symbol": list(datasets),
        "rows": [len(df) for df in datasets.values()],
        "first": [_to_date(df["timestamp"].min()) for df in datasets.values()],
        "last": [_to_date(df["timestamp"].max()) for df in datasets.values()],
    }
)
per_symbol_rows

# %% [markdown]
# ---
#
# ## Part 1: OHLC Invariant Validation
#
# **OHLC invariants** are relationships that hold by the definition of the four fields:
#
# | Invariant          | Why it holds        |
# |--------------------|---------------------|
# | High >= Low        | by definition       |
# | High >= Open, Close| high is the maximum |
# | Low <= Open, Close | low is the minimum  |
# | Prices > 0         | no negative prices  |
# | Volume >= 0        | no negative volume  |
#
# A violation is a provider error, transmission corruption, or an adjustment applied to some
# fields and not others.

# %%
validator = OHLCVValidator(
    check_nulls=True,
    check_price_consistency=True,
    negative_price_policy="forbid",
    check_negative_volume=True,
    check_duplicate_timestamps=True,
    check_chronological_order=True,
    check_price_staleness=True,
    check_extreme_returns=True,
    max_return_threshold=MAX_RETURN_THRESHOLD,
    staleness_threshold=STALENESS_DAYS_VALIDATOR,
)

validation_summary = pl.DataFrame(
    [
        {
            "symbol": sym,
            "passed": (r := validator.validate(df)).passed,
            "issues": len(r.issues),
            "critical": r.critical_count,
            "errors": r.error_count,
        }
        for sym, df in datasets.items()
    ]
)
validation_summary

# %% [markdown]
# ### A check that passes has not been tested
#
# Every symbol passes, which is the outcome a clean panel should produce and also the outcome
# a broken validator would produce. The two are told apart by giving it data known to be bad:
# three faults are injected into the demo symbol below, one of each kind the validator claims
# to catch, and the report is checked for all three.

# %%
clean_df = datasets[DEMO_SYMBOL]
highs = clean_df["high"].to_numpy().copy()
lows = clean_df["low"].to_numpy().copy()
volumes = clean_df["volume"].to_numpy().copy()

FAULT_ROWS = slice(10, 13)
FAULT_VOLUME_ROW = 20
FAULT_NULL_ROW = 30

highs[FAULT_ROWS] = lows[FAULT_ROWS] - 1.0
volumes[FAULT_VOLUME_ROW] = -1000
# A genuine null, not NumPy NaN: polars stores np.nan as a float NaN that the null check
# ignores, so only a real null exercises OHLCVValidator's check_nulls.
close_col = pl.Series("close", clean_df["close"].to_numpy()).scatter(FAULT_NULL_ROW, None)

dirty_df = pl.DataFrame(
    {
        "timestamp": clean_df["timestamp"],
        "symbol": clean_df["symbol"],
        "open": clean_df["open"],
        "high": highs,
        "low": lows,
        "close": close_col,
        "volume": volumes,
    }
)

dirty_result = validator.validate(dirty_df)
print(f"Validation passed: {dirty_result.passed}")
print(f"Critical: {dirty_result.critical_count}, Errors: {dirty_result.error_count}")

_checks_fired = {issue.check for issue in dirty_result.issues}
for fault, check in [
    ("high below low", "price_consistency"),
    ("negative volume", "negative_volume"),
    ("null close", "null_values"),
]:
    print(f"  {fault:16s} -> {check:18s} {'fired' if check in _checks_fired else 'NOT FIRED'}")

dirty_issues = pl.DataFrame(
    [
        {
            "severity": issue.severity.name,
            "check": issue.check,
            "rows": issue.row_count or 0,
            "message": issue.message,
        }
        for issue in dirty_result.issues
    ]
)
dirty_issues

# %% [markdown]
# ---
#
# ## Part 2: Anomaly Detection
#
# Validation asks whether the data is structurally possible. Anomaly detection asks whether
# structurally valid data is unusual:
#
# | Detector               | What it finds                  | Method                |
# |------------------------|--------------------------------|-----------------------|
# | ReturnOutlierDetector  | flash crashes, splits, pumps   | MAD, Z-score, IQR     |
# | VolumeSpikeDetector    | unusual trading activity       | rolling Z-score       |
# | PriceStalenessDetector | data gaps, illiquid securities | consecutive-unchanged |

# %% [markdown]
# ### The same threshold is three different cutoffs
#
# All three return-outlier methods take a threshold and compare a scaled deviation against it.
# They do not scale by the same thing:
#
# - **MAD** flags a return when $|r - \mathrm{median}|$ exceeds $\tau \cdot \mathrm{MAD} /
#   c$, where MAD is the median absolute deviation from the median and $c$, declared as
#   `MAD_NORMAL_SCALE`, is the MAD of a standard normal, which puts the result on the same
#   footing as a standard deviation.
# - **Z-score** flags it when $|r - \mathrm{mean}|$ exceeds $\tau \cdot \sigma$.
# - **IQR** flags it when $r$ falls outside $[Q_1 - \tau \cdot \mathrm{IQR},\ Q_3 + \tau \cdot
#   \mathrm{IQR}]$.
#
# Running all three at one threshold therefore compares three different cutoffs, not three
# ways of applying one. The next cell prints the cutoffs the shared threshold implies in
# return space, so the counts that follow can be read against the boundary that produced them.

# %%
sample_df = datasets[DEMO_SYMBOL]
_returns = sample_df.select(pl.col("close").pct_change().alias("r"))["r"].drop_nulls()

_median = _returns.median()
_mad = (_returns - _median).abs().median()
_mean, _sd = _returns.mean(), _returns.std()
_q1, _q3 = _returns.quantile(0.25), _returns.quantile(0.75)
_iqr = _q3 - _q1

cutoffs = pl.DataFrame(
    {
        "method": ["mad", "zscore", "iqr"],
        "scale_estimate": [_mad, _sd, _iqr],
        "lower_cutoff": [
            _median - OUTLIER_THRESHOLD * _mad / MAD_NORMAL_SCALE,
            _mean - OUTLIER_THRESHOLD * _sd,
            _q1 - OUTLIER_THRESHOLD * _iqr,
        ],
        "upper_cutoff": [
            _median + OUTLIER_THRESHOLD * _mad / MAD_NORMAL_SCALE,
            _mean + OUTLIER_THRESHOLD * _sd,
            _q3 + OUTLIER_THRESHOLD * _iqr,
        ],
    }
)
print(f"{DEMO_SYMBOL} daily returns, cutoffs implied by threshold {OUTLIER_THRESHOLD}:")
cutoffs

# %%
methods = ["mad", "zscore", "iqr"]

method_summary = pl.DataFrame(
    [
        {
            "method": m,
            "anomalies": len(
                ReturnOutlierDetector(
                    config=ReturnOutlierConfig(
                        method=m,
                        threshold=OUTLIER_THRESHOLD,
                        min_samples=OUTLIER_MIN_SAMPLES,
                    )
                ).detect(sample_df, symbol=DEMO_SYMBOL)
            ),
        }
        for m in methods
    ]
).join(cutoffs, on="method")
method_summary

# %% [markdown]
# The counts follow the cutoffs: the method with the narrowest band flags the most, and the
# ordering of the counts is the ordering of the bands.
#
# The reason MAD's band is narrowest is worth stating carefully, because the intuitive version
# has it backwards. MAD is a robust scale estimate, meaning the extreme returns barely move it:
# it is the median of the absolute deviations, and a handful of enormous days cannot shift a
# median. The standard deviation has no such protection and is inflated by those same days. So
# on a heavy-tailed return series MAD comes out much smaller than sigma, the cutoff built from
# it is tighter, and more returns fall outside. MAD flags more because it *ignores* the tails
# when measuring scale, not because it is sensitive to them.

# %%
fig = go.Figure(
    go.Bar(
        x=[m.upper() for m in method_summary["method"].to_list()],
        y=method_summary["anomalies"].to_list(),
        marker_color=COLORS["slate"],
        text=method_summary["anomalies"].to_list(),
        textposition="outside",
    )
)
fig.update_layout(
    title=f"{DEMO_SYMBOL} return outliers flagged, by method",
    xaxis_title="Method",
    yaxis_title="Events flagged",
    height=400,
)
show_plotly_with_alt(
    fig,
    "A bar chart of three bars labelled MAD, ZSCORE and IQR, each showing how many return "
    "outliers that method flagged. The MAD bar is several times taller than the other two, "
    "which are of similar height to each other, with the IQR bar the shortest.",
)

# %% [markdown]
# ### What the detector cannot tell you
#
# The largest flagged moves in this series are not data faults. They are stock splits and an
# earnings crash, in a panel that stores prices unadjusted. A return-outlier detector locates
# large moves and has no way to attribute them, so the table below is a work queue rather than
# a defect list, and Section 4 shows what it takes to resolve one.

# %%
mad_anomalies = ReturnOutlierDetector(
    config=ReturnOutlierConfig(
        method="mad", threshold=OUTLIER_THRESHOLD, min_samples=OUTLIER_MIN_SAMPLES
    )
).detect(sample_df, symbol=DEMO_SYMBOL)

top_mad = pl.DataFrame(
    [
        {
            "date": _to_date(a.timestamp),
            "return_pct": float(a.value),  # value is already a percentage
        }
        for a in sorted(mad_anomalies, key=lambda x: abs(x.value), reverse=True)[:5]
    ]
)
top_mad

# %%
returns_df = sample_df.with_columns((pl.col("close").pct_change() * 100).alias("return_pct"))
anomaly_dates = {_to_date(a.timestamp) for a in mad_anomalies}
flagged = returns_df.with_columns(
    pl.col("timestamp")
    .map_elements(lambda t: _to_date(t) in anomaly_dates, return_dtype=pl.Boolean)
    .alias("flagged")
)
flagged_pts = flagged.filter(pl.col("flagged"))

fig = go.Figure()
fig.add_trace(
    go.Scatter(
        x=returns_df["timestamp"].to_list(),
        y=returns_df["return_pct"].to_list(),
        mode="lines",
        line=dict(color=COLORS["slate"], width=0.6),
        name="Daily return (%)",
    )
)
fig.add_trace(
    go.Scatter(
        x=flagged_pts["timestamp"].to_list(),
        y=flagged_pts["return_pct"].to_list(),
        mode="markers",
        marker=dict(color=COLORS["copper"], size=5),
        name="MAD-flagged",
    )
)
fig.update_layout(
    title=f"{DEMO_SYMBOL} daily returns, with MAD-flagged days marked",
    xaxis_title="Date",
    yaxis_title="Return (%)",
    height=420,
)
show_plotly_with_alt(
    fig,
    "A line chart of daily percentage returns across nearly four decades, with flagged days "
    "marked as separate points. The line is a dense band a few percent wide with occasional "
    "long excursions, one of them reaching far below the rest of the series. The marked "
    "points sit on the outer edges of the band throughout, and are denser in the earlier and "
    "more volatile stretches than in the later years.",
)

# %% [markdown]
# ### VolumeSpikeDetector
#
# Flags rolling-window volume Z-scores above a threshold.

# %%
volume_anomalies = VolumeSpikeDetector(
    config=VolumeSpikeConfig(
        window=VOLUME_WINDOW,
        threshold=VOLUME_THRESHOLD,
        min_volume=0,
        min_samples=OUTLIER_MIN_SAMPLES,
    )
).detect(sample_df, symbol=DEMO_SYMBOL)

top_volume = pl.DataFrame(
    [
        {
            "date": _to_date(a.timestamp),
            "volume": int(a.value),
            "ratio_vs_avg": (
                a.value / a.metadata["average_volume"] if a.metadata.get("average_volume") else None
            ),
        }
        for a in sorted(volume_anomalies, key=lambda x: x.value, reverse=True)[:5]
    ]
)
print(f"{DEMO_SYMBOL} volume spikes: {len(volume_anomalies)}")
top_volume

# %% [markdown]
# ### PriceStalenessDetector
#
# Flags runs of consecutive identical prices, which is what a feed outage and an untraded
# security both look like from inside the file.

# %%
stale_anomalies = PriceStalenessDetector(
    config=PriceStalenessConfig(max_unchanged_days=STALENESS_DAYS_DETECTOR, check_close_only=False)
).detect(sample_df, symbol=DEMO_SYMBOL)
print(
    f"{DEMO_SYMBOL} stale-price runs longer than {STALENESS_DAYS_DETECTOR} days: "
    f"{len(stale_anomalies)}"
)

# %% [markdown]
# ### AnomalyManager: running all three across the universe
#
# `AnomalyManager` orchestrates the three detectors and reports per symbol. It concatenates
# what each detector returns without reconciling them, so a day that is both a return outlier
# and a volume spike appears twice. The per-row column below is therefore events per row and
# not the share of rows that carry an event, and it can exceed one.

# %%
anomaly_config = AnomalyConfig(
    enabled=True,
    report_severity_threshold="warning",
    return_outliers=ReturnOutlierConfig(method="mad", threshold=OUTLIER_THRESHOLD),
    volume_spikes=VolumeSpikeConfig(window=VOLUME_WINDOW, threshold=VOLUME_THRESHOLD),
    price_staleness=PriceStalenessConfig(max_unchanged_days=STALENESS_DAYS_VALIDATOR),
)
manager = AnomalyManager(config=anomaly_config)
reports = manager.analyze_batch(datasets)

batch_summary = pl.DataFrame(
    [
        {
            "symbol": sym,
            "rows": len(datasets[sym]),
            "total_anomalies": len(rep.anomalies),
            "critical": len(rep.get_critical_anomalies()),
        }
        for sym, rep in reports.items()
    ]
).with_columns((pl.col("total_anomalies") / pl.col("rows")).alias("anomalies_per_row"))
batch_summary

# %% [markdown]
# ---
#
# ## Part 3: Population Stability Index (PSI)
#
# PSI measures distribution drift: whether recent data follows the same distribution as a
# historical baseline. Bin the baseline into quantiles, count how much of the current series
# lands in each of those bins, and sum
#
# $$\mathrm{PSI} = \sum_i (p_i^{\text{current}} - p_i^{\text{baseline}})
# \log \frac{p_i^{\text{current}}}{p_i^{\text{baseline}}}$$
#
# The conventional reading thresholds are declared in the parameters cell and printed below
# rather than written into this paragraph, so the code and the description cannot drift apart.

# %%
print("PSI reading thresholds, as declared:")
print(f"  below {PSI_MODERATE:<12} no material change")
print(f"  {f'{PSI_MODERATE} to {PSI_SIGNIFICANT}':<18} moderate shift, investigate")
print(f"  above {PSI_SIGNIFICANT:<12} significant shift, act")

# %% [markdown]
# ### The binning decides what the measure can see
#
# The bin edges come from the baseline's quantiles, so the outermost edges are the baseline's
# own minimum and maximum. A current observation beyond either edge falls outside every bin,
# and a histogram silently drops it.
#
# That is a problem specific to the thing PSI exists to detect. An observation more extreme
# than anything in the baseline is the strongest possible evidence of drift, and closed edges
# discard exactly those. The implementation below opens the outer bins to infinity and reports
# how many observations that recovers, so the cost of the closed version is visible rather
# than assumed to be small.


# %%
def calculate_psi(
    baseline: pl.Series,
    current: pl.Series,
    n_bins: int = PSI_BINS,
    epsilon: float = 1e-6,
    open_outer_bins: bool = True,
) -> tuple[float, pl.DataFrame]:
    """Population Stability Index between a baseline and a current distribution.

    With ``open_outer_bins`` the first and last bins extend to -inf and +inf, so every current
    observation is counted. With it off the outer edges are the baseline's own extremes and
    anything beyond them is dropped, which is the behaviour a plain histogram gives you.
    """
    baseline_clean = baseline.drop_nulls()
    current_clean = current.drop_nulls()

    percentiles = [i / n_bins for i in range(n_bins + 1)]
    bin_edges = [baseline_clean.quantile(p) for p in percentiles]

    unique_edges = [bin_edges[0]]
    for edge in bin_edges[1:]:
        if edge <= unique_edges[-1]:
            edge = unique_edges[-1] + epsilon
        unique_edges.append(edge)
    if open_outer_bins:
        unique_edges = [-np.inf, *unique_edges[1:-1], np.inf]

    baseline_counts = np.histogram(baseline_clean.to_numpy(), bins=unique_edges)[0]
    current_counts = np.histogram(current_clean.to_numpy(), bins=unique_edges)[0]
    baseline_pct = np.maximum(baseline_counts / len(baseline_clean), epsilon)
    current_pct = np.maximum(current_counts / len(current_clean), epsilon)

    psi_values = (current_pct - baseline_pct) * np.log(current_pct / baseline_pct)
    breakdown = pl.DataFrame(
        {
            "bin": list(range(1, n_bins + 1)),
            "baseline_pct": baseline_pct.round(4),
            "current_pct": current_pct.round(4),
            "psi_contribution": psi_values.round(4),
            "baseline_counted": baseline_counts,
            "current_counted": current_counts,
        }
    )
    return float(np.sum(psi_values)), breakdown


# %%
df = datasets[DEMO_SYMBOL].with_columns((pl.col("close").pct_change() * 100).alias("return_pct"))
midpoint = len(df) // 2
baseline_returns = df["return_pct"][:midpoint]
current_returns = df["return_pct"][midpoint:]

psi_value, psi_breakdown = calculate_psi(baseline_returns, current_returns)
psi_closed, closed_breakdown = calculate_psi(
    baseline_returns, current_returns, open_outer_bins=False
)

_current_n = current_returns.drop_nulls().len()
_dropped = _current_n - int(closed_breakdown["current_counted"].sum())
psi_severity = (
    "no material change"
    if psi_value < PSI_MODERATE
    else "moderate shift"
    if psi_value < PSI_SIGNIFICANT
    else "significant shift"
)
print(
    f"{DEMO_SYMBOL} daily-return PSI: {psi_value:.4f} ({psi_severity})\n"
    f"Baseline: {_to_date(df['timestamp'][0])} to {_to_date(df['timestamp'][midpoint])}\n"
    f"Current:  {_to_date(df['timestamp'][midpoint])} to {_to_date(df['timestamp'][-1])}"
)
print(
    f"\nWith the outer bins closed at the baseline's own range: PSI {psi_closed:.4f}, "
    f"and {_dropped} of {_current_n:,} current observations counted in no bin at all."
)
print(
    "  Those observations lie outside everything the baseline contained, which is the case "
    "the measure is for."
)
psi_breakdown

# %% [markdown]
# ### The baseline bins are not equal, and the largest one drives the result
#
# Quantile bins are supposed to hold an equal share of the baseline each, and the table above
# shows two that do not. Quantile edges cannot split a point mass: if one value occurs often
# enough to straddle an edge, every copy of it lands on one side and the two neighbouring
# bins come out lopsided. Daily returns have exactly such a point mass at zero, which the next
# cell measures on both halves.
#
# This is not bookkeeping. The per-bin contributions show where the statistic comes from.

# %%
_zero_baseline = (baseline_returns.drop_nulls() == 0).sum()
_zero_current = (current_returns.drop_nulls() == 0).sum()
_n_baseline = baseline_returns.drop_nulls().len()
_n_current = current_returns.drop_nulls().len()
print(
    f"Returns of exactly zero: baseline {_zero_baseline:,} of {_n_baseline:,} "
    f"({100 * _zero_baseline / _n_baseline:.2f}%), current {_zero_current:,} of "
    f"{_n_current:,} ({100 * _zero_current / _n_current:.2f}%)"
)

_top = psi_breakdown.sort("psi_contribution", descending=True).row(0, named=True)
print(
    f"Largest single contribution: bin {_top['bin']}, "
    f"{_top['psi_contribution']:.4f} of the total {psi_value:.4f} "
    f"({100 * _top['psi_contribution'] / psi_value:.0f}%)"
)
print(
    f"  its baseline share is {psi_breakdown['baseline_pct'][_top['bin'] - 1]:.1%}, "
    f"not the {1 / PSI_BINS:.0%} an equal split would give"
)

# %% [markdown]
# The zero-return tie group is large in the first half and nearly gone in the second, and the
# bin beside it supplies the majority of the whole statistic. The natural next move is to
# explain the tie group away, and the natural explanation is the tick size: US equities quoted
# in fractions until decimalization in 2001, so a small move could round to no move. That
# explanation is testable from this file, because it predicts *when* the tie group should
# disappear.

# %%
zeros_by_year = (
    df.drop_nulls("return_pct")
    .group_by(pl.col("timestamp").dt.year().alias("year"))
    .agg((pl.col("return_pct") == 0).mean().alias("zero_share"), pl.len().alias("days"))
    .sort("year")
)
print(f"Share of {DEMO_SYMBOL} days with a return of exactly zero, by year:")
with pl.Config(tbl_rows=-1):
    print(zeros_by_year)

# %% [markdown]
# The prediction fails. The tie group does not thin out at decimalization; it thins out in the
# late 1990s, several years earlier, and is already down to a couple of percent before 2001.
# Whatever ended it - the earlier move from eighths to sixteenths, this stock's price level
# and turnover rising through that period, or something else - one tick-size event on a known
# date does not fit the timing, and nothing computed here separates the candidates.
#
# So the section ends where the evidence does. The distribution genuinely changed. Most of the
# statistic comes from one bin, that bin's baseline share is distorted by a tie group, and the
# tie group thinned out over the late 1990s for reasons this file cannot settle. The reading
# the headline number invites - that returns grew calmer - rests on the outer bins, which do
# lose mass and which contribute a small part of the total.
#
# The general form: **a drift score locates a change and cannot attribute one.** Reading the
# per-bin contributions turns it into a question worth asking, and answering that question
# takes evidence from outside the series.

# %%
fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=psi_breakdown["bin"].to_list(),
        y=(psi_breakdown["baseline_pct"] * 100).to_list(),
        name="Baseline %",
        marker_color=COLORS["slate"],
    )
)
fig.add_trace(
    go.Bar(
        x=psi_breakdown["bin"].to_list(),
        y=(psi_breakdown["current_pct"] * 100).to_list(),
        name="Current %",
        marker_color=COLORS["copper"],
    )
)
fig.add_hline(y=100 / PSI_BINS, line_dash="dash", line_color=COLORS["amber"], line_width=1)
fig.update_layout(
    title=f"{DEMO_SYMBOL} return-decile membership, baseline against current",
    xaxis_title="Baseline decile bin",
    yaxis_title="Share of observations (%)",
    barmode="group",
    height=420,
)
show_plotly_with_alt(
    fig,
    "A grouped bar chart with ten baseline decile bins on the horizontal axis and two bars "
    "per bin, one for the baseline share and one for the current share, against a dashed "
    "reference line at ten percent. Most baseline bars sit on the reference line, but the "
    "fifth is well below it and the sixth well above. The current bars fall below the line in "
    "the outermost bins on both sides and rise above it through the middle, most sharply in "
    "the fifth bin, where the current bar is more than twice the baseline one.",
)

# %% [markdown]
# ---
#
# ## Part 4: Data Hygiene
#
# Gap detection, deduplication, and corporate-action signalling.

# %% [markdown]
# ### Gap Detection
#
# A weekend puts three calendar days between consecutive rows, a holiday next to one puts four,
# and a holiday on both sides of a weekend puts five. The threshold has to sit above whatever
# the calendar produces, so the distribution of step sizes is printed first and the threshold
# is read against it. What matters is whether the distribution has a break: if the ordinary
# cases stop at one value and the next occupied value is well beyond it, a threshold in
# between separates calendar from incident. If they shade into each other, no threshold does.


# %%
def detect_gaps(df: pl.DataFrame, max_gap_days: int = MAX_GAP_DAYS) -> pl.DataFrame:
    """Return rows whose gap from the previous timestamp exceeds `max_gap_days`."""
    return (
        df.sort("timestamp")
        .with_columns(
            pl.col("timestamp").diff().dt.total_days().alias("days_since_prev"),
            pl.col("timestamp").shift(1).alias("prev_timestamp"),
        )
        .filter(pl.col("days_since_prev") > max_gap_days)
        .select(["prev_timestamp", "timestamp", "days_since_prev"])
    )


# %%
gap_sizes = (
    pl.concat(
        [
            df.sort("timestamp")
            .with_columns(pl.col("timestamp").diff().dt.total_days().alias("days"))
            .select("days")
            .drop_nulls()
            for df in datasets.values()
        ]
    )
    .group_by("days")
    .len()
    .sort("days")
)
print("Calendar days between consecutive rows, pooled across the universe:")
print(gap_sizes)

gap_rows = []
for symbol, df in datasets.items():
    for row in detect_gaps(df).iter_rows(named=True):
        gap_rows.append(
            {
                "symbol": symbol,
                "prev_date": _to_date(row["prev_timestamp"]),
                "next_date": _to_date(row["timestamp"]),
                "days": int(row["days_since_prev"]),
            }
        )
gap_table = (
    pl.DataFrame(gap_rows)
    if gap_rows
    else pl.DataFrame({"symbol": [], "prev_date": [], "next_date": [], "days": []})
)
print(f"\nGaps longer than {MAX_GAP_DAYS} days: {gap_table.height}")
gap_table

# %% [markdown]
# One date, and every symbol that was listed at the time carries it: the NYSE closure that
# followed 11 September 2001. The two symbols without it had not listed yet, which the
# coverage table at the top of the notebook shows. A gap that appears in every symbol trading
# at the time is a market event; a gap in one symbol while its peers keep quoting is the one
# worth investigating.

# %% [markdown]
# ### Deduplication
#
# Duplicates appear when a provider re-sends an overlapping date range in an incremental
# update. Whether to keep the first or the last copy is a question about whether the original
# feed or the correction is more trustworthy, and it is not answerable from the row count.

# %%
sample = datasets[DEMO_SYMBOL].head(100)
df_with_dups = pl.concat([sample, sample.head(10)]).sort("timestamp")
print(
    f"Original: {len(df_with_dups)} rows; keep first: "
    f"{df_with_dups.unique(subset=['timestamp'], keep='first').shape[0]} rows; "
    f"keep last: {df_with_dups.unique(subset=['timestamp'], keep='last').shape[0]} rows"
)

# %% [markdown]
# ### Corporate-Action Detection, Scored Against the File's Own Answer
#
# Splits and large special distributions produce overnight returns far outside the ordinary
# range, and a threshold on the overnight return will find them. It will also find every large
# genuine move, and from the return alone the two are indistinguishable.
#
# This panel does not require guessing. It carries `split_ratio` and `ex-dividend` columns
# alongside the prices, so the threshold's output can be scored: every date carrying a split
# ratio other than one is a corporate action, and the flags that match none of them are what
# the threshold costs.


# %%
def detect_corporate_actions(
    df: pl.DataFrame, threshold: float = CORPORATE_ACTION_THRESHOLD
) -> pl.DataFrame:
    """Flag overnight returns whose magnitude exceeds `threshold`."""
    return (
        df.with_columns(pl.col("close").shift(1).alias("prev_close"))
        .with_columns(((pl.col("open") / pl.col("prev_close")) - 1).alias("overnight_return"))
        .filter(pl.col("overnight_return").abs() > threshold)
        .select(["timestamp", "prev_close", "open", "overnight_return"])
    )


# %%
truth = (
    wiki_df.lazy()
    .filter(pl.col("symbol").is_in(SYMBOLS) & (pl.col("split_ratio") != 1.0))
    .select("symbol", "timestamp", "split_ratio")
    .collect()
)

events_rows = []
for symbol, df in datasets.items():
    for row in detect_corporate_actions(df).iter_rows(named=True):
        events_rows.append(
            {
                "symbol": symbol,
                "timestamp": row["timestamp"],
                "date": _to_date(row["timestamp"]),
                "prev_close": round(row["prev_close"], 2),
                "open": round(row["open"], 2),
                "overnight_pct": round(row["overnight_return"] * 100, 1),
            }
        )
events_table = pl.DataFrame(events_rows)

scored = events_table.join(truth, on=["symbol", "timestamp"], how="left")
_caught = truth.join(
    events_table.select("symbol", "timestamp").with_columns(pl.lit(True).alias("flagged")),
    on=["symbol", "timestamp"],
    how="left",
)
_recall_num = _caught.filter(pl.col("flagged").is_not_null()).height
_not_splits = scored.filter(pl.col("split_ratio").is_null())

print(f"Recorded splits in this universe: {truth.height}")
print(f"  flagged by the {CORPORATE_ACTION_THRESHOLD:.0%} overnight threshold: {_recall_num}")
print(f"Flags raised in total: {events_table.height}")
print(
    f"  of which match no recorded split: {_not_splits.height} "
    f"({100 * _not_splits.height / events_table.height:.0f}%)"
)
_not_splits.select("symbol", "date", "prev_close", "open", "overnight_pct").sort("date")

# %% [markdown]
# The threshold misses no split in this universe, and one flag in three matches no split at
# all. One of those is still a corporate action recorded elsewhere in the same file: the GOOGL
# Class C distribution of April 2014 appears in the `ex-dividend` column rather than as a
# split ratio, which is a reminder that "corporate action" is several columns, not one. The
# rest are ordinary large moves, an earnings crash among them.
#
# So the detector's output is a candidate list whose false-positive rate is knowable here only
# because the file carries the answer. On a feed that does not, the same threshold produces
# the same list with no way to score it, and the difference between the two situations is the
# corporate-action feed, not the detection code.

# %%
fig = go.Figure()
for label, frame, color in [
    ("Matches a recorded split", scored.filter(pl.col("split_ratio").is_not_null()), "slate"),
    ("No recorded split", _not_splits, "copper"),
]:
    fig.add_trace(
        go.Scatter(
            x=frame["date"].to_list(),
            y=frame["overnight_pct"].to_list(),
            mode="markers",
            marker=dict(color=COLORS[color], size=8),
            text=frame["symbol"].to_list(),
            hovertemplate="%{text}<br>%{x|%Y-%m-%d}: %{y:.1f}%<extra></extra>",
            name=label,
        )
    )
for sign in (1, -1):
    fig.add_hline(
        y=sign * CORPORATE_ACTION_THRESHOLD * 100,
        line_dash="dash",
        line_color=COLORS["amber"],
        line_width=1,
    )
fig.update_layout(
    title="Flagged overnight returns, split-matched and not",
    xaxis_title="Date",
    yaxis_title="Overnight return (%)",
    height=420,
)
show_plotly_with_alt(
    fig,
    "A scatter plot of flagged overnight returns against date, in two colours for whether the "
    "date matches a recorded split. Dashed reference lines mark the plus and minus threshold. "
    "Most points sit near minus fifty percent and are split-matched, with one far lower. The "
    "unmatched points are spread on both sides, mostly just outside the threshold lines.",
)

# %% [markdown]
# ---
#
# ## Part 5: Production Quality Pipeline
#
# Validation, anomaly detection and deduplication wired together, with quarantine routing for
# critical failures.


# %%
def quality_check_pipeline(
    df: pl.DataFrame,
    symbol: str,
    quarantine_dir: Path,
    anomaly_cfg: AnomalyConfig | None = None,
) -> tuple[pl.DataFrame, dict]:
    """Run validation, then anomaly detection, then dedup; quarantine on critical issues."""
    results: dict = {
        "symbol": symbol,
        "timestamp": datetime.now().isoformat(),
        "input_rows": len(df),
        "actions": [],
    }

    validation = OHLCVValidator().validate(df)
    results["validation_issues"] = len(validation.issues)
    results["actions"].append(
        f"{'PASS' if validation.passed else 'FAIL'} Validation: {len(validation.issues)} issues"
    )

    if not validation.passed:
        critical = [i for i in validation.issues if i.severity.value == "critical"]
        if critical:
            quarantine_dir.mkdir(parents=True, exist_ok=True)
            path = quarantine_dir / f"{symbol}_{datetime.now():%Y%m%d_%H%M%S}.parquet"
            df.write_parquet(path)
            results["actions"].append(f"QUARANTINED: {path.name}")

    mgr = AnomalyManager(config=anomaly_cfg) if anomaly_cfg else AnomalyManager()
    report = mgr.analyze(df, symbol)
    results["anomaly_count"] = len(report.anomalies)
    results["actions"].append(f"Anomalies: {len(report.anomalies)}")

    duplicates = len(df) - df["timestamp"].n_unique()
    if duplicates > 0:
        df = df.unique(subset=["timestamp"], keep="last")
        results["actions"].append(f"Removed {duplicates} duplicates")

    results["output_rows"] = len(df)
    results["status"] = (
        "PASS" if validation.passed and not report.get_critical_anomalies() else "REVIEW"
    )
    return df, results


# %%
quarantine_dir = OUTPUT_DIR / "quarantine"
pipeline_rows = []
for symbol, df in datasets.items():
    _, result = quality_check_pipeline(df, symbol, quarantine_dir, anomaly_config)
    pipeline_rows.append(
        {
            "symbol": symbol,
            "status": result["status"],
            "input_rows": result["input_rows"],
            "output_rows": result["output_rows"],
            "validation_issues": result["validation_issues"],
            "anomalies": result["anomaly_count"],
        }
    )
pipeline_summary = pl.DataFrame(pipeline_rows)
pipeline_summary

# %% [markdown]
# ---
#
# ## Key Takeaways
#
# 1. **A validator that passes has told you nothing until it has been shown a fault.** Every
#    symbol here passes, and so would a validator with its checks switched off. Injecting one
#    fault of each kind and confirming which check fires is what separates the two, and it is
#    three lines of setup.
#
# 2. **One threshold across three methods is three different cutoffs.** MAD, Z-score and IQR
#    each multiply the threshold by their own scale estimate, so the numbers they flag are not
#    comparable until those cutoffs are printed in the units of the data. They are printed
#    above, and the ordering of the flag counts is the ordering of the cutoffs.
#
# 3. **MAD flags more because it ignores the tails, not because it sees them.** Its scale
#    estimate is a median of deviations, which extreme days cannot move, so on a heavy-tailed
#    series it comes out well below the standard deviation and the band built from it is
#    correspondingly narrow. The intuitive explanation runs the other way and gets the
#    mechanism backwards.
#
# 4. **Most of a PSI can come from one bin that is not a decile.** Quantile edges cannot split
#    a point mass, and daily returns have one at zero, so two neighbouring bins come out
#    lopsided and the larger deviation sits beside the tie group rather than in the tails.
#    Reading the per-bin contributions is what shows this; the headline value does not. What
#    caused the tie group to thin is a further question, and the yearly counts above rule out
#    the obvious answer without supplying another.
#
# 5. **PSI's binning also discards the observations PSI exists to find.** Bin edges taken from
#    baseline quantiles end at the baseline's own extremes, so a current observation more
#    extreme than anything seen before falls in no bin and is dropped by the histogram. The
#    count dropped is printed above. Opening the outer bins costs nothing and removes a blind
#    spot aimed squarely at the strongest evidence of drift.
#
# 6. **An anomaly detector produces a work queue, and cannot resolve one.** The overnight-return
#    threshold misses none of this universe's recorded splits, and better than a third of its
#    flags match no split at all: one corporate action recorded in a different column of the
#    same file, and several ordinary large moves. Scoring it was possible only because this file carries
#    the split and dividend columns; on a feed that does not, the same list arrives with no way
#    to grade it.
#
# 7. **An unadjusted panel makes correct detectors fire on real events.** Either adjust
#    upstream or give the pipeline a corporate-action source to consult before it quarantines
#    anything, because the alternative is quarantining the days the market actually moved.
#
# **Next**: `14_point_in_time_validation` adds the temporal dimension, bitemporal queries, on
# top of the structural checks shown here.

```

Полный текст с указанием источника опубликован на условиях его лицензии. Лицензия: MIT

Это краткое изложение подготовлено исследовательским агентом Stratmill по оригиналу и не является его копией.