feat(cli): subcommand 'audit data' — qualità dati market + chain

Usa il group 'audit' esistente (sibling di 'audit verify' per la hash
chain). Opzioni: --db, --since DAYS, --asset ETH|BTC, --json.
Output stdout Rich di default, dump JSON con --json. Esce con codice 2
su sqlite3.OperationalError (DB malformato/schema mancante).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-16 19:37:33 +00:00
parent 06ce2c0eb2
commit 197c9db74d
2 changed files with 256 additions and 0 deletions
+172
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import os
import sqlite3
import sys
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
@@ -23,6 +24,12 @@ from rich.console import Console
from rich.table import Table
from cerbero_bite import __version__
from cerbero_bite.analysis.data_audit import (
ChainAuditReport,
MarketAuditReport,
audit_market_snapshots,
audit_option_chain,
)
from cerbero_bite.clients import HttpToolClient, McpError
from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient
@@ -898,6 +905,171 @@ def audit_verify(audit_path: Path) -> None:
console.print(f"[green]ok[/green] {count} entries verified")
def _market_to_dict(r: MarketAuditReport) -> dict[str, Any]:
return {
"asset": r.asset,
"since": r.since.isoformat(),
"until": r.until.isoformat(),
"expected_ticks": r.expected_ticks,
"actual_ticks": r.actual_ticks,
"coverage_pct": str(r.coverage_pct),
"gaps": [
{
"prev": g.prev_timestamp.isoformat(),
"next": g.next_timestamp.isoformat(),
"gap_minutes": g.gap_minutes,
}
for g in r.gaps
],
"fetch_ok_zero_count": r.fetch_ok_zero_count,
"max_fetch_ok_zero_streak": r.max_fetch_ok_zero_streak,
"null_rate_by_column": {
k: str(v) for k, v in r.null_rate_by_column.items()
},
}
def _chain_to_dict(r: ChainAuditReport) -> dict[str, Any]:
return {
"asset": r.asset,
"since": r.since.isoformat(),
"until": r.until.isoformat(),
"expected_snapshots": r.expected_snapshots,
"actual_snapshots": r.actual_snapshots,
"coverage_pct": str(r.coverage_pct),
"quotes_per_snap_median": r.quotes_per_snap_median,
"quotes_per_snap_p10": r.quotes_per_snap_p10,
"quotes_per_snap_p90": r.quotes_per_snap_p90,
"bid_gt_ask_count": r.bid_gt_ask_count,
"iv_null_count": r.iv_null_count,
"iv_null_pct": str(r.iv_null_pct),
}
def _render_market_report(asset: str, r: MarketAuditReport) -> None:
console.print(
f"\n[bold cyan]=== {asset} — market_snapshots "
f"({r.since.date()}{r.until.date()}) ===[/bold cyan]"
)
console.print(
f" ticks: {r.actual_ticks} expected: {r.expected_ticks} "
f"coverage: {r.coverage_pct}%"
)
console.print(f" gaps > 20min: {len(r.gaps)}")
console.print(
f" fetch_ok=0: {r.fetch_ok_zero_count} rows "
f"(max streak: {r.max_fetch_ok_zero_streak})"
)
bad_nulls = {k: v for k, v in r.null_rate_by_column.items() if v > Decimal("0")}
if bad_nulls:
parts = " ".join(
f"{k} {(v * 100).quantize(Decimal('0.1'))}%"
for k, v in bad_nulls.items()
)
console.print(f" null rate: {parts}")
else:
console.print(" null rate: all columns 0%")
def _render_chain_report(asset: str, r: ChainAuditReport) -> None:
console.print(
f"\n[bold cyan]=== {asset} — option_chain_snapshots "
f"({r.since.date()}{r.until.date()}) ===[/bold cyan]"
)
console.print(
f" snapshots: {r.actual_snapshots} expected: {r.expected_snapshots} "
f"coverage: {r.coverage_pct}%"
)
console.print(
f" quotes/snap: median {r.quotes_per_snap_median} "
f"p10 {r.quotes_per_snap_p10} p90 {r.quotes_per_snap_p90}"
)
console.print(f" bid > ask: {r.bid_gt_ask_count}")
console.print(
f" IV null: {r.iv_null_count} quotes ({r.iv_null_pct}%)"
)
@audit.command(name="data")
@click.option(
"--db",
"db_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=_DEFAULT_DB_PATH,
show_default=True,
help="Path al DB SQLite di stato.",
)
@click.option(
"--since",
"since_days",
type=int,
default=7,
show_default=True,
help="Finestra di analisi (giorni indietro da ora).",
)
@click.option(
"--asset",
"asset",
type=click.Choice(["ETH", "BTC"], case_sensitive=False),
default=None,
help="Limita l'audit a un singolo asset (default: entrambi).",
)
@click.option(
"--json",
"as_json",
is_flag=True,
default=False,
help="Stampa solo dump JSON, niente tabelle umane.",
)
def audit_data(
db_path: Path,
since_days: int,
asset: str | None,
as_json: bool,
) -> None:
"""Audit qualità dati: market_snapshots + option_chain_snapshots."""
import json as _json # noqa: PLC0415
until = datetime.now(UTC)
since = until - timedelta(days=since_days)
assets = [asset.upper()] if asset else ["ETH", "BTC"]
conn = connect_state(db_path)
try:
market_reports = {
a: audit_market_snapshots(conn, asset=a, since=since, until=until)
for a in assets
}
chain_reports = {
a: audit_option_chain(conn, asset=a, since=since, until=until)
for a in assets
}
except sqlite3.OperationalError as exc:
console.print(f"[red]DB error[/red]: {exc}")
sys.exit(2)
finally:
conn.close()
if as_json:
payload = {
"since": since.isoformat(),
"until": until.isoformat(),
"assets": {
a: {
"market": _market_to_dict(market_reports[a]),
"chain": _chain_to_dict(chain_reports[a]),
}
for a in assets
},
}
click.echo(_json.dumps(payload, indent=2, default=str))
return
for a in assets:
_render_market_report(a, market_reports[a])
_render_chain_report(a, chain_reports[a])
@main.group()
def state() -> None:
"""State inspection utilities."""
+84
View File
@@ -0,0 +1,84 @@
"""Smoke tests for the ``cerbero-bite audit data`` CLI subcommand."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from click.testing import CliRunner
from cerbero_bite.cli import main as cli_main
from cerbero_bite.state import connect, run_migrations, transaction
def _seed_minimal_db(db_path: Path) -> None:
conn = connect(db_path)
try:
run_migrations(conn)
now = datetime.now(UTC).replace(microsecond=0)
with transaction(conn):
for asset, spot in (("ETH", "3000"), ("BTC", "80000")):
conn.execute(
"INSERT INTO market_snapshots(timestamp, asset, spot, "
"dvol, fetch_ok) VALUES (?,?,?,?,1)",
(now.isoformat(), asset, spot, "55"),
)
conn.execute(
"INSERT INTO option_chain_snapshots(timestamp, asset, "
"instrument_name, strike, expiry, option_type, bid, ask, iv) "
"VALUES (?, 'ETH', 'ETH-X', '3000', ?, 'C', '0.01', '0.02', '0.7')",
(now.isoformat(), now.isoformat()),
)
finally:
conn.close()
def test_audit_data_human_output(tmp_path: Path) -> None:
db = tmp_path / "state.sqlite"
_seed_minimal_db(db)
result = CliRunner().invoke(
cli_main, ["audit", "data", "--db", str(db), "--since", "1"]
)
assert result.exit_code == 0, result.output
assert "ETH — market_snapshots" in result.output
assert "BTC — market_snapshots" in result.output
assert "ETH — option_chain_snapshots" in result.output
def test_audit_data_json_output(tmp_path: Path) -> None:
db = tmp_path / "state.sqlite"
_seed_minimal_db(db)
result = CliRunner().invoke(
cli_main,
["audit", "data", "--db", str(db), "--since", "1", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert "since" in payload
assert "until" in payload
assert set(payload["assets"]) == {"ETH", "BTC"}
assert "market" in payload["assets"]["ETH"]
assert "chain" in payload["assets"]["ETH"]
assert payload["assets"]["ETH"]["market"]["asset"] == "ETH"
def test_audit_data_single_asset_filter(tmp_path: Path) -> None:
db = tmp_path / "state.sqlite"
_seed_minimal_db(db)
result = CliRunner().invoke(
cli_main,
["audit", "data", "--db", str(db), "--asset", "BTC", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert set(payload["assets"]) == {"BTC"}
def test_audit_data_missing_db_exits_2(tmp_path: Path) -> None:
# click.Path(exists=True) returns exit_code=2 (UsageError) for missing files.
result = CliRunner().invoke(
cli_main,
["audit", "data", "--db", str(tmp_path / "absent.sqlite")],
)
assert result.exit_code == 2