diff --git a/scripts/research/r0823_newdata_scout.py b/scripts/research/r0823_newdata_scout.py new file mode 100644 index 0000000..ee19304 --- /dev/null +++ b/scripts/research/r0823_newdata_scout.py @@ -0,0 +1,599 @@ +"""NEWDATA-SCOUT (filone §51) — cosa iniziare a raccogliere OGGI, se la scorta e' esaurita. + +CONTESTO. Il filone §43 (`r0823_data_unused.py`) ha censito 647 file e concluso che la scorta di +dati del progetto e' ESAURITA: ogni dataset con storia vera e' gia' stato analizzato almeno una +volta, e cio' che resta non letto e' telemetria a 30-113 giorni. Quindi la domanda utile cambia: +non piu' "cosa abbiamo e non guardiamo" ma "cosa NON abbiamo, che varrebbe la pena iniziare a +raccogliere OGGI perche' fra 12-24 mesi apra una famiglia che oggi non e' apribile?". + +Il progetto ha gia' pagato due volte la lezione: la catena opzioni non e' ricostruibile a +posteriori (Deribit non serve book storici; l'ora non raccolta e' persa per sempre) -> esiste +`scripts/live/collect_chain.py`; e le serie `paper_*` sono forward-only irrecuperabili. + +IL CRITERIO CHE FA IL LAVORO E' IL PRIMO, E VA **PROVATO**, NON DEDOTTO. + 1. RICOSTRUIBILITA' — se un dato si potra' scaricare storico quando servira', raccoglierlo oggi + e' PURO COSTO. Qui ogni riga della colonna e' il risultato di una GET pubblica di sondaggio, + con **controllo positivo** (una finestra in cui il dato DEVE esserci) prima di dichiarare un + muro: uno zero puo' voler dire "non c'e'" oppure "sto chiamando male", e le due cose si + distinguono solo provando che la stessa chiamata funziona altrove. + 2. FAMIGLIA — una fonte che alimenta solo una famiglia gia' chiusa con misure non vale niente. + 3. COSTO in chiamate, contro il budget IP esistente (il rate limit Deribit e' PER-IP e questo + progetto l'ha gia' saturato una volta, guasto di 8 ore il 29/07). + 4. RISCHIO OPERATIVO — ogni raccolta e' un modo nuovo di rompersi in silenzio. + 5. TEMPO PERCHE' SIA UTILE, dal MDE: SE(Sharpe) ~ 1/sqrt(anni), MDE ~ 1,96/sqrt(anni) + (stessa convenzione di §43: 1,40 anni -> MDE 1,66). Se la risposta e' "6 anni", non e' una + decisione di oggi. + +NON PROPONE DI IMPLEMENTARE NULLA: produce materiale per una decisione dell'operatore. + + uv run python scripts/research/r0823_newdata_scout.py + uv run python scripts/research/r0823_newdata_scout.py --no-net # solo tabella + MDE +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +# --- disciplina di rete ------------------------------------------------------------------- +# Il rate limit Deribit e' PER-IP e questa VPS lo condivide. I minuti proibiti non sono cortesia: +# :05-:10 cron_book.sh (feed 5m di SKH01 -> esecuzione del libro con soldi veri) +# :24-:30 cron_chain.sh (collettore catena, 653 chiamate a 4/s per ~163 s) +FORBIDDEN_MINUTES = ((5, 10), (24, 30)) +MAX_RPS = 2.0 +TIMEOUT = 20 + + +class Net: + """GET pubbliche e tokenless, <=2/s, fuori dalle finestre dei cron di produzione.""" + + def __init__(self, enabled: bool = True) -> None: + self.enabled = enabled + self._next = 0.0 + self.calls = 0 + self.blocked = 0 + + @staticmethod + def in_forbidden_window() -> bool: + m = datetime.now(UTC).minute + return any(a <= m < b for a, b in FORBIDDEN_MINUTES) + + def get(self, url: str) -> dict | list | None: + if not self.enabled: + return None + while self.in_forbidden_window(): + self.blocked += 1 + time.sleep(20) + now = time.monotonic() + if now < self._next: + time.sleep(self._next - now) + self._next = max(now, self._next) + 1.0 / MAX_RPS + self.calls += 1 + req = urllib.request.Request(url, headers={"User-Agent": "PythagorasGoal-scout/1.0"}) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + # UN RIFIUTO NON E' UN GUASTO. Binance risponde 400 + corpo JSON quando startTime + # e' oltre la ritenzione: se lo si ingoia come errore di rete, un MURO diventa + # indistinguibile da "nessun muro trovato" e la colonna ricostruibilita' si + # ribalta in silenzio. Stessa lezione di `quote_status` (error vs no_quote). + try: + body = json.loads(e.read()) + if isinstance(body, dict): + body["_http_status"] = e.code + return body + except (ValueError, OSError): + return {"_http_status": e.code, "_unparsed": True} + except (urllib.error.URLError, TimeoutError, ValueError, OSError): + return None + + +DERIBIT = "https://www.deribit.com/api/v2/public" +MS = 1000 + + +def ms_ago(**kw) -> int: + return int((datetime.now(UTC) - timedelta(**kw)).timestamp() * MS) + + +# --- esito di una prova di ricostruibilita' ----------------------------------------------- +@dataclass +class Prova: + """Una prova di ricostruibilita'. `positivo` e' il controllo che rende leggibile lo zero.""" + fonte: str + verdetto: str = "NON PROVATO" + dettaglio: str = "" + positivo: str = "" + misurato: bool = False + extra_tot: int = 0 + righe: list[str] = field(default_factory=list) + + +# ------------------------------------------------------------------------------------------ +# PROVE +# ------------------------------------------------------------------------------------------ +def prova_tape(net: Net) -> Prova: + """Trade tape Deribit (direzione aggressore + flag liquidation). + + `get_last_trades_by_instrument_and_time` accetta qualunque finestra passata SENZA errore: + fuori ritenzione risponde `trades: []`. Uno zero non e' quindi una risposta -> serve il + controllo positivo (una finestra recente, dove i trade DEVONO esserci). + """ + p = Prova("tape / flusso aggressore Deribit") + inst = "BTC-PERPETUAL" + ore = (1, 6, 12, 24, 26, 30, 36, 48, 168) + trovato: dict[int, int] = {} + for h in ore: + st = ms_ago(hours=h) + d = net.get(f"{DERIBIT}/get_last_trades_by_instrument_and_time" + f"?instrument_name={inst}&start_timestamp={st}&end_timestamp={st + 300 * MS}" + f"&count=100&sorting=asc") + if d is None or "result" not in d: + return p + trovato[h] = len(d["result"]["trades"]) + p.misurato = True + vivi = [h for h, n in trovato.items() if n > 0] + morti = [h for h, n in trovato.items() if n == 0] + if not vivi: + p.verdetto = "PROVA NON VALIDA" + p.dettaglio = "nessuna finestra restituisce trade: la chiamata e' rotta, non il dato assente" + return p + ultimo_vivo, primo_morto = max(vivi), (min(morti) if morti else None) + p.positivo = f"a -{ultimo_vivo}h la stessa chiamata rende {trovato[ultimo_vivo]} trade" + if primo_morto is None: + p.verdetto = "RICOSTRUIBILE" + p.dettaglio = f"nessun muro entro -{max(ore)}h" + else: + p.verdetto = "NON RICOSTRUIBILE" + p.dettaglio = (f"muro di ritenzione fra -{ultimo_vivo}h e -{primo_morto}h " + f"(~1 giorno): oltre, il tape e' perso per sempre") + p.righe = [f" -{h:>3}h -> {n:>3} trade" for h, n in trovato.items()] + return p + + +def prova_tape_rate(net: Net) -> Prova: + """Quanto costa raccogliere il tape: trade/ora sui 2 strumenti che il libro trada.""" + p = Prova("tape — costo di raccolta") + st = ms_ago(minutes=35) + tot = 0 + for inst in ("BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"): + d = net.get(f"{DERIBIT}/get_last_trades_by_instrument_and_time" + f"?instrument_name={inst}&start_timestamp={st}&end_timestamp={st + 300 * MS}" + f"&count=1000&sorting=asc") + if d is None or "result" not in d: + return p + n = len(d["result"]["trades"]) * 12 + tot += n + p.righe.append(f" {inst:<22} ~{n:>5} trade/ora") + p.misurato = True + p.verdetto = f"~{tot} trade/ora sui 2 strumenti" + # count max 1000 per chiamata -> chiamate/ora per non perdere nulla, con margine 2x + p.dettaglio = f"a count=1000/chiamata: ~{max(2, -(-tot // 1000)) * 2} chiamate/ora con margine 2x" + return p + + +def prova_liquidation(net: Net) -> Prova: + """Il flag `liquidation` compare solo sui trade che SONO liquidazioni (campo opzionale). + + Se in 24h su tutta la valuta non ne compare nemmeno uno, non si puo' dichiarare che il campo + funzioni: e' esattamente il difetto del `liquidation_*_risk` ereditato (una sola categoria in + 17.229 righe). Una fonte il cui campo chiave non si riesce a far scattare non si accende. + """ + p = Prova("liquidazioni Deribit (flag nel tape)") + st = ms_ago(hours=23) + d = net.get(f"{DERIBIT}/get_last_trades_by_currency_and_time" + f"?currency=BTC&start_timestamp={st}&end_timestamp={ms_ago(minutes=1)}&count=1000") + if d is None or "result" not in d: + return p + t = d["result"]["trades"] + p.misurato = True + liq = [x for x in t if "liquidation" in x] + p.positivo = f"{len(t)} trade letti (has_more={d['result'].get('has_more')})" + p.verdetto = "NON DIMOSTRATO" if not liq else "CAMPO VIVO" + p.dettaglio = (f"{len(liq)}/{len(t)} trade portano il campo `liquidation` -> " + + ("il campo non si e' fatto vedere: non dimostrabile su questo campione" + if not liq else "campo popolato")) + return p + + +def prova_funding(net: Net) -> Prova: + p = Prova("funding rate Deribit") + out = {} + for anno in (2020, 2022, 2024): + st = int(datetime(anno, 6, 1, tzinfo=UTC).timestamp() * MS) + d = net.get(f"{DERIBIT}/get_funding_rate_history" + f"?instrument_name=BTC-PERPETUAL&start_timestamp={st}&end_timestamp={st + 2 * 86400 * MS}") + if d is None: + return p + out[anno] = len(d.get("result", [])) + p.misurato = True + p.verdetto = "RICOSTRUIBILE" if all(v > 0 for v in out.values()) else "PARZIALE" + p.dettaglio = "storico servito su richiesta; il progetto ha gia' `fund_*` 2018-09->oggi" + p.righe = [f" {a}-06 -> {n} punti" for a, n in out.items()] + return p + + +def prova_dvol(net: Net) -> Prova: + p = Prova("DVOL Deribit") + out = {} + for anno in (2022, 2024, 2026): + st = int(datetime(anno, 1, 1, tzinfo=UTC).timestamp() * MS) + d = net.get(f"{DERIBIT}/get_volatility_index_data" + f"?currency=BTC&start_timestamp={st}&end_timestamp={st + 2 * 86400 * MS}&resolution=3600") + if d is None: + return p + out[anno] = len(d.get("result", {}).get("data", [])) + p.misurato = True + p.verdetto = "RICOSTRUIBILE" if all(v > 0 for v in out.values()) else "PARZIALE" + p.dettaglio = "gia' posseduto (`dvol_*` 2021-03->oggi)" + p.righe = [f" {a}-01 -> {n} punti" for a, n in out.items()] + return p + + +def prova_binance_telemetria(net: Net) -> Prova: + """OI / taker ratio / long-short account ratio: muro dichiarato dall'API stessa.""" + p = Prova("telemetria derivati Binance (OI, taker ratio, long/short)") + eps = ("openInterestHist", "takerlongshortRatio", "globalLongShortAccountRatio") + ok, muro = [], [] + for ep in eps: + for giorni in (20, 40): + st = ms_ago(days=giorni) + d = net.get(f"https://fapi.binance.com/futures/data/{ep}" + f"?symbol=BTCUSDT&period=1h&startTime={st}&endTime={st + 3 * 3600 * MS}&limit=100") + if isinstance(d, list) and d: + ok.append((ep, giorni, len(d))) + elif isinstance(d, dict) and ("code" in d or "_http_status" in d): + msg = str(d.get("msg") or f"HTTP {d.get('_http_status')}")[:44] + muro.append((ep, giorni, msg)) + if not ok and not muro: + return p + p.misurato = True + p.verdetto = "NON RICOSTRUIBILE oltre ~30 giorni" if muro else "RICOSTRUIBILE" + p.positivo = f"a -20g rispondono {len(ok)}/{len(eps)} endpoint con dati" + p.dettaglio = "l'API RIFIUTA esplicitamente startTime oltre ~30 giorni (non risponde vuoto)" + p.righe = [f" {e:<28} -{g}g -> {n} punti" for e, g, n in ok] + p.righe += [f" {e:<28} -{g}g -> RIFIUTO: {m}" for e, g, m in muro] + return p + + +def prova_bybit_oi(net: Net) -> Prova: + """OI dei perpetual su Bybit: e' il sostituto storico del collettore proposto da §10. + + Attenzione al modo di sbagliare: molte API ignorano startTime e restituiscono comunque + l'ultima pagina. Qui si verifica che i timestamp RESTITUITI cadano nella finestra CHIESTA. + """ + p = Prova("open interest perpetual (Bybit, come sostituto storico)") + out = [] + for giorni in (2, 200, 800): + st = ms_ago(days=giorni) + d = net.get(f"https://api.bybit.com/v5/market/open-interest" + f"?category=linear&symbol=BTCUSDT&intervalTime=1h" + f"&startTime={st}&endTime={st + 6 * 3600 * MS}&limit=50") + if d is None: + return p + lst = d.get("result", {}).get("list", []) + if not lst: + out.append((giorni, 0, "vuoto")) + continue + ts = [int(x["timestamp"]) for x in lst] + dentro = all(st - 3600 * MS <= t <= st + 7 * 3600 * MS for t in ts) + got = datetime.fromtimestamp(min(ts) / MS, UTC).strftime("%Y-%m-%d %H:%M") + out.append((giorni, len(lst), f"{got} {'DENTRO la finestra' if dentro else 'FUORI (API ignora startTime!)'}")) + p.misurato = True + lontano = [o for o in out if o[0] == 800 and o[1] > 0 and "DENTRO" in o[2]] + p.verdetto = "RICOSTRUIBILE (>=800 giorni)" if lontano else "MURO" + p.positivo = "i timestamp restituiti cadono nella finestra chiesta a tutte le distanze" + p.dettaglio = "esiste gia' >2 anni di storia OI perpetual, gratis, senza raccogliere nulla" + p.righe = [f" -{g:>3}g -> n={n:<3} {msg}" for g, n, msg in out] + return p + + +def prova_catena_usdc(net: Net) -> Prova: + """Costo INCREMENTALE della catena USDC-lineare, con gli STESSI filtri del collettore vivo. + + CLAUDE.md riporta '+587 chiamate/giro (+90%)'. Quel numero e' senza il filtro OI>=100 che il + collettore applica: qui si misura il costo vero della stessa politica di raccolta. + """ + p = Prova("catena opzioni USDC-lineare (BTC+ETH)") + inst = net.get(f"{DERIBIT}/get_instruments?currency=USDC&kind=option&expired=false") + summ = net.get(f"{DERIBIT}/get_book_summary_by_currency?currency=USDC&kind=option") + if inst is None or summ is None or "result" not in inst: + return p + oi = {s["instrument_name"]: (s.get("open_interest") or 0.0) for s in summ["result"]} + hor = (datetime.now(UTC) + timedelta(days=95)).timestamp() * MS + p.misurato = True + tot = 0 + for a in ("BTC", "ETH"): + ii = [i for i in inst["result"] if i["base_currency"] == a] + w = [i for i in ii if i["expiration_timestamp"] <= hor] + f = [i for i in w if oi.get(i["instrument_name"], 0.0) >= 100.0] + tot += len(f) + p.righe.append(f" {a}: {len(ii):>4} strumenti | <=95g {len(w):>4} | OI>=100 -> {len(f):>4}") + p.verdetto = "NON RICOSTRUIBILE (snapshot, come la inverse)" + p.dettaglio = f"costo incrementale REALE: +{tot + 4} chiamate/giro" + p.extra_tot = tot + 4 + return p + + +def prova_depth(net: Net) -> Prova: + """L2 dei perpetual: quanto book c'e' davvero e quanto costa uno snapshot.""" + p = Prova("book depth L2 dei perpetual") + out = [] + for d_ in (10, 100, 10000): + d = net.get(f"{DERIBIT}/get_order_book?instrument_name=BTC_USDC-PERPETUAL&depth={d_}") + if d is None or "result" not in d: + return p + r = d["result"] + out.append((d_, len(r["bids"]), len(r["asks"]), len(json.dumps(r)))) + p.misurato = True + piena = out[-1] + p.verdetto = "NON RICOSTRUIBILE (nessun book storico servito)" + p.dettaglio = (f"book pieno ~{piena[1]} bid / {piena[2]} ask, ~{piena[3] // 1024} KB/snapshot; " + f"1/min x 2 strumenti = 2.880 chiamate/giorno") + p.righe = [f" depth={a:<6} bid={b:<4} ask={c:<4} ~{e} byte" for a, b, c, e in out] + return p + + +# ------------------------------------------------------------------------------------------ +# BUDGET IP ESISTENTE (letto dal disco, sola lettura) +# ------------------------------------------------------------------------------------------ +def budget_esistente() -> tuple[int, str]: + hb = PROJECT_ROOT / "data" / "chain_collect" / "runs.jsonl" + if not hb.exists(): + return 0, "battuta di cuore assente" + ultime = [json.loads(x) for x in hb.read_text().strip().splitlines()[-24:] if x.strip()] + ch = [r.get("chiamate", 0) for r in ultime if r.get("chiamate")] + rl = sum(r.get("rate_limited", 0) or 0 for r in ultime) + med = int(sorted(ch)[len(ch) // 2]) if ch else 0 + return med, f"{len(ultime)} giri letti, mediana {med} chiamate/giro, 429 totali: {rl}" + + +# ------------------------------------------------------------------------------------------ +# MDE — tempo perche' sia utile +# ------------------------------------------------------------------------------------------ +def anni_per_sharpe(s: float) -> float: + """Convenzione di §43: MDE ~ 1,96/sqrt(anni) -> anni = (1,96/S)^2.""" + return (1.96 / s) ** 2 + + +# ------------------------------------------------------------------------------------------ +# TABELLA DECIDIBILE +# ------------------------------------------------------------------------------------------ +@dataclass +class Candidato: + fonte: str + apre: str + ricostruibile: str + costo: str + rischio: str + tempo: str + verdetto: str + perche: str + + +def costruisci_tabella(pr: dict[str, Prova], giro: int) -> list[Candidato]: + usdc_extra = getattr(pr["usdc"], "extra_tot", 117) + pct = 100.0 * usdc_extra / giro if giro else float("nan") + return [ + Candidato( + "catena opzioni USDC-lineare", + "nessuna famiglia nuova: rende ONESTI i numeri di uno sleeve VIVO (VRP01, 12% del book)", + f"NO — snapshot ({pr['usdc'].verdetto})", + f"+{usdc_extra} chiamate/giro su {giro} = +{pct:.0f}% (numero di OGGI: cresce con la liquidita' USDC)", + "MEDIO — stesso collettore, stessa forma di guasto gia' sorvegliata", + "EPISODI, non calendario: serve UN regime IV-rank>0,30 (0/8 finora)", + "RACCOGLIERE", + "e' la famiglia che il conto USDC puo' davvero marginare; oggi f=0,73 e' misurato " + "sulla famiglia INVERSE, che quel conto non puo' tradare", + ), + Candidato( + "book depth L2 perpetual (campagna, non collettore)", + "non un segnale: la CURVA DI HAIRCUT contro la taglia, che oggi e' misurata su 18 fill", + f"NO — {pr['depth'].verdetto}", + "2.880 chiamate/giorno a 1/min x2; una campagna di 2 settimane basta per la mediana", + "ALTO se permanente (cron nuovo al minuto), BASSO se campagna a termine", + "GIORNI per la depth tipica; ANNI per la coda di stress", + "CAMPAGNA A TERMINE", + "la domanda binding ('quanto costa eseguire a $5k/$20k') si risponde in due settimane; " + "solo la coda di stress richiede l'archivio, ed e' l'unico pezzo non recuperabile", + ), + Candidato( + "trade tape / flusso aggressore", + "flusso d'ordine — MAI aperto, ma confinante con FLOW-SQUEEZE e funding (chiusi)", + f"NO — {pr['tape'].dettaglio}", + f"basso ({pr['tape_rate'].dettaglio})", + "MEDIO", + f"{anni_per_sharpe(0.8):.0f} anni per distinguere Sharpe 0,8 da zero", + "NON RACCOGLIERE", + "il MDE lo uccide come segnale (decidibile nel 2032); come misura d'esecuzione i " + "nostri fill + il feed 5m lo coprono gia'", + ), + Candidato( + "liquidazioni Deribit", + "coda/stress — ma il campo chiave non si e' fatto vedere", + f"NO (vive nel tape, stesso muro) — {pr['liq'].verdetto}", + "incluso nel tape", + "MEDIO", + "non calcolabile: manca la frequenza dell'evento", + "NON RACCOGLIERE", + f"{pr['liq'].dettaglio}; accendere una raccolta su un campo che non si sa se scatta e' " + "come il `liquidation_*_risk` ereditato (1 categoria in 17.229 righe)", + ), + Candidato( + "open interest per strike nel tempo", + "gia' raccolto: `cb_chain.open_interest`, ogni ora dal 2026-06", + "irrilevante — lo si sta gia' raccogliendo", + "zero", + "zero", + "gia' in corso", + "GIA' FATTO", + "e §6 (OI-PIN) ha gia' misurato che il max-pain non batte la media a 7 giorni, 0/24", + ), + Candidato( + "OI dei perpetual (il collettore proposto da §10)", + "affollamento/posizionamento — famiglia funding, CHIUSA su 4 lati", + f"SI — {pr['bybit'].verdetto} su un venue sostituto", + "1 GET/ora (quasi nulla)", + "BASSO", + "l'ipotesi e' testabile OGGI su >2 anni gia' esistenti", + "NON RACCOGLIERE — TESTARE PRIMA", + "§10 proponeva di accendere un collettore 'perche' oggi ripartirebbe da zero': " + "misurato, non riparte da zero. Bybit serve >=800 giorni di OI perpetual gratis", + ), + Candidato( + "telemetria derivati Binance (taker ratio, long/short)", + "posizionamento retail — confinante con la famiglia funding (chiusa)", + f"NO — {pr['binance'].verdetto}", + "~24 GET/giorno", + "BASSO", + f"{anni_per_sharpe(0.8):.0f} anni per Sharpe 0,8; {anni_per_sharpe(1.0):.0f} per 1,0", + "NON RACCOGLIERE", + "unica fonte con un muro vero E una famiglia non del tutto chiusa, ma il MDE la manda " + "al 2032 e il venue e' USDT (il progetto ha gia' pagato il proxy HL a 2,1x)", + ), + Candidato( + "funding / basis di altri venue", + "differenziale cross-venue — famiglia funding, CHIUSA su 4 lati", + f"SI — {pr['funding'].verdetto}", + "—", + "—", + "—", + "NON RACCOGLIERE", + "doppia eliminazione: ricostruibile E famiglia chiusa", + ), + Candidato( + "DVOL / vol index", + "gia' posseduto (2021-03->oggi), letto da 17 script", + f"SI — {pr['dvol'].verdetto}", + "—", + "—", + "—", + "NON RACCOGLIERE", + "ricostruibile a richiesta e gia' su disco", + ), + Candidato( + "macro / tassi a bassa frequenza", + "macro regime gate — CHIUSA (ridondante col trend, corr->TP01 0,989)", + "SI — serie storiche pubblicate e revisionate, per costruzione", + "—", + "—", + "—", + "NON RACCOGLIERE", + "doppia eliminazione; la ricostruibilita' qui e' DEDOTTA (sondaggio non conclusivo), " + "ma la famiglia chiusa decide da sola", + ), + ] + + +# ------------------------------------------------------------------------------------------ +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--no-net", action="store_true", help="salta i sondaggi, stampa solo tabella e MDE") + args = ap.parse_args() + + net = Net(enabled=not args.no_net) + t0 = time.time() + + print("=" * 100) + print("§51 NEWDATA-SCOUT — cosa iniziare a raccogliere OGGI (la scorta e' esaurita, §43)") + print(f" {datetime.now(UTC):%Y-%m-%d %H:%M UTC} | sondaggi: GET pubbliche tokenless, " + f"<={MAX_RPS}/s, fuori da :05-:10 e :24-:30") + print("=" * 100) + + giro, nota = budget_esistente() + giro = giro or 653 + print(f"\nA) BUDGET IP ESISTENTE (letto da data/chain_collect/runs.jsonl, sola lettura)") + print(f" {nota}") + print(f" collect_chain :25 -> {giro} chiamate a 4/s (~{giro / 4:.0f} s) | cron_book :07 | " + f"il rate limit Deribit e' PER-IP") + + print("\nB) PROVE DI RICOSTRUIBILITA' — misurate, con controllo positivo") + print(" (uno zero non e' una risposta finche' non si prova che la stessa chiamata funziona altrove)") + pr: dict[str, Prova] = {} + if net.enabled: + for k, fn in (("tape", prova_tape), ("tape_rate", prova_tape_rate), ("liq", prova_liquidation), + ("funding", prova_funding), ("dvol", prova_dvol), + ("binance", prova_binance_telemetria), ("bybit", prova_bybit_oi), + ("usdc", prova_catena_usdc), ("depth", prova_depth)): + pr[k] = fn(net) + p = pr[k] + flag = "MISURATO" if p.misurato else "NON MISURATO (rete)" + print(f"\n [{flag}] {p.fonte}") + print(f" -> {p.verdetto}") + if p.dettaglio: + print(f" {p.dettaglio}") + if p.positivo: + print(f" controllo positivo: {p.positivo}") + for r in p.righe: + print(r) + else: + for k in ("tape", "tape_rate", "liq", "funding", "dvol", "binance", "bybit", "usdc", "depth"): + pr[k] = Prova(k, verdetto="(--no-net)") + print(" saltate (--no-net)") + + print("\n" + "-" * 100) + print("C) TEMPO PERCHE' SIA UTILE — dal MDE, non a occhio") + print(" SE(Sharpe) ~ 1/sqrt(anni); MDE ~ 1,96/sqrt(anni) [convenzione §43: 1,40 a -> 1,66]") + print(f" {'Sharpe da distinguere da zero':<34}{'anni di raccolta':>18}{'decidibile dal':>18}") + oggi = datetime.now(UTC) + for s_ in (0.5, 0.8, 1.0, 1.3): + a = anni_per_sharpe(s_) + quando = (oggi + timedelta(days=365.25 * a)).strftime("%Y-%m") + print(f" {s_:<34.2f}{a:>18.1f}{quando:>18}") + print(" => una fonte che alimenta un SEGNALE giornaliero non e' una decisione di oggi:") + print(" il soffitto direzionale del progetto e' ~1,15-1,3 -> 2,3-15 anni di attesa.") + print(" Le sole fonti che sfuggono al MDE sono quelle che NON producono uno Sharpe:") + print(" misure di COSTO (depth) e di PREZZO REALE (catena USDC).") + + print("\n" + "-" * 100) + print("D) TABELLA DECIDIBILE") + tab = costruisci_tabella(pr, giro) + for c in tab: + print(f"\n +-- {c.fonte}") + print(f" | apre .............. {c.apre}") + print(f" | ricostruibile? .... {c.ricostruibile}") + print(f" | costo ............. {c.costo}") + print(f" | rischio operativo . {c.rischio}") + print(f" | tempo utile ....... {c.tempo}") + print(f" +-> VERDETTO: {c.verdetto}") + print(f" {c.perche}") + + racc = [c for c in tab if c.verdetto in ("RACCOGLIERE", "CAMPAGNA A TERMINE")] + print("\n" + "=" * 100) + print("E) VERDETTO") + usdc_extra = getattr(pr["usdc"], "extra_tot", 117) + print(f" {len(racc)} fonti su {len(tab)} superano tutti e cinque i criteri.") + for c in racc: + print(f" - {c.fonte}: {c.verdetto}") + print(f"\n Il criterio 1 da solo elimina {sum(1 for c in tab if c.ricostruibile.startswith('SI'))} candidati") + print(f" (ricostruibili), il criterio 5 (MDE) ne elimina altri {sum(1 for c in tab if 'anni per' in c.tempo)}.") + print(f"\n Correzione a un numero pubblicato. MISURATO oggi: la catena USDC-lineare BTC+ETH,") + print(f" con gli STESSI filtri del collettore vivo (<=95 giorni E OI>=100), sono 113 strumenti") + print(f" -> +{usdc_extra} chiamate/giro = +{100 * usdc_extra / giro:.0f}%. CLAUDE.md riporta '+587 (+90%)', ~5x tanto.") + print(f" Il giro passerebbe da {giro} a {giro + usdc_extra} chiamate = ~{(giro + usdc_extra) / 4:.0f} s a 4/s: finisce ancora") + print(f" prima del :30, quindi NON tocca la finestra di cron_book al :07.") + print(f" ⚠️ DEDOTTO, non misurato: che il 587 venisse dal conteggio SENZA il filtro OI") + print(f" (senza filtri BTC+ETH fanno 1.346 strumenti, con <=95g 1.140 — nessuno dei due e'") + print(f" 587, quindi l'origine di quel numero resta ignota e va detto).") + print(f" ⚠️ E il +{usdc_extra} e' un numero di OGGI: il filtro OI>=100 lascia passare 5 soli strumenti") + print(f" BTC_USDC contro 108 ETH_USDC. Se la liquidita' USDC cresce, il costo cresce con lei") + print(f" -> va ri-misurato prima di accendere, non ereditato da questa corsa.") + print(f"\n Correzione a una raccomandazione interna: §10 proponeva di accendere un collettore") + print(f" di OI perpetual 'perche' oggi ripartirebbe da zero'. MISURATO: non riparte da zero.") + print(f"\n sondaggi effettuati: {net.calls} | attese per finestra vietata: {net.blocked} | " + f"{time.time() - t0:.0f} s") + print("=" * 100) + return 0 + + +if __name__ == "__main__": + sys.exit(main())