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 asyncio
import os import os
import sqlite3
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -23,6 +24,12 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from cerbero_bite import __version__ 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 import HttpToolClient, McpError
from cerbero_bite.clients.deribit import DeribitClient from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient 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") 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() @main.group()
def state() -> None: def state() -> None:
"""State inspection utilities.""" """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