merge lavoro/goldrake-u11 (secondo giro 27/09)
This commit is contained in:
@@ -79,6 +79,7 @@ SOGLIA_OK = 5.0 # <= -> nessuna azione
|
||||
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
|
||||
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
|
||||
D_CAGR_PER_BPS = -0.0023
|
||||
SOGLIA_DIVERGENZA_BPS = 1.0 # #37 B9: |fee realizzata - tier pubblico| oltre questo -> notify
|
||||
|
||||
|
||||
def fetch_fees(instrument: str) -> dict:
|
||||
@@ -209,14 +210,30 @@ def run() -> dict:
|
||||
changes = diff_vs(prev, cur)
|
||||
worst = max(c["taker_bps"] for c in cur.values())
|
||||
lvl, why = verdict(worst)
|
||||
# issue #37 (B9). realized_fee_bps() girava SOLO dentro il blocco di stampa: in cron
|
||||
# (--quiet, il caso normale) non girava mai e una fee realmente pagata diversa dal tier
|
||||
# pubblico non avvisava nessuno. Qui si calcola SEMPRE ed entra nel record del giro
|
||||
# (registra_giro, sotto). {} -> None: "non misurata" (nessun trade recente), mai "zero".
|
||||
real = realized_fee_bps() or None
|
||||
divergenze = []
|
||||
if real:
|
||||
for ins, bps in real.items():
|
||||
tier = cur.get(ins, {}).get("taker_bps")
|
||||
if tier is None:
|
||||
continue
|
||||
d = bps - tier
|
||||
if abs(d) > SOGLIA_DIVERGENZA_BPS:
|
||||
divergenze.append(
|
||||
f"{ins}: realizzata {bps:.2f} bps vs tier pubblico {tier:.2f} ({d:+.2f})")
|
||||
return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why,
|
||||
first_read=not prev)
|
||||
first_read=not prev, realized=real, divergenze=divergenze,
|
||||
divergenza_gia_detta=bool(prev.get("divergenza_detta")))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = run()
|
||||
quiet = "--quiet" in sys.argv
|
||||
interessante = bool(r["changes"]) or r["level"] != "OK"
|
||||
interessante = bool(r["changes"]) or r["level"] != "OK" or bool(r.get("divergenze"))
|
||||
|
||||
if not quiet or interessante:
|
||||
print("=" * 86)
|
||||
@@ -232,7 +249,7 @@ def main() -> int:
|
||||
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
||||
for c in r["changes"]:
|
||||
print(f" CAMBIO: {c}")
|
||||
real = realized_fee_bps()
|
||||
real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9)
|
||||
if real:
|
||||
print("\n cross-check sui trade REALI del conto (fonte autorevole):")
|
||||
for ins, bps in real.items():
|
||||
@@ -240,7 +257,8 @@ def main() -> int:
|
||||
nota = ""
|
||||
if tier is not None:
|
||||
d = bps - tier
|
||||
nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "")
|
||||
nota = (f" (tier taker {tier:.2f} → {d:+.2f})"
|
||||
+ (" ⚠️ DIVERGE" if abs(d) > SOGLIA_DIVERGENZA_BPS else ""))
|
||||
print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}")
|
||||
else:
|
||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||
@@ -257,9 +275,31 @@ def main() -> int:
|
||||
})
|
||||
if not inviato and (not quiet or interessante):
|
||||
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
|
||||
# issue #37 (B9): fee realmente pagata che diverge dal tier pubblico atteso -> notify UNA
|
||||
# volta per episodio. Marcatore "divergenza_detta" nello stesso state.json (stessa regola di
|
||||
# B8: si scrive solo se l'invio e' riuscito), azzerato quando la divergenza torna sotto
|
||||
# soglia. E' un evento indipendente dal cambio di tier: se qui serve un invio e quello di B8
|
||||
# e' appena fallito, save_state sotto non gira (stessa gate di B8) e il marcatore non si
|
||||
# scrive — al giro dopo si ridice, ridondante ma mai persa, la stessa scelta di B8.
|
||||
divergenze = r.get("divergenze") or []
|
||||
gia_detta = bool(r.get("divergenza_gia_detta"))
|
||||
marcatore = gia_detta
|
||||
if divergenze and not gia_detta:
|
||||
inviata_div = notify("💸 FEE WATCH — fee realizzata diverge dal tier pubblico", {
|
||||
f"divergenza {i+1}": d for i, d in enumerate(divergenze)
|
||||
})
|
||||
if inviata_div:
|
||||
marcatore = True
|
||||
elif not quiet or interessante:
|
||||
print(" ⚠️ invio divergenza FALLITO — marcatore non scritto: il prossimo giro ritenta")
|
||||
elif not divergenze:
|
||||
marcatore = False
|
||||
|
||||
stato_da_salvare = dict(r["current"])
|
||||
stato_da_salvare["divergenza_detta"] = marcatore
|
||||
if not serve_invio or inviato:
|
||||
save_state(r["current"])
|
||||
registra_giro(RUNS, level=r["level"])
|
||||
save_state(stato_da_salvare)
|
||||
registra_giro(RUNS, level=r["level"], realized=r.get("realized"))
|
||||
return 2 if r["level"] == "AZIONE" else 0
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
|
||||
def sync(verbose: bool = True) -> dict:
|
||||
@@ -31,10 +32,35 @@ def sync(verbose: bool = True) -> dict:
|
||||
T.set_meta(con, "ultimo_sync", T.ora())
|
||||
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
||||
tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"]
|
||||
|
||||
# issue #37 (C11). Dal 2026-08-24T01:07Z (T.CUTOFF_TS_VERO) book_executions.jsonl porta l'ora
|
||||
# VERA per-fill (docstring di tradesdb.py), ma `sync()` popola il DB SOLO da cron_book.log —
|
||||
# l'arricchimento dal jsonl e' TradingBook#11, non qui. Un conteggio diverso in quella
|
||||
# finestra segnala che una delle due fonti sta perdendo fill. Notify UNA volta per episodio:
|
||||
# marcatore in `meta` (stessa regola di fee_watch #37 B9 — scritto solo se l'invio riesce,
|
||||
# azzerato quando i conteggi tornano uguali).
|
||||
righe_jsonl = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||
n_jsonl_dal = sum(1 for r in righe_jsonl if str(r.get("ts_utc", "")) >= T.CUTOFF_TS_VERO)
|
||||
n_db_dal = con.execute("SELECT COUNT(*) c FROM fills WHERE ts_utc >= ?",
|
||||
(T.CUTOFF_TS_VERO,)).fetchone()["c"]
|
||||
diverge = n_jsonl_dal != n_db_dal
|
||||
gia_detto = T.get_meta(con, "conteggio_diverso_detto") == "1"
|
||||
if diverge:
|
||||
msg = f"⚠️ trades.db: {n_jsonl_dal} fill nel jsonl, {n_db_dal} nel DB dal 24/08"
|
||||
if verbose:
|
||||
print(f" {msg}")
|
||||
if not gia_detto:
|
||||
if notify("⚠️ trades.db — conteggio fill diverso dal jsonl dal 24/08",
|
||||
{"fill nel jsonl": n_jsonl_dal, "fill nel DB": n_db_dal}):
|
||||
T.set_meta(con, "conteggio_diverso_detto", "1")
|
||||
elif gia_detto:
|
||||
T.set_meta(con, "conteggio_diverso_detto", "0")
|
||||
|
||||
if verbose:
|
||||
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
|
||||
con.close()
|
||||
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot)
|
||||
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot,
|
||||
jsonl_dal_24_08=n_jsonl_dal, db_dal_24_08=n_db_dal)
|
||||
|
||||
|
||||
def reconcile() -> None:
|
||||
@@ -42,8 +68,8 @@ def reconcile() -> None:
|
||||
fills = [f for r in runs for f in r.fills]
|
||||
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||
rec = T.reconcile(fills, righe)
|
||||
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)")
|
||||
print(f" book_executions : {len(righe)} righe (senza ora)")
|
||||
print(f"\n cron_book.log : {len(fills)} fill (ora di inizio giro)")
|
||||
print(f" book_executions : {len(righe)} righe (ora VERA per-fill dal 24/08, #37 C11)")
|
||||
print(f" concordano : {len(rec['ok'])}")
|
||||
print(f" solo nel log : {len(rec['solo_log'])}")
|
||||
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
|
||||
|
||||
+33
-5
@@ -39,8 +39,16 @@ FINESTRA_GIORNALE_G = 7 # pagine del giornale lette
|
||||
FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
|
||||
FINESTRA_COMMIT_G = 14 # commit letti
|
||||
CARTELLA = Path("docs") / "revisioni"
|
||||
MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo
|
||||
MAX_CHARS_VOCE = 90_000 # il resto delle voci e' molto piu' piccolo
|
||||
# #37 C12. CLAUDE.md e' arrivato a 89.590 caratteri (99,5% di MAX_CHARS_VOCE, 27/09): al primo
|
||||
# paragrafo aggiunto si sarebbe troncato. Tetto SUO, piu' largo — il vincolo vero resta
|
||||
# MAX_CHARS_TOTALE, applicato A PARTE in `materiale()` a prescindere dal tetto di ogni voce: la
|
||||
# somma dei tetti per-voce (che supera 400k) non e' il limite, lo e' il secondo giro di `_tronca`.
|
||||
MAX_CHARS_CLAUDE_MD = 150_000
|
||||
MAX_CHARS_TOTALE = 400_000
|
||||
SOGLIA_AVVISO_TETTO = 0.95 # avviso quando una voce supera questa frazione del SUO tetto
|
||||
_MARCA_VICINO_TETTO = "[… vicino al tetto:" # marcatore condiviso: materiale() lo scrive, i
|
||||
# consumatori (prompt, rapporto) lo rileggono
|
||||
MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito
|
||||
LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata
|
||||
TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre
|
||||
@@ -89,6 +97,12 @@ def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
|
||||
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
|
||||
|
||||
|
||||
def _cap_voce(nome: str) -> int:
|
||||
"""Tetto PER-VOCE: CLAUDE.md ne ha uno suo, piu' largo (#37 C12). Il totale
|
||||
(MAX_CHARS_TOTALE) resta un vincolo A PARTE, applicato in `materiale()` a valle di questo."""
|
||||
return MAX_CHARS_CLAUDE_MD if nome.startswith("CLAUDE.md") else MAX_CHARS_VOCE
|
||||
|
||||
|
||||
def _cmd(args: list[str], cwd: Path) -> str:
|
||||
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
|
||||
non e' «va tutto bene»)."""
|
||||
@@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
|
||||
|
||||
out, tot = [], 0
|
||||
for nome, testo in voci:
|
||||
t, tr = _tronca(testo)
|
||||
cap = _cap_voce(nome)
|
||||
# #37 C12: avviso PRIMA di troncare, quando la voce e' vicina al SUO tetto (non ancora al
|
||||
# tetto totale, sotto): la nota entra nel testo, cosi' arriva sia al prompt del revisore
|
||||
# sia — via _MARCA_VICINO_TETTO — al rapporto, come [TRONCATO] oggi. Il margine
|
||||
# (`+ len(nota) <= cap`) e' voluto: la nota stessa non deve MAI far scattare il
|
||||
# troncamento che sta segnalando in anticipo.
|
||||
if len(testo) >= SOGLIA_AVVISO_TETTO * cap:
|
||||
nota = (f"\n{_MARCA_VICINO_TETTO} {len(testo):,} di {cap:,} caratteri "
|
||||
f"({100 * len(testo) / cap:.0f}%)]")
|
||||
if len(testo) + len(nota) <= cap:
|
||||
testo = testo + nota
|
||||
t, tr = _tronca(testo, cap)
|
||||
if tot + len(t) > MAX_CHARS_TOTALE:
|
||||
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
|
||||
tot += len(t)
|
||||
@@ -186,7 +211,8 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
|
||||
def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str:
|
||||
parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"]
|
||||
for nome, testo, tr in voci:
|
||||
parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n")
|
||||
tag = " [TRONCATO]" if tr else (" [VICINO AL TETTO]" if _MARCA_VICINO_TETTO in testo else "")
|
||||
parti.append(f"\n=== {nome}{tag} ===\n{testo}\n")
|
||||
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
|
||||
return "\n".join(parti)
|
||||
|
||||
@@ -236,7 +262,8 @@ def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str
|
||||
"- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
|
||||
"scritto e' questo", "",
|
||||
"| voce | caratteri | troncata |", "|---|---|---|"]
|
||||
coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci]
|
||||
coda += [f"| {n} | {len(t):,} | "
|
||||
f"{'si' if tr else ('quasi' if _MARCA_VICINO_TETTO in t else '')} |" for n, t, tr in voci]
|
||||
return "\n".join(testa + corpo + coda) + "\n"
|
||||
|
||||
|
||||
@@ -316,7 +343,8 @@ def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *
|
||||
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
|
||||
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
|
||||
for n, t, tr in voci:
|
||||
print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}")
|
||||
tag = " [troncata]" if tr else (" [vicino al tetto]" if _MARCA_VICINO_TETTO in t else "")
|
||||
print(f" {len(t):>8,} {n}{tag}")
|
||||
if secco:
|
||||
return esito
|
||||
|
||||
|
||||
+23
-8
@@ -2,20 +2,31 @@
|
||||
|
||||
PERCHE' ESISTE
|
||||
--------------
|
||||
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della
|
||||
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill:
|
||||
19 righe su 19 a `00:00:00`. L'ora vera sta solo in `logs/cron_book.log` — che e' **gitignored,
|
||||
non nel backup e ruotabile**. Quindi oggi la cronologia reale del libro live vive in un file
|
||||
che una rotazione cancella, e nessuno se ne accorgerebbe.
|
||||
`logs/cron_book.log` e' **gitignored, non nel backup e ruotabile**, ed e' la fonte che `sync()`
|
||||
usa per popolare il DB: una rotazione lo cancellerebbe senza che nessuno se ne accorga.
|
||||
|
||||
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
|
||||
il perimetro gia' coperto dal backup rotativo della VPS.
|
||||
|
||||
⚠️ CORREZIONE (#37 C11, #35 A9). Fino al 2026-08-23 `data/live/book_executions.jsonl` scriveva in
|
||||
`ts_utc` la data della **barra di segnale** (`pd.Timestamp(r['last_data'])`), non l'ora del fill:
|
||||
19 righe su 19 a `00:00:00`. Dal 2026-08-24 01:07Z `book_execute.py` (`_registra_fill`) scrive
|
||||
l'ora VERA del fill (`datetime.now(timezone.utc)` al momento dell'ordine, non piu' la barra),
|
||||
UNA per fill — con il prezzo a piena precisione (non arrotondato come nella stampa del log) e il
|
||||
campo `pos_after` (oggi sempre `None` qui: la posizione post-rebalance si legge dopo, best-effort,
|
||||
e non viene riscritta nel jsonl — vedi il commento in `_registra_fill`). E' quindi, dal 24/08 in
|
||||
poi, la fonte con l'ora PIU' precisa per-fill — ma resta usata solo da `reconcile()`: arricchire
|
||||
`fills` con ts/prezzo/pos_after dal jsonl e' uno SVILUPPO non fatto qui (TradingBook#11).
|
||||
|
||||
FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola
|
||||
----------------------------------------------------------------------
|
||||
1. `logs/cron_book.log` -> ORA VERA del giro + contesto (equity, tp_frac, skh, target, posizione)
|
||||
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`.
|
||||
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora.
|
||||
1. `logs/cron_book.log` -> l'ora di INIZIO GIRO (una per run, condivisa da tutti i fill di quel
|
||||
run, non per-fill) + contesto (equity, tp_frac, skh, target,
|
||||
posizione) e la riga di fill
|
||||
`-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. FONTE usata da `sync()`.
|
||||
2. `data/live/book_executions.jsonl` -> gli stessi fill, con l'ora VERA per-fill dal 2026-08-24
|
||||
01:07Z (prima: la data della barra di segnale, 00:00:00), prezzo a
|
||||
piena precisione e `pos_after`. Entra SOLO in `reconcile()`.
|
||||
3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms,
|
||||
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
|
||||
|
||||
@@ -42,6 +53,10 @@ EXEC_JSONL = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
|
||||
|
||||
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
|
||||
ARMING = "2026-06-20"
|
||||
# Da qui in poi book_executions.jsonl porta l'ora VERA del fill in `ts_utc` (fix #35 A9, docstring
|
||||
# sopra): soglia per il controllo di conteggio jsonl-vs-DB in scripts/live/trades_db.py:sync()
|
||||
# (#37 C11). Prima di questo istante il campo e' la barra di segnale, non comparabile.
|
||||
CUTOFF_TS_VERO = "2026-08-24T01:07:00+00:00"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS fills (
|
||||
|
||||
+97
-1
@@ -204,7 +204,10 @@ def test_invio_riuscito_salva_lo_stato(monkeypatch):
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"]
|
||||
# (#37 B9) lo stato salvato porta anche il marcatore "divergenza_detta" (qui False: la
|
||||
# fixture non ha divergenze), sibling delle fee per-strumento, non piu' solo `current`.
|
||||
assert len(salvato) == 1
|
||||
assert salvato[0] == {**_RIS_AZIONE["current"], "divergenza_detta": False}
|
||||
|
||||
|
||||
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
|
||||
@@ -213,3 +216,96 @@ def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
|
||||
FW.main()
|
||||
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
|
||||
assert len(salvato) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# issue #37 (B9) — realized_fee_bps() SEMPRE calcolato (non solo nel blocco di stampa) e
|
||||
# divergenza dal tier pubblico notificata UNA volta per episodio.
|
||||
# ===========================================================================
|
||||
def test_run_calcola_sempre_la_fee_realizzata(monkeypatch):
|
||||
"""Prima del #37 la fee realizzata girava solo dentro `if not quiet or interessante`: in
|
||||
cron (--quiet) non girava mai. Ora e' `run()` a calcolarla, sempre."""
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
ins = FW.INSTRUMENTS[0]
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 7.5})
|
||||
r = FW.run()
|
||||
assert r["realized"] == {ins: 7.5}
|
||||
assert len(r["divergenze"]) == 1 and ins in r["divergenze"][0]
|
||||
assert r["divergenza_gia_detta"] is False
|
||||
|
||||
|
||||
def test_run_nessun_trade_e_None_non_zero(monkeypatch):
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {})
|
||||
r = FW.run()
|
||||
assert r["realized"] is None
|
||||
assert r["divergenze"] == []
|
||||
|
||||
|
||||
def test_run_sotto_soglia_non_e_divergenza(monkeypatch):
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
ins = FW.INSTRUMENTS[0]
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 5.3}) # +0.3 < 1.0
|
||||
r = FW.run()
|
||||
assert r["divergenze"] == []
|
||||
|
||||
|
||||
_RIS_DIVERGE = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
|
||||
realized={"BTC_USDC-PERPETUAL": 7.5},
|
||||
divergenze=["BTC_USDC-PERPETUAL: realizzata 7.50 bps vs tier pubblico 5.00 (+2.50)"],
|
||||
divergenza_gia_detta=False,
|
||||
)
|
||||
_RIS_DIVERGE_RIPETUTA = dict(_RIS_DIVERGE, divergenza_gia_detta=True)
|
||||
_RIS_DIVERGE_RIENTRATA = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
|
||||
realized={"BTC_USDC-PERPETUAL": 5.3}, divergenze=[], divergenza_gia_detta=True,
|
||||
)
|
||||
|
||||
|
||||
def _prepara_b9(monkeypatch, risultato, esito_invio):
|
||||
"""Come `_prepara`, ma senza `changes`/livello AZIONE: isola il notify di B9 da quello di
|
||||
B8 (qui non richiesto, dato che tutte le fixture hanno level='OK' e changes=[])."""
|
||||
salvato, inviato = [], []
|
||||
monkeypatch.setattr(FW, "run", lambda: risultato)
|
||||
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
|
||||
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
|
||||
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
|
||||
return salvato, inviato
|
||||
|
||||
|
||||
def test_divergenza_prima_volta_notifica_e_marca(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=True)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert salvato[-1]["divergenza_detta"] is True
|
||||
|
||||
|
||||
def test_divergenza_stesso_episodio_non_ripete(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIPETUTA, esito_invio=True)
|
||||
FW.main()
|
||||
assert inviato == [], "gia' detta in questo episodio: nessun secondo invio"
|
||||
assert salvato[-1]["divergenza_detta"] is True
|
||||
|
||||
|
||||
def test_divergenza_notify_fallita_non_marca(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=False)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert salvato[-1]["divergenza_detta"] is False, \
|
||||
"invio fallito -> marcatore non scritto, il giro dopo ritenta"
|
||||
|
||||
|
||||
def test_divergenza_rientrata_azzera_il_marcatore(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIENTRATA, esito_invio=False)
|
||||
FW.main()
|
||||
assert inviato == [], "sotto soglia: nessun invio da tentare"
|
||||
assert salvato[-1]["divergenza_detta"] is False
|
||||
|
||||
+52
-1
@@ -68,14 +68,65 @@ def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path):
|
||||
|
||||
|
||||
def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch):
|
||||
"""CLAUDE.md e' la prima voce e ha un tetto SUO (MAX_CHARS_CLAUDE_MD, #37 C12): si monkeypatcha
|
||||
quello, non il MAX_CHARS_VOCE generico, che non lo tocca piu'."""
|
||||
r = _radice(tmp_path)
|
||||
(r / "CLAUDE.md").write_text("x" * 1000)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 100)
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert tr and "[… troncato a 100 caratteri su 1,000]" in t
|
||||
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
|
||||
|
||||
|
||||
def test_claude_md_ha_un_tetto_suo_distinto_dal_generico(tmp_path, monkeypatch):
|
||||
"""#37 C12: CLAUDE.md sfiora MAX_CHARS_VOCE con un margine minimo — deve avere un tetto suo
|
||||
piu' largo, cosi' un piccolo aggiornamento non lo tronca."""
|
||||
r = _radice(tmp_path)
|
||||
n_chars = R.MAX_CHARS_VOCE + 1_000 # sopra il generico, ma sotto MAX_CHARS_CLAUDE_MD
|
||||
assert n_chars < R.MAX_CHARS_CLAUDE_MD
|
||||
(r / "CLAUDE.md").write_text("x" * n_chars)
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert n.startswith("CLAUDE.md")
|
||||
assert not tr, "sotto il tetto SUO (150k): non deve troncare, anche se supera i 90k generici"
|
||||
|
||||
|
||||
def test_voce_vicina_al_tetto_e_avvisata_nel_prompt_e_nel_rapporto(tmp_path, monkeypatch):
|
||||
"""#37 C12: al 95% del tetto (non ancora troncata) l'avviso entra nel testo, e da li' arriva
|
||||
sia al prompt del revisore sia alla tabella del rapporto — come [TRONCATO] oggi."""
|
||||
r = _radice(tmp_path)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 10_000)
|
||||
(r / "CLAUDE.md").write_text("x" * 9_600) # 96% di 10.000, margine largo per la nota
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert not tr, "non e' troncata: e' vicina"
|
||||
assert R._MARCA_VICINO_TETTO in t
|
||||
assert "[VICINO AL TETTO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
|
||||
rap = R.rapporto("revisione", None, "ok", [], "claude-fable-5-1", ADESSO, [(n, t, tr)])
|
||||
assert "| quasi |" in rap
|
||||
|
||||
|
||||
def test_voce_lontana_dal_tetto_non_e_avvisata(tmp_path):
|
||||
voci = R.materiale(_radice(tmp_path), ADESSO)
|
||||
claude_md = [t for n, t, tr in voci if n.startswith("CLAUDE.md")][0]
|
||||
assert R._MARCA_VICINO_TETTO not in claude_md
|
||||
|
||||
|
||||
def test_il_totale_resta_sotto_il_tetto_anche_se_la_somma_dei_tetti_lo_supera(tmp_path, monkeypatch):
|
||||
"""#37 C12: CLAUDE.md (150k) + il resto delle voci (90k l'una) puo' superare facilmente i 400k
|
||||
di MAX_CHARS_TOTALE in somma — il tetto totale e' applicato A PARTE, a valle, e resta il vero
|
||||
vincolo: non deve MAI sfondare, qualunque sia il tetto di ogni singola voce."""
|
||||
r = _radice(tmp_path)
|
||||
(r / "CLAUDE.md").write_text("x" * R.MAX_CHARS_CLAUDE_MD) # al SUO tetto: 150k
|
||||
j = r / "docs" / "journal"
|
||||
for i in range(1, 10): # sovrascrive i giornali della finestra
|
||||
g = (ADESSO - timedelta(days=i)).date().isoformat()
|
||||
(j / f"{g}.md").write_text("y" * R.MAX_CHARS_VOCE) # ognuno al SUO tetto: 90k
|
||||
voci = R.materiale(r, ADESSO)
|
||||
tot = sum(len(t) for _, t, _ in voci)
|
||||
somma_tetti = sum(R._cap_voce(n) for n, _, _ in voci)
|
||||
assert somma_tetti > R.MAX_CHARS_TOTALE, "il caso e' quello che sfonderebbe se il totale non fosse a parte"
|
||||
assert tot <= R.MAX_CHARS_TOTALE
|
||||
|
||||
|
||||
def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
|
||||
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
|
||||
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""scripts/live/trades_db.py:sync() — controllo di conteggio jsonl-vs-DB (#37 C11, #35 A9).
|
||||
|
||||
Dal 2026-08-24T01:07Z (`tradesdb.CUTOFF_TS_VERO`) `book_executions.jsonl` porta l'ora VERA del
|
||||
fill: un conteggio diverso da quello del DB in quella finestra segnala che una delle due fonti
|
||||
sta perdendo fill. `sync()` non ripara (come `reconcile()`): riporta e notifica UNA volta per
|
||||
episodio, con lo stesso marcatore-solo-su-invio-riuscito di fee_watch (#37 B9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
import trades_db as TD # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def redirige(monkeypatch, tmp_path):
|
||||
"""DB, cron_book.log e jsonl finti in tmp_path: non si tocca mai il libro vero."""
|
||||
reale = T.connect
|
||||
monkeypatch.setattr(T, "connect", lambda *a, **k: reale(tmp_path / "t.db"))
|
||||
monkeypatch.setattr(T, "CRON_LOG", tmp_path / "cron_book.log") # assente -> nessun fill dal log
|
||||
monkeypatch.setattr(T, "EXEC_JSONL", tmp_path / "book_executions.jsonl")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _semina_db(tmp_path, n, ts=None):
|
||||
ts = ts or T.CUTOFF_TS_VERO
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
fills = [T.Fill(ts_utc=ts, asset="BTC", side="buy", qty=0.001 * (i + 1), price=70000.0, fee=0.01)
|
||||
for i in range(n)]
|
||||
T.upsert_fills(con, fills)
|
||||
con.close()
|
||||
|
||||
|
||||
def _scrivi_jsonl(tmp_path, n, ts=None):
|
||||
ts = ts or T.CUTOFF_TS_VERO
|
||||
righe = [dict(ts_utc=ts, asset="BTC", side="buy", filled=0.001 * (i + 1), price=70000.0, fee=0.01)
|
||||
for i in range(n)]
|
||||
(tmp_path / "book_executions.jsonl").write_text(
|
||||
"\n".join(json.dumps(r) for r in righe) + ("\n" if righe else ""))
|
||||
|
||||
|
||||
def test_conteggi_uguali_non_notifica(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 2)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert inviato == []
|
||||
assert r["jsonl_dal_24_08"] == r["db_dal_24_08"] == 2
|
||||
|
||||
|
||||
def test_conteggi_diversi_notifica_una_volta_per_episodio(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert len(inviato) == 1, "conteggi diversi: un invio"
|
||||
assert r["jsonl_dal_24_08"] == 3 and r["db_dal_24_08"] == 2
|
||||
|
||||
# secondo giro, stessa divergenza -> nessun invio aggiuntivo (marcatore gia' scritto)
|
||||
r2 = TD.sync(verbose=False)
|
||||
assert len(inviato) == 1, "stesso episodio: nessun secondo invio"
|
||||
assert r2["jsonl_dal_24_08"] == 3 and r2["db_dal_24_08"] == 2
|
||||
|
||||
|
||||
def test_invio_fallito_non_marca_ritenta_al_giro_dopo(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or False)
|
||||
TD.sync(verbose=False)
|
||||
TD.sync(verbose=False)
|
||||
assert len(inviato) == 2, "invio fallito -> marcatore non scritto, si ritenta ogni giro"
|
||||
|
||||
|
||||
def test_conteggio_rientrato_azzera_il_marcatore(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: True)
|
||||
TD.sync(verbose=False) # divergenza detta
|
||||
|
||||
# il jsonl si allinea (es. arrivato il fill mancante) -> episodio chiuso
|
||||
_scrivi_jsonl(tmp_path, 2)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
TD.sync(verbose=False)
|
||||
assert inviato == [], "conteggi tornati uguali: nessun invio da fare"
|
||||
|
||||
# una NUOVA divergenza deve poter notificare di nuovo (il marcatore era stato azzerato)
|
||||
_scrivi_jsonl(tmp_path, 4)
|
||||
TD.sync(verbose=False)
|
||||
assert len(inviato) == 1
|
||||
|
||||
|
||||
def test_fill_prima_del_24_08_non_entra_nel_conteggio(monkeypatch, redirige):
|
||||
"""Prima di CUTOFF_TS_VERO il ts_utc del jsonl e' la barra di segnale, non l'ora vera: non
|
||||
e' comparabile col DB, e non deve contare come divergenza."""
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 0)
|
||||
_scrivi_jsonl(tmp_path, 2, ts="2026-08-10T00:00:00+00:00") # prima della soglia
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert inviato == []
|
||||
assert r["jsonl_dal_24_08"] == 0 and r["db_dal_24_08"] == 0
|
||||
Reference in New Issue
Block a user