Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb2ca425a7 | |||
| d6af69f4cb | |||
| aeac8f2a95 | |||
| 75fe803296 | |||
| ea5c612446 | |||
| 9e2216d202 | |||
| 35ac92e938 | |||
| 07a8bbf5c8 | |||
| 0c6e462545 | |||
| 569df334dc |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
# Data Quality Audit — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Status:** Approved (design phase)
|
||||||
|
**Author:** session-driven (operator + agent)
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Prima di costruire il backtest non-stilizzato su `option_chain_snapshots`
|
||||||
|
(prossimo macro-step del progetto), serve confermare che i dati
|
||||||
|
raccolti negli ultimi 11 giorni (ETH) e 8 giorni (BTC) siano usabili:
|
||||||
|
copertura temporale piena, niente buchi sistemici, niente quote
|
||||||
|
malformate (bid > ask, IV mancante, depth book a zero). Lo stesso
|
||||||
|
audit dev'essere ri-eseguibile periodicamente come check operativo.
|
||||||
|
|
||||||
|
`market_snapshots` rientra nello scope per simmetria (entrambe le
|
||||||
|
tabelle alimentano la decisione di entrata e il monitoring), mentre
|
||||||
|
`dvol_history` è escluso: appena migrato a multi-asset (commit
|
||||||
|
`19695e4`), serie troppo corta per BTC (29 righe al momento del
|
||||||
|
design) e copertura ETH già implicita in `market_snapshots`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- `market_snapshots`: continuità temporale, fetch_ok streaks, NULL rate
|
||||||
|
per colonna numerica, parità ETH/BTC.
|
||||||
|
- `option_chain_snapshots`: snapshot mancanti, distribuzione quote per
|
||||||
|
snap, bid/ask sanity, IV null rate, book depth.
|
||||||
|
- CLI subcommand `cerbero-bite audit`, output stdout + opzionale `--json`.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- `dvol_history`, `decisions`, `positions`, `instructions`,
|
||||||
|
`manual_actions` (non rilevanti per il backtest non-stilizzato).
|
||||||
|
- Audit di consistenza cross-tabella (es: per ogni snapshot chain esiste
|
||||||
|
uno snapshot market) — interessante ma rinviato.
|
||||||
|
- Persistenza dei risultati audit nello stesso DB.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/cerbero_bite/analysis/
|
||||||
|
__init__.py
|
||||||
|
data_audit.py # logica pura, no I/O lato MCP
|
||||||
|
|
||||||
|
src/cerbero_bite/cli.py # nuovo subcommand `audit`
|
||||||
|
|
||||||
|
tests/unit/
|
||||||
|
test_data_audit.py # DB temporaneo + seed deterministico
|
||||||
|
```
|
||||||
|
|
||||||
|
`data_audit.py` espone funzioni pure che prendono una `sqlite3.Connection`
|
||||||
|
e una finestra temporale, ritornano `dataclass` di risultati. Il CLI
|
||||||
|
apre la connection in read-only, chiama le funzioni, formatta l'output.
|
||||||
|
|
||||||
|
Funzioni principali:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MarketAuditReport:
|
||||||
|
asset: str
|
||||||
|
expected_ticks: int
|
||||||
|
actual_ticks: int
|
||||||
|
coverage_pct: Decimal
|
||||||
|
gaps_over_threshold: list[GapRecord]
|
||||||
|
fetch_ok_zero_count: int
|
||||||
|
max_fetch_ok_zero_streak: int
|
||||||
|
null_rate_by_column: dict[str, Decimal]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChainAuditReport:
|
||||||
|
asset: str
|
||||||
|
expected_snapshots: int
|
||||||
|
actual_snapshots: int
|
||||||
|
coverage_pct: Decimal
|
||||||
|
quotes_per_snap_median: int
|
||||||
|
quotes_per_snap_p10: int
|
||||||
|
quotes_per_snap_p90: int
|
||||||
|
bid_gt_ask_count: int
|
||||||
|
iv_null_count: int
|
||||||
|
iv_null_pct: Decimal
|
||||||
|
depth_zero_pct: Decimal
|
||||||
|
|
||||||
|
def audit_market_snapshots(conn, *, asset, since, now) -> MarketAuditReport: ...
|
||||||
|
def audit_option_chain(conn, *, asset, since, now) -> ChainAuditReport: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checks & Thresholds
|
||||||
|
|
||||||
|
| Tabella | Check | Soglia "bad" | Rationale |
|
||||||
|
|---|---|---|---|
|
||||||
|
| market_snapshots | gap tra tick consecutivi | > 20 min | cron è `*/15`; +5 min tolleranza |
|
||||||
|
| market_snapshots | streak `fetch_ok=0` | ≥ 3 consecutivi | 1-2 = transient MCP, 3+ = pattern |
|
||||||
|
| market_snapshots | NULL rate per colonna | > 10% nella finestra | una metrica con >10% NULL non è affidabile per backtest |
|
||||||
|
| option_chain_snapshots | snap mancanti | qualsiasi (count visibile) | cron `*/15`, ogni miss è significativo |
|
||||||
|
| option_chain_snapshots | quote/snap < 50% mediana 24h | qualsiasi | rilevatore di chain truncate (mismatch with width filter) |
|
||||||
|
| option_chain_snapshots | bid > ask | qualsiasi | dato corrotto, da indagare |
|
||||||
|
| option_chain_snapshots | IV null/non-parseable | conteggio + % | IV è chiave per BS skew calibration |
|
||||||
|
| option_chain_snapshots | `book_depth_top3 = 0` | % per snapshot | proxy di illiquidità |
|
||||||
|
|
||||||
|
Le soglie sono costanti modulo (non config YAML) per ridurre il blast
|
||||||
|
radius dei cambi: il backtest e l'audit girano in contesti diversi,
|
||||||
|
non condividono parametri operativi.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
cerbero-bite audit [--since DAYS] [--json] [--asset ETH|BTC]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--since DAYS` (default `7`): finestra di analisi, retro dal `now()` corrente.
|
||||||
|
- `--json` (default off): stampa solo dump JSON serializzabile, niente tabelle umane.
|
||||||
|
- `--asset` (default `tutti`): filtra ad un singolo asset.
|
||||||
|
|
||||||
|
Exit code:
|
||||||
|
- `0`: audit completato (a prescindere dai problemi trovati).
|
||||||
|
- `2`: errori di connessione/DB (separare da problemi nei dati).
|
||||||
|
|
||||||
|
Niente exit code per "found issues": l'audit è informativo, decide
|
||||||
|
l'umano. Far diventare l'audit un gate CI è out of scope.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
**Stdout (default):**
|
||||||
|
|
||||||
|
```
|
||||||
|
=== ETH — market_snapshots (last 7d, 2026-05-05 → 2026-05-12) ===
|
||||||
|
ticks: 672 expected: 672 coverage: 100.0%
|
||||||
|
gaps > 20min: 0
|
||||||
|
fetch_ok=0: 4 rows (max streak: 1)
|
||||||
|
null rate: dealer_net_gamma 2.1% oi_delta_pct_4h 0.3%
|
||||||
|
|
||||||
|
=== ETH — option_chain_snapshots (last 7d) ===
|
||||||
|
snapshots: 672 expected: 672 coverage: 100.0%
|
||||||
|
quotes/snap: median 55 p10 50 p90 60
|
||||||
|
bid > ask: 0
|
||||||
|
IV null: 12 quotes (0.03%)
|
||||||
|
depth_top3 = 0: 1.2% of quotes
|
||||||
|
|
||||||
|
=== BTC — ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON (`--json`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"since": "2026-05-05T20:46:00+00:00",
|
||||||
|
"until": "2026-05-12T20:46:00+00:00",
|
||||||
|
"assets": {
|
||||||
|
"ETH": {
|
||||||
|
"market": {"expected_ticks": 672, "actual_ticks": 672, ...},
|
||||||
|
"chain": {"expected_snapshots": 672, ...}
|
||||||
|
},
|
||||||
|
"BTC": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`tests/unit/test_data_audit.py`. Per ogni funzione:
|
||||||
|
|
||||||
|
- DB temporaneo (`tmp_path`), schema migrato via `run_migrations`.
|
||||||
|
- Seed deterministico: insert manuali per riprodurre lo scenario.
|
||||||
|
- Test cases:
|
||||||
|
- market: copertura piena → 100%; un gap iniettato → conteggio gap=1;
|
||||||
|
streak `fetch_ok=0` lunga 3 → flagged.
|
||||||
|
- chain: snap mancante → expected − actual = 1; quote dimezzate in
|
||||||
|
un tick → quotes/snap p10 cala; `bid=10 ask=5` → bid>ask=1.
|
||||||
|
- 0 dipendenze nuove (sqlite + pytest standard).
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Tabelle attuali: ~57k quote chain. Le query usano gli index
|
||||||
|
`idx_option_chain_asset_ts` e `(asset, timestamp)` di
|
||||||
|
`market_snapshots`. L'audit deve girare in < 2s su 7gg.
|
||||||
|
|
||||||
|
## Anti-goals (esplicito)
|
||||||
|
|
||||||
|
- Nessun salvataggio dei risultati nello stato del DB.
|
||||||
|
- Nessun trigger automatico (no cron job, no APScheduler).
|
||||||
|
- Nessun alert/notifica: stdout + JSON sono lo strumento, l'operatore
|
||||||
|
decide cosa farne.
|
||||||
|
- Nessun ML / detection di anomalie sofisticate. Soglie costanti.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Nessuna al momento della scrittura. Eventuali punti emergeranno durante
|
||||||
|
l'implementazione e andranno annotati qui.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Analysis utilities — pure functions over the state DB.
|
||||||
|
|
||||||
|
Modules here read SQLite, never write. They are ergonomic to call
|
||||||
|
from CLI commands, notebooks, or one-off scripts.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Data quality audit over market_snapshots + option_chain_snapshots.
|
||||||
|
|
||||||
|
Pure functions: each takes a ``sqlite3.Connection`` and a UTC time
|
||||||
|
window, returns a frozen dataclass. No side effects, no MCP, no
|
||||||
|
writes. The CLI layer (``cli.audit``) is responsible for I/O and
|
||||||
|
formatting.
|
||||||
|
|
||||||
|
Thresholds are module-level constants by design: the audit and the
|
||||||
|
runtime live in different contexts and must not share operational
|
||||||
|
parameters. To tune a threshold, edit this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChainAuditReport",
|
||||||
|
"GapRecord",
|
||||||
|
"MarketAuditReport",
|
||||||
|
"audit_market_snapshots",
|
||||||
|
"audit_option_chain",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tick cadence + gap tolerance. Cron is */15; +5 min tolerance covers
|
||||||
|
# late-arriving MCP responses.
|
||||||
|
_TICK_INTERVAL_MIN: int = 15
|
||||||
|
_GAP_THRESHOLD_MIN: int = 20
|
||||||
|
|
||||||
|
# fetch_ok=0 streak threshold: 1-2 are transient MCP failures, 3+ is a
|
||||||
|
# pattern worth flagging.
|
||||||
|
_FETCH_OK_STREAK_THRESHOLD: int = 3
|
||||||
|
|
||||||
|
# A numeric column with >10% NULL in the window is too unreliable for
|
||||||
|
# backtesting that metric.
|
||||||
|
_NULL_RATE_FLAG: Decimal = Decimal("0.10")
|
||||||
|
|
||||||
|
# Columns to NULL-audit on market_snapshots. fetch_ok / fetch_errors_json
|
||||||
|
# are excluded (they are status fields, not metrics).
|
||||||
|
_MARKET_NUMERIC_COLUMNS: tuple[str, ...] = (
|
||||||
|
"spot",
|
||||||
|
"dvol",
|
||||||
|
"realized_vol_30d",
|
||||||
|
"iv_minus_rv",
|
||||||
|
"funding_perp_annualized",
|
||||||
|
"funding_cross_annualized",
|
||||||
|
"dealer_net_gamma",
|
||||||
|
"gamma_flip_level",
|
||||||
|
"oi_delta_pct_4h",
|
||||||
|
"macro_days_to_event",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GapRecord:
|
||||||
|
"""One gap between consecutive market_snapshots ticks."""
|
||||||
|
|
||||||
|
prev_timestamp: datetime
|
||||||
|
next_timestamp: datetime
|
||||||
|
gap_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MarketAuditReport:
|
||||||
|
asset: str
|
||||||
|
since: datetime
|
||||||
|
until: datetime
|
||||||
|
expected_ticks: int
|
||||||
|
actual_ticks: int
|
||||||
|
coverage_pct: Decimal
|
||||||
|
gaps: tuple[GapRecord, ...] = field(default_factory=tuple)
|
||||||
|
fetch_ok_zero_count: int = 0
|
||||||
|
max_fetch_ok_zero_streak: int = 0
|
||||||
|
null_rate_by_column: dict[str, Decimal] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChainAuditReport:
|
||||||
|
asset: str
|
||||||
|
since: datetime
|
||||||
|
until: datetime
|
||||||
|
expected_snapshots: int
|
||||||
|
actual_snapshots: int
|
||||||
|
coverage_pct: Decimal
|
||||||
|
quotes_per_snap_median: int = 0
|
||||||
|
quotes_per_snap_p10: int = 0
|
||||||
|
quotes_per_snap_p90: int = 0
|
||||||
|
bid_gt_ask_count: int = 0
|
||||||
|
iv_null_count: int = 0
|
||||||
|
iv_null_pct: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_ticks(since: datetime, until: datetime) -> int:
|
||||||
|
"""Number of `*/15` ticks in ``[since, until)`` aligned to wall clock.
|
||||||
|
|
||||||
|
A tick is any UTC instant where ``minute % 15 == 0``. The first
|
||||||
|
tick at or after ``since`` is computed by rounding ``since`` up;
|
||||||
|
every subsequent tick is +15 minutes. The window is half-open on
|
||||||
|
the right.
|
||||||
|
"""
|
||||||
|
if until <= since:
|
||||||
|
return 0
|
||||||
|
# Round `since` up to the next */15 boundary.
|
||||||
|
minute = since.minute
|
||||||
|
remainder = minute % _TICK_INTERVAL_MIN
|
||||||
|
if remainder == 0 and since.second == 0 and since.microsecond == 0:
|
||||||
|
first_tick = since
|
||||||
|
else:
|
||||||
|
bump = _TICK_INTERVAL_MIN - remainder
|
||||||
|
first_tick = (since + timedelta(minutes=bump)).replace(
|
||||||
|
second=0, microsecond=0
|
||||||
|
)
|
||||||
|
if first_tick >= until:
|
||||||
|
return 0
|
||||||
|
# Count ticks in [first_tick, until): the largest k with
|
||||||
|
# first_tick + k*15min < until is ceil(span/15) - 1, so the
|
||||||
|
# count is ceil(span_minutes / 15). floor() under-counts at
|
||||||
|
# aligned multiples and would mis-count non-aligned spans.
|
||||||
|
span_seconds = (until - first_tick).total_seconds()
|
||||||
|
return math.ceil(span_seconds / (_TICK_INTERVAL_MIN * 60))
|
||||||
|
|
||||||
|
|
||||||
|
def _max_zero_streak(flags: list[int]) -> int:
|
||||||
|
"""Longest run of consecutive zeros."""
|
||||||
|
longest = 0
|
||||||
|
current = 0
|
||||||
|
for v in flags:
|
||||||
|
if v == 0:
|
||||||
|
current += 1
|
||||||
|
longest = max(longest, current)
|
||||||
|
else:
|
||||||
|
current = 0
|
||||||
|
return longest
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_gaps(timestamps: list[datetime]) -> tuple[GapRecord, ...]:
|
||||||
|
"""Return gaps where consecutive timestamps differ by > threshold."""
|
||||||
|
out: list[GapRecord] = []
|
||||||
|
for prev, nxt in zip(timestamps, timestamps[1:], strict=False):
|
||||||
|
delta_min = int((nxt - prev).total_seconds() // 60)
|
||||||
|
if delta_min > _GAP_THRESHOLD_MIN:
|
||||||
|
out.append(
|
||||||
|
GapRecord(
|
||||||
|
prev_timestamp=prev,
|
||||||
|
next_timestamp=nxt,
|
||||||
|
gap_minutes=delta_min,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_market_rows(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
asset: str,
|
||||||
|
since: datetime,
|
||||||
|
until: datetime,
|
||||||
|
) -> list[sqlite3.Row]:
|
||||||
|
cols = ", ".join(("timestamp", "fetch_ok", *_MARKET_NUMERIC_COLUMNS))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT {cols} FROM market_snapshots "
|
||||||
|
"WHERE asset = ? AND timestamp >= ? AND timestamp < ? "
|
||||||
|
"ORDER BY timestamp ASC",
|
||||||
|
(asset, since.isoformat(), until.isoformat()),
|
||||||
|
).fetchall()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_null_rate(
|
||||||
|
rows: list[sqlite3.Row], columns: tuple[str, ...]
|
||||||
|
) -> dict[str, Decimal]:
|
||||||
|
if not rows:
|
||||||
|
return {c: Decimal("0") for c in columns}
|
||||||
|
total = Decimal(len(rows))
|
||||||
|
out: dict[str, Decimal] = {}
|
||||||
|
for c in columns:
|
||||||
|
nulls = sum(1 for r in rows if r[c] is None)
|
||||||
|
out[c] = (Decimal(nulls) / total).quantize(Decimal("0.0001"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def audit_market_snapshots(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
asset: str,
|
||||||
|
since: datetime,
|
||||||
|
until: datetime,
|
||||||
|
) -> MarketAuditReport:
|
||||||
|
"""Compute the market_snapshots audit report for an asset in [since, until)."""
|
||||||
|
rows = _fetch_market_rows(conn, asset=asset, since=since, until=until)
|
||||||
|
timestamps = [datetime.fromisoformat(r["timestamp"]) for r in rows]
|
||||||
|
expected = _expected_ticks(since, until)
|
||||||
|
actual = len(rows)
|
||||||
|
coverage = (
|
||||||
|
(Decimal(actual) / Decimal(expected) * Decimal("100")).quantize(
|
||||||
|
Decimal("0.01")
|
||||||
|
)
|
||||||
|
if expected > 0
|
||||||
|
else Decimal("0")
|
||||||
|
)
|
||||||
|
gaps = _detect_gaps(timestamps)
|
||||||
|
fetch_ok_flags = [int(r["fetch_ok"]) for r in rows]
|
||||||
|
fetch_ok_zero_count = sum(1 for v in fetch_ok_flags if v == 0)
|
||||||
|
max_streak = _max_zero_streak(fetch_ok_flags)
|
||||||
|
null_rates = _compute_null_rate(rows, _MARKET_NUMERIC_COLUMNS)
|
||||||
|
return MarketAuditReport(
|
||||||
|
asset=asset,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
expected_ticks=expected,
|
||||||
|
actual_ticks=actual,
|
||||||
|
coverage_pct=coverage,
|
||||||
|
gaps=gaps,
|
||||||
|
fetch_ok_zero_count=fetch_ok_zero_count,
|
||||||
|
max_fetch_ok_zero_streak=max_streak,
|
||||||
|
null_rate_by_column=null_rates,
|
||||||
|
)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Unit tests for analysis.data_audit."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cerbero_bite.analysis.data_audit import (
|
||||||
|
MarketAuditReport,
|
||||||
|
audit_market_snapshots,
|
||||||
|
)
|
||||||
|
from cerbero_bite.analysis.data_audit import ( # noqa: PLC2701
|
||||||
|
_detect_gaps,
|
||||||
|
_expected_ticks,
|
||||||
|
_max_zero_streak,
|
||||||
|
)
|
||||||
|
from cerbero_bite.state import connect, run_migrations, transaction
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_ticks_basic() -> None:
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
|
||||||
|
# 12:00, 12:15, 12:30, 12:45 → 4 ticks before 13:00
|
||||||
|
assert _expected_ticks(since, until) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_ticks_inclusive_left_exclusive_right() -> None:
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 12, 15, tzinfo=UTC)
|
||||||
|
# Only 12:00
|
||||||
|
assert _expected_ticks(since, until) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_ticks_unaligned_since_rounds_up() -> None:
|
||||||
|
since = datetime(2026, 5, 12, 12, 7, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
|
||||||
|
# First aligned tick after 12:07 is 12:15, then 12:30 is excluded
|
||||||
|
assert _expected_ticks(since, until) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_ticks_non_multiple_span_uses_ceiling() -> None:
|
||||||
|
# Span = 16 min (12:00 → 12:16): ticks 12:00 AND 12:15 are < 12:16.
|
||||||
|
# floor(16/15) = 1 would under-count; the correct answer is 2.
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 12, 16, tzinfo=UTC)
|
||||||
|
assert _expected_ticks(since, until) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_ticks_exact_quarter_boundary_is_excluded() -> None:
|
||||||
|
# Until landing exactly on a */15 tick: that tick is NOT counted
|
||||||
|
# because the window is half-open on the right.
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 12, 45, tzinfo=UTC)
|
||||||
|
# Ticks at 12:00, 12:15, 12:30 → 3 (12:45 excluded)
|
||||||
|
assert _expected_ticks(since, until) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_gaps_returns_empty_when_no_gap() -> None:
|
||||||
|
ts = [
|
||||||
|
datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 5, 12, 12, 15, tzinfo=UTC),
|
||||||
|
datetime(2026, 5, 12, 12, 30, tzinfo=UTC),
|
||||||
|
]
|
||||||
|
assert _detect_gaps(ts) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_gaps_flags_above_threshold() -> None:
|
||||||
|
ts = [
|
||||||
|
datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 5, 12, 12, 45, tzinfo=UTC), # 45-min gap
|
||||||
|
datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
|
||||||
|
]
|
||||||
|
gaps = _detect_gaps(ts)
|
||||||
|
assert len(gaps) == 1
|
||||||
|
assert gaps[0].gap_minutes == 45
|
||||||
|
assert gaps[0].prev_timestamp == ts[0]
|
||||||
|
assert gaps[0].next_timestamp == ts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_gaps_ignores_threshold_boundary() -> None:
|
||||||
|
# 20-min gap is exactly the threshold → NOT flagged (strict >)
|
||||||
|
ts = [
|
||||||
|
datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 5, 12, 12, 20, tzinfo=UTC),
|
||||||
|
]
|
||||||
|
assert _detect_gaps(ts) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_zero_streak_empty() -> None:
|
||||||
|
assert _max_zero_streak([]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_zero_streak_no_zeros() -> None:
|
||||||
|
assert _max_zero_streak([1, 1, 1, 1]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_zero_streak_single_zero_block() -> None:
|
||||||
|
assert _max_zero_streak([1, 0, 0, 0, 1, 0, 1]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_zero_streak_all_zeros() -> None:
|
||||||
|
assert _max_zero_streak([0, 0, 0]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn(tmp_path: Path) -> sqlite3.Connection:
|
||||||
|
conn = connect(tmp_path / "state.sqlite")
|
||||||
|
run_migrations(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_market(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
asset: str,
|
||||||
|
ts: datetime,
|
||||||
|
spot: Decimal | None = Decimal("3000"),
|
||||||
|
dvol: Decimal | None = Decimal("55"),
|
||||||
|
dealer_net_gamma: Decimal | None = Decimal("-50000000"),
|
||||||
|
fetch_ok: int = 1,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO market_snapshots(timestamp, asset, spot, dvol, "
|
||||||
|
"dealer_net_gamma, fetch_ok) VALUES (?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
ts.isoformat(),
|
||||||
|
asset,
|
||||||
|
str(spot) if spot is not None else None,
|
||||||
|
str(dvol) if dvol is not None else None,
|
||||||
|
str(dealer_net_gamma) if dealer_net_gamma is not None else None,
|
||||||
|
fetch_ok,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_market_full_coverage(tmp_path: Path) -> None:
|
||||||
|
conn = _make_conn(tmp_path)
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
for minute in (0, 15, 30, 45):
|
||||||
|
_seed_market(
|
||||||
|
conn,
|
||||||
|
asset="ETH",
|
||||||
|
ts=since.replace(minute=minute),
|
||||||
|
)
|
||||||
|
report = audit_market_snapshots(
|
||||||
|
conn, asset="ETH", since=since, until=until
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert report.asset == "ETH"
|
||||||
|
assert report.expected_ticks == 4
|
||||||
|
assert report.actual_ticks == 4
|
||||||
|
assert report.coverage_pct == Decimal("100")
|
||||||
|
assert report.gaps == ()
|
||||||
|
assert report.fetch_ok_zero_count == 0
|
||||||
|
assert report.max_fetch_ok_zero_streak == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_market_detects_gap_and_streak(tmp_path: Path) -> None:
|
||||||
|
conn = _make_conn(tmp_path)
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 13, 30, tzinfo=UTC)
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
# 12:00 OK, 12:15 OK, gap (12:30, 12:45 missing), 13:00 fail,
|
||||||
|
# 13:15 fail, 13:30 outside window.
|
||||||
|
_seed_market(conn, asset="ETH", ts=since.replace(minute=0))
|
||||||
|
_seed_market(conn, asset="ETH", ts=since.replace(minute=15))
|
||||||
|
_seed_market(
|
||||||
|
conn, asset="ETH", ts=since.replace(hour=13, minute=0),
|
||||||
|
fetch_ok=0,
|
||||||
|
)
|
||||||
|
_seed_market(
|
||||||
|
conn, asset="ETH", ts=since.replace(hour=13, minute=15),
|
||||||
|
fetch_ok=0,
|
||||||
|
)
|
||||||
|
report = audit_market_snapshots(
|
||||||
|
conn, asset="ETH", since=since, until=until
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert report.expected_ticks == 6
|
||||||
|
assert report.actual_ticks == 4
|
||||||
|
# 12:15 → 13:00 is a 45-min gap → flagged
|
||||||
|
assert len(report.gaps) == 1
|
||||||
|
assert report.gaps[0].gap_minutes == 45
|
||||||
|
assert report.fetch_ok_zero_count == 2
|
||||||
|
assert report.max_fetch_ok_zero_streak == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_market_null_rate_per_column(tmp_path: Path) -> None:
|
||||||
|
conn = _make_conn(tmp_path)
|
||||||
|
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
|
||||||
|
until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
# 4 ticks, 1 with dealer_net_gamma=NULL → 25% null
|
||||||
|
for minute in (0, 15, 30, 45):
|
||||||
|
_seed_market(
|
||||||
|
conn,
|
||||||
|
asset="ETH",
|
||||||
|
ts=since.replace(minute=minute),
|
||||||
|
dealer_net_gamma=None if minute == 30 else Decimal("-50000000"),
|
||||||
|
)
|
||||||
|
report = audit_market_snapshots(
|
||||||
|
conn, asset="ETH", since=since, until=until
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert report.null_rate_by_column["dealer_net_gamma"] == Decimal("0.25")
|
||||||
|
assert report.null_rate_by_column["spot"] == Decimal("0")
|
||||||
Reference in New Issue
Block a user