Compare commits
10 Commits
19695e4730
...
bb2ca425a7
| 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