SPB Git

spb/findata

Public
5.3 KB · 97 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)141516def validate_bars(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None,17                  check_sessions: bool = True) -> tuple[pa.Table, list[dict[str, Any]]]:18    """Return (table with `quality_flags` filled, anomalies list). Input must follow BARS_SCHEMA columns."""19    cal = calendar or xnys()20    step = INTERVAL_SECONDS[interval]21    df = pl.from_arrow(table)22    if df.height == 0:23        return table, []24    df = df.sort(["symbol_as_reported", "timestamp_utc"])25    ts_s = pl.col("timestamp_utc").dt.epoch("s")26    flags: list[pl.Expr] = [27        pl.when((pl.col("high") < pl.max_horizontal("open", "close")) | (pl.col("low") > pl.min_horizontal("open", "close"))28                | (pl.col("high") < pl.col("low"))).then(pl.lit(QualityFlag.ohlc_inconsistent.value)),29        pl.when(pl.min_horizontal("open", "high", "low", "close") <= 0).then(pl.lit(QualityFlag.nonpositive_price.value)),30        pl.when(pl.col("volume") < 0).then(pl.lit(QualityFlag.negative_volume.value)),31        pl.when(pl.col("volume") == 0).then(pl.lit(QualityFlag.zero_volume.value)),32        pl.when(pl.col("volume") != pl.col("volume").floor()).then(pl.lit(QualityFlag.fractional_volume.value)),33        pl.when((ts_s % step) != 0).then(pl.lit(QualityFlag.off_interval.value)),34        pl.when(pl.struct(["symbol_as_reported", "timestamp_utc"]).is_duplicated()).then(pl.lit(QualityFlag.duplicate.value)),35    ]36    if interval != Interval.d1:37        prev_close = pl.col("close").shift(1).over(["symbol_as_reported", "session_date"])38        flags.append(pl.when(((pl.col("close") / prev_close) - 1).abs() > PRICE_JUMP_THRESHOLD)39                     .then(pl.lit(QualityFlag.price_jump.value)))40    if check_sessions and interval != Interval.d1:41        # outside extended hours / non trading day: computed per distinct session_date42        dates = df.get_column("session_date").unique().to_list()43        bounds = {d: cal.extended_bounds_utc(d) for d in dates}44        non_sessions = [d for d in dates if not cal.is_session(d)]45        lo = pl.col("session_date").replace_strict({d: b[0] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))46        hi = pl.col("session_date").replace_strict({d: b[1] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))47        flags.append(pl.when((pl.col("timestamp_utc") < lo) | (pl.col("timestamp_utc") >= hi))48                     .then(pl.lit(QualityFlag.outside_session.value)))49        if non_sessions:50            flags.append(pl.when(pl.col("session_date").is_in(non_sessions)).then(pl.lit(QualityFlag.non_trading_day.value)))51    df = df.with_columns(52        pl.concat_list([f.otherwise(None) for f in flags]).list.drop_nulls().alias("quality_flags")53    )54    anomalies = _summarize(df)55    out = df.to_arrow()56    # cast back to the canonical schema (polars may widen list/int types)57    out = out.select(table.column_names).cast(table.schema)58    return out, anomalies596061def _summarize(df: pl.DataFrame) -> list[dict[str, Any]]:62    flagged = df.filter(pl.col("quality_flags").list.len() > 0)63    if flagged.height == 0:64        return []65    agg = (flagged.explode("quality_flags")66           .group_by(["symbol_as_reported", "session_date", "quality_flags"]).agg(pl.len().alias("n"))67           .sort(["symbol_as_reported", "session_date", "quality_flags"]))68    sev = {QualityFlag.zero_volume.value: "info", QualityFlag.fractional_volume.value: "info",69           QualityFlag.price_jump.value: "warning", QualityFlag.outside_session.value: "warning",70           QualityFlag.gap.value: "info"}71    return [{"symbol": r["symbol_as_reported"], "session_date": r["session_date"], "code": r["quality_flags"],72             "severity": sev.get(r["quality_flags"], "error"), "detail": {"rows": r["n"]}}73            for r in agg.iter_rows(named=True)]747576def session_coverage(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None) -> list[dict[str, Any]]:77    """Per (symbol, session_date): bar count, first/last ts, expected regular-hours bars, gap flags."""78    cal = calendar or xnys()79    step = INTERVAL_SECONDS[interval]80    df = pl.from_arrow(table)81    if df.height == 0:82        return []83    g = (df.group_by(["symbol_as_reported", "session_date"])84         .agg(pl.len().alias("bar_count"), pl.col("timestamp_utc").min().alias("first_ts"),85              pl.col("timestamp_utc").max().alias("last_ts"))86         .sort(["symbol_as_reported", "session_date"]))87    rows = []88    for r in g.iter_rows(named=True):89        d: date = r["session_date"]90        exp = cal.expected_regular_bars(d, step) if interval != Interval.d1 else 191        flags = []92        if not cal.is_session(d):93            flags.append(QualityFlag.non_trading_day.value)94        rows.append({"symbol": r["symbol_as_reported"], "session_date": d, "bar_count": r["bar_count"],95                     "first_ts": r["first_ts"], "last_ts": r["last_ts"], "expected_bars": exp, "flags": flags})96    return rows97
Files · 8aa539b79849fb28991011546f6e2c19166d7f01
Menu

Theme