SPB Git

spb/findata

Public
5.7 KB · 104 lines python
1"""Row-level and session-level validation of canonical bars. Flags, never mutates prices."""2from __future__ import annotations34from datetime import date5from typing import Any67import polars as pl8import pyarrow as pa9from findata_schema import INTERVAL_SECONDS, Interval, QualityFlag1011from findata_quality.calendar import SessionCalendar, xnys1213PRICE_JUMP_THRESHOLD = 0.25  # 25 % between consecutive 1-minute closes inside a session (flag only)141516NO_SESSION_CALENDAR = {"fx", "crypto", "futures"}  # 24h / overnight sessions: no XNYS session checks171819def validate_bars(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None,20                  check_sessions: bool = True, asset_class: str | None = None) -> tuple[pa.Table, list[dict[str, Any]]]:21    """Return (table with `quality_flags` filled, anomalies list). Input must follow BARS_SCHEMA columns."""22    cal = calendar or xnys()23    step = INTERVAL_SECONDS[interval]24    df = pl.from_arrow(table)25    if df.height == 0:26        return table, []27    if asset_class is None and "asset_class" in df.columns:28        asset_class = df.get_column("asset_class")[0]29    if asset_class in NO_SESSION_CALENDAR:30        check_sessions = False31    df = df.sort(["symbol_as_reported", "timestamp_utc"])32    ts_s = pl.col("timestamp_utc").dt.epoch("s")33    flags: list[pl.Expr] = [34        pl.when((pl.col("high") < pl.max_horizontal("open", "close")) | (pl.col("low") > pl.min_horizontal("open", "close"))35                | (pl.col("high") < pl.col("low"))).then(pl.lit(QualityFlag.ohlc_inconsistent.value)),36        pl.when(pl.min_horizontal("open", "high", "low", "close") <= 0).then(pl.lit(QualityFlag.nonpositive_price.value)),37        pl.when(pl.col("volume") < 0).then(pl.lit(QualityFlag.negative_volume.value)),38        pl.when((pl.col("volume") == 0) & pl.lit(asset_class != "index")).then(pl.lit(QualityFlag.zero_volume.value)),39        pl.when(pl.col("volume") != pl.col("volume").floor()).then(pl.lit(QualityFlag.fractional_volume.value)),40        pl.when((ts_s % step) != 0).then(pl.lit(QualityFlag.off_interval.value)),41        pl.when(pl.struct(["symbol_as_reported", "timestamp_utc"]).is_duplicated()).then(pl.lit(QualityFlag.duplicate.value)),42    ]43    if interval != Interval.d1:44        prev_close = pl.col("close").shift(1).over(["symbol_as_reported", "session_date"])45        flags.append(pl.when(((pl.col("close") / prev_close) - 1).abs() > PRICE_JUMP_THRESHOLD)46                     .then(pl.lit(QualityFlag.price_jump.value)))47    if check_sessions and interval != Interval.d1:48        # outside extended hours / non trading day: computed per distinct session_date49        dates = df.get_column("session_date").unique().to_list()50        bounds = {d: cal.extended_bounds_utc(d) for d in dates}51        non_sessions = [d for d in dates if not cal.is_session(d)]52        lo = pl.col("session_date").replace_strict({d: b[0] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))53        hi = pl.col("session_date").replace_strict({d: b[1] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))54        flags.append(pl.when((pl.col("timestamp_utc") < lo) | (pl.col("timestamp_utc") >= hi))55                     .then(pl.lit(QualityFlag.outside_session.value)))56        if non_sessions:57            flags.append(pl.when(pl.col("session_date").is_in(non_sessions)).then(pl.lit(QualityFlag.non_trading_day.value)))58    df = df.with_columns(59        pl.concat_list([f.otherwise(None) for f in flags]).list.drop_nulls().alias("quality_flags")60    )61    anomalies = _summarize(df)62    out = df.to_arrow()63    # cast back to the canonical schema (polars may widen list/int types)64    out = out.select(table.column_names).cast(table.schema)65    return out, anomalies666768def _summarize(df: pl.DataFrame) -> list[dict[str, Any]]:69    flagged = df.filter(pl.col("quality_flags").list.len() > 0)70    if flagged.height == 0:71        return []72    agg = (flagged.explode("quality_flags")73           .group_by(["symbol_as_reported", "session_date", "quality_flags"]).agg(pl.len().alias("n"))74           .sort(["symbol_as_reported", "session_date", "quality_flags"]))75    sev = {QualityFlag.zero_volume.value: "info", QualityFlag.fractional_volume.value: "info",76           QualityFlag.price_jump.value: "warning", QualityFlag.outside_session.value: "warning",77           QualityFlag.gap.value: "info"}78    return [{"symbol": r["symbol_as_reported"], "session_date": r["session_date"], "code": r["quality_flags"],79             "severity": sev.get(r["quality_flags"], "error"), "detail": {"rows": r["n"]}}80            for r in agg.iter_rows(named=True)]818283def session_coverage(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None) -> list[dict[str, Any]]:84    """Per (symbol, session_date): bar count, first/last ts, expected regular-hours bars, gap flags."""85    cal = calendar or xnys()86    step = INTERVAL_SECONDS[interval]87    df = pl.from_arrow(table)88    if df.height == 0:89        return []90    g = (df.group_by(["symbol_as_reported", "session_date"])91         .agg(pl.len().alias("bar_count"), pl.col("timestamp_utc").min().alias("first_ts"),92              pl.col("timestamp_utc").max().alias("last_ts"))93         .sort(["symbol_as_reported", "session_date"]))94    rows = []95    for r in g.iter_rows(named=True):96        d: date = r["session_date"]97        exp = cal.expected_regular_bars(d, step) if interval != Interval.d1 else 198        flags = []99        if not cal.is_session(d):100            flags.append(QualityFlag.non_trading_day.value)101        rows.append({"symbol": r["symbol_as_reported"], "session_date": d, "bar_count": r["bar_count"],102                     "first_ts": r["first_ts"], "last_ts": r["last_ts"], "expected_bars": exp, "flags": flags})103    return rows104
Files · main
Menu

Theme