merge lavoro/goldrake-u3 (revisione 27/09)
This commit is contained in:
@@ -245,12 +245,20 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||||
|
|
||||||
if r["changes"] or r["level"] == "AZIONE":
|
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
|
||||||
notify("💸 FEE WATCH — schema fee Deribit", {
|
# il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
|
||||||
|
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
|
||||||
|
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
|
||||||
|
inviato = True
|
||||||
|
if serve_invio:
|
||||||
|
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
|
||||||
"verdetto": f"[{r['level']}] {r['reason']}",
|
"verdetto": f"[{r['level']}] {r['reason']}",
|
||||||
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
||||||
})
|
})
|
||||||
save_state(r["current"])
|
if not inviato and (not quiet or interessante):
|
||||||
|
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
|
||||||
|
if not serve_invio or inviato:
|
||||||
|
save_state(r["current"])
|
||||||
registra_giro(RUNS, level=r["level"])
|
registra_giro(RUNS, level=r["level"])
|
||||||
return 2 if r["level"] == "AZIONE" else 0
|
return 2 if r["level"] == "AZIONE" else 0
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,59 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
|
|||||||
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
||||||
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
||||||
|
|
||||||
|
# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script
|
||||||
|
# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca).
|
||||||
|
LABEL_PREFIX = "usde-quota-"
|
||||||
|
|
||||||
|
|
||||||
|
def _cancella(c, order_id: str) -> tuple[bool, str]:
|
||||||
|
"""Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la
|
||||||
|
lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche'
|
||||||
|
il chiamante deve poter fermarsi con un messaggio invece di un traceback."""
|
||||||
|
try:
|
||||||
|
resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
||||||
|
except Exception as e: # noqa: BLE001 — si registra (P3)
|
||||||
|
return False, f"{type(e).__name__}: {e}"
|
||||||
|
if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error":
|
||||||
|
return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def cancella_residui(c) -> list[str]:
|
||||||
|
"""issue #26 — GTC mai cancellati. Il gateway cerbero-mcp NON accetta `time_in_force`
|
||||||
|
(PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit
|
||||||
|
riempito solo in parte resta APERTO per sempre e si accumula a ogni giro invece di sparire
|
||||||
|
con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label
|
||||||
|
(`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca.
|
||||||
|
|
||||||
|
-> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non
|
||||||
|
si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio)."""
|
||||||
|
# non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC (quotazione USDC o
|
||||||
|
# base USDE): si interrogano entrambe e si unisce per order_id, cosi' non si perde il residuo
|
||||||
|
# se il venue lo elenca sotto la base.
|
||||||
|
visti = {}
|
||||||
|
for valuta in ("USDC", "USDE"):
|
||||||
|
out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders",
|
||||||
|
{"currency": valuta, "kind": "spot", "type": "all"}))
|
||||||
|
ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||||
|
for o in ordini:
|
||||||
|
if isinstance(o, dict) and o.get("order_id") is not None:
|
||||||
|
visti[o["order_id"]] = o
|
||||||
|
ordini = list(visti.values())
|
||||||
|
cancellati = []
|
||||||
|
for o in ordini:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
instrument = o.get("instrument") or o.get("instrument_name")
|
||||||
|
label = o.get("label") or ""
|
||||||
|
if instrument == U.SPOT and label.startswith(LABEL_PREFIX):
|
||||||
|
oid = o.get("order_id")
|
||||||
|
ok, motivo = _cancella(c, oid)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}")
|
||||||
|
cancellati.append(oid)
|
||||||
|
return cancellati
|
||||||
|
|
||||||
|
|
||||||
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
|
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
|
||||||
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
||||||
@@ -232,6 +285,9 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
residui = cancella_residui(c)
|
||||||
|
print(f" residui cancellati all'avvio: {len(residui)}"
|
||||||
|
+ (f" ({', '.join(residui)})" if residui else ""))
|
||||||
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
||||||
@@ -274,6 +330,18 @@ def main() -> int:
|
|||||||
"amount": amt, "filled": filled})
|
"amount": amt, "filled": filled})
|
||||||
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
||||||
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
||||||
|
# issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non
|
||||||
|
# accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si
|
||||||
|
# FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo.
|
||||||
|
if filled < amt:
|
||||||
|
oid = order.get("order_id")
|
||||||
|
ok, motivo = _cancella(c, oid)
|
||||||
|
fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}"
|
||||||
|
if not ok:
|
||||||
|
print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}"
|
||||||
|
" — mi fermo (mai un secondo ordine con un residuo vivo)")
|
||||||
|
break
|
||||||
|
print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})")
|
||||||
if filled <= 0:
|
if filled <= 0:
|
||||||
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
||||||
time.sleep(a.pausa)
|
time.sleep(a.pausa)
|
||||||
|
|||||||
+51
-50
@@ -58,12 +58,6 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
|
|||||||
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
||||||
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
||||||
|
|
||||||
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
|
|
||||||
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
|
|
||||||
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
|
|
||||||
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
|
|
||||||
# ogni giorno finche' la riga non viene tolta.
|
|
||||||
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
|
|
||||||
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
||||||
|
|
||||||
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
|
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
|
||||||
@@ -162,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
|
|||||||
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
||||||
|
|
||||||
|
|
||||||
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
|
|
||||||
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
|
|
||||||
if verdetto_corrente != "IDONEO":
|
|
||||||
return False
|
|
||||||
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
|
|
||||||
|
|
||||||
|
|
||||||
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||||
"""PURA. Condizioni di allerta attive su un record."""
|
"""PURA. Condizioni di allerta attive su un record."""
|
||||||
if not rec:
|
if not rec:
|
||||||
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
|
|||||||
return att
|
return att
|
||||||
|
|
||||||
|
|
||||||
|
def nuove_condizioni(att_now: set[str], att_prev: set[str], prev: dict | None) -> set[str]:
|
||||||
|
"""PURA (issue #29 B2). Le condizioni da notificare in questo giro.
|
||||||
|
|
||||||
|
Fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si leggeva: un
|
||||||
|
invio fallito restava "gia' detto" per l'episodio intero (l'ora dopo att_prev diceva ancora
|
||||||
|
che la condizione c'era, quindi non era piu' "nuova"). Schema di cuscino_watch.py: una
|
||||||
|
condizione conta come "gia' detta" solo se era attiva l'ultima volta E il suo invio e'
|
||||||
|
RIUSCITO — cioe' compare in `allertati` del record precedente. Un record vecchio senza
|
||||||
|
`allertati` (scritto prima di questo fix) vale come "tutto allertato": nessun rispam
|
||||||
|
retroattivo delle condizioni gia' viste."""
|
||||||
|
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||||
|
return att_now - (att_prev & confermate_prev)
|
||||||
|
|
||||||
|
|
||||||
def _safe_client():
|
def _safe_client():
|
||||||
try:
|
try:
|
||||||
from src.live.deribit import DeribitRead
|
from src.live.deribit import DeribitRead
|
||||||
@@ -284,54 +285,58 @@ def main() -> int:
|
|||||||
v, v_motivo = verdetto(records + [rec], now)
|
v, v_motivo = verdetto(records + [rec], now)
|
||||||
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
||||||
|
|
||||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
|
||||||
with STATE.open("a") as fh:
|
# issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
|
||||||
fh.write(json.dumps(rec) + "\n")
|
# non si leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema di
|
||||||
|
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
|
||||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
|
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
|
||||||
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
||||||
nuove = att_now - att_prev
|
nuove = nuove_condizioni(att_now, att_prev, prev)
|
||||||
|
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||||
inviati = []
|
inviati = []
|
||||||
|
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
|
||||||
if "DEPEG_CRIT" in att_now:
|
if "DEPEG_CRIT" in att_now:
|
||||||
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||||
inviati.append("DEPEG_CRIT")
|
inviati.append("DEPEG_CRIT")
|
||||||
|
if ok:
|
||||||
|
allertati.add("DEPEG_CRIT")
|
||||||
if "DEPEG_WARN" in nuove:
|
if "DEPEG_WARN" in nuove:
|
||||||
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||||
"nota": "entra gia' nel sizing orario del book"})
|
"nota": "entra gia' nel sizing orario del book"})
|
||||||
inviati.append("DEPEG_WARN")
|
inviati.append("DEPEG_WARN")
|
||||||
|
if ok:
|
||||||
|
allertati.add("DEPEG_WARN")
|
||||||
if "QUOTA_OVER" in nuove:
|
if "QUOTA_OVER" in nuove:
|
||||||
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||||
inviati.append("QUOTA_OVER")
|
inviati.append("QUOTA_OVER")
|
||||||
|
if ok:
|
||||||
|
allertati.add("QUOTA_OVER")
|
||||||
if "BLIND" in nuove:
|
if "BLIND" in nuove:
|
||||||
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||||
inviati.append("BLIND")
|
inviati.append("BLIND")
|
||||||
|
if ok:
|
||||||
|
allertati.add("BLIND")
|
||||||
|
rec["allertati"] = sorted(allertati)
|
||||||
|
|
||||||
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
||||||
if v != v_prev:
|
if v != v_prev:
|
||||||
if v == "IDONEO":
|
if v == "IDONEO":
|
||||||
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
||||||
"prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"})
|
"prossimo passo": f"decisione di quota dell'operatore (tetto allerta {c['quota_max_frac']:.0%})"})
|
||||||
elif v == "NON_IDONEO":
|
elif v == "NON_IDONEO":
|
||||||
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
||||||
inviati.append(f"VERDETTO->{v}")
|
inviati.append(f"VERDETTO->{v}")
|
||||||
|
|
||||||
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
|
|
||||||
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
|
|
||||||
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
|
|
||||||
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
|
|
||||||
rend = rendimento(records + [rec])
|
rend = rendimento(records + [rec])
|
||||||
if quota_da_decidere(now, v):
|
|
||||||
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
with STATE.open("a") as fh:
|
||||||
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
fh.write(json.dumps(rec) + "\n")
|
||||||
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
|
|
||||||
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
|
|
||||||
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
|
|
||||||
inviati.append("QUOTA_DA_DECIDERE")
|
|
||||||
|
|
||||||
cambiato = bool(inviati) or v != v_prev
|
cambiato = bool(inviati) or v != v_prev
|
||||||
if not quiet or cambiato:
|
if not quiet or cambiato:
|
||||||
@@ -344,14 +349,12 @@ def main() -> int:
|
|||||||
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
||||||
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
||||||
if quota is not None:
|
if quota is not None:
|
||||||
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
|
# Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
|
||||||
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
|
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
|
||||||
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
|
# NON e' piu' il Segregated Standard Margin di prima di quella data.
|
||||||
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
|
|
||||||
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
||||||
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
||||||
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
|
print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
|
||||||
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
|
|
||||||
f" {c['haircut']:.0%})")
|
f" {c['haircut']:.0%})")
|
||||||
if an["delta"] is not None:
|
if an["delta"] is not None:
|
||||||
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
||||||
@@ -366,8 +369,6 @@ def main() -> int:
|
|||||||
f" (tot {rend['tot']:+.6f} USDE)")
|
f" (tot {rend['tot']:+.6f} USDE)")
|
||||||
if rend["finestre"] < 4:
|
if rend["finestre"] < 4:
|
||||||
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
||||||
if v == "IDONEO" and not quota_da_decidere(now, v):
|
|
||||||
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
|
|
||||||
if inviati:
|
if inviati:
|
||||||
print(f" allarmi : {', '.join(inviati)}")
|
print(f" allarmi : {', '.join(inviati)}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ def _titolo(rep: dict) -> str:
|
|||||||
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
|
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
|
||||||
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
|
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
|
||||||
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
|
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
|
||||||
|
if rep.get("severity") == "blind":
|
||||||
|
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
|
||||||
if rep.get("severity") == "warn":
|
if rep.get("severity") == "warn":
|
||||||
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||||
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
||||||
|
|||||||
@@ -346,6 +346,11 @@ def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
|
|||||||
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
||||||
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
||||||
report["severity"] = "alert"
|
report["severity"] = "alert"
|
||||||
|
# issue #31: senza ALERT, un asset BLIND lasciava severity=None — il titolo del runner
|
||||||
|
# cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per un episodio che e'
|
||||||
|
# "non vedo", non "stress misurato". BLIND e' lo stato MORBIDO: cede il passo ad ALERT.
|
||||||
|
elif report["levels"] and any(l == "BLIND" for l in report["levels"].values()):
|
||||||
|
report["severity"] = "blind"
|
||||||
|
|
||||||
# --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati
|
# --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati
|
||||||
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
|
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
|
||||||
|
|||||||
@@ -162,3 +162,54 @@ def test_un_fill_non_misurabile_e_None_non_zero():
|
|||||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
|
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
|
||||||
# fee nulla e' invece una misura legittima (ordine maker a rebate zero)
|
# fee nulla e' invece una misura legittima (ordine maker a rebate zero)
|
||||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
|
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito
|
||||||
|
# ===========================================================================
|
||||||
|
# Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario
|
||||||
|
# falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio
|
||||||
|
# sarebbe sparito senza essere mai stato detto.
|
||||||
|
|
||||||
|
_RIS_AZIONE = dict(
|
||||||
|
current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)},
|
||||||
|
changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0,
|
||||||
|
level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False,
|
||||||
|
)
|
||||||
|
_RIS_OK_SENZA_CAMBI = dict(
|
||||||
|
current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)},
|
||||||
|
changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara(monkeypatch, risultato, esito_invio):
|
||||||
|
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)
|
||||||
|
monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {})
|
||||||
|
return salvato, inviato
|
||||||
|
|
||||||
|
|
||||||
|
def test_invio_fallito_non_salva_lo_stato(monkeypatch):
|
||||||
|
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False)
|
||||||
|
rc = FW.main()
|
||||||
|
assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio"
|
||||||
|
assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio"
|
||||||
|
assert rc == 2
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
|
||||||
|
"""Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify."""
|
||||||
|
salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False)
|
||||||
|
FW.main()
|
||||||
|
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
|
||||||
|
assert len(salvato) == 1
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""usde_convert.py — GTC mai cancellati (issue #26).
|
||||||
|
|
||||||
|
Il gateway cerbero-mcp NON accetta `time_in_force` (PlaceOrderReq non ha il campo, extra
|
||||||
|
ignorati: gitea:Adriano/Cerbero-mcp#1): un limit riempito solo in parte resta APERTO per
|
||||||
|
sempre, e senza guardia si accumula a ogni giro dello script. Blindano: (a) all'avvio si
|
||||||
|
cancellano SOLO i residui con la nostra label (`usde-quota-*`), un ordine con altra label non
|
||||||
|
si tocca; (b) nel ciclo, un ordine con `filled < amt` viene cancellato subito dopo; (c) se la
|
||||||
|
cancellazione fallisce il ciclo si FERMA (mai un secondo ordine con un residuo vivo).
|
||||||
|
|
||||||
|
Client finto (nessuna rete), `time.sleep` patchato: nessun ordine vero puo' partire da questi
|
||||||
|
test.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
import usde_convert as C # noqa: E402
|
||||||
|
from src.live import usde as U # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# cancella_residui — SOLA lettura + cancellazione dei nostri ordini
|
||||||
|
# =============================================================================================
|
||||||
|
class _ClientOrdini:
|
||||||
|
"""Fake minimale per _post/_unwrap: nessuna rete."""
|
||||||
|
|
||||||
|
def __init__(self, open_orders=None, open_orders_usde=None, risposte_place=None,
|
||||||
|
boom_cancel=None):
|
||||||
|
self.open_orders = open_orders or []
|
||||||
|
# None = stessa lista di open_orders (retrocompat dei test esistenti, che non
|
||||||
|
# distinguono la valuta interrogata); una lista propria serve solo a chi vuole
|
||||||
|
# simulare un residuo elencato SOLO sotto USDE.
|
||||||
|
self.open_orders_usde = open_orders_usde
|
||||||
|
self.risposte_place = list(risposte_place or [])
|
||||||
|
self.boom_cancel = boom_cancel or set() # order_id la cui cancel fallisce
|
||||||
|
self.cancellati = []
|
||||||
|
self.get_open_orders_chiamate = 0
|
||||||
|
|
||||||
|
def _post(self, path, payload):
|
||||||
|
if path.endswith("get_open_orders"):
|
||||||
|
self.get_open_orders_chiamate += 1
|
||||||
|
if payload.get("currency") == "USDE" and self.open_orders_usde is not None:
|
||||||
|
return {"result": self.open_orders_usde}
|
||||||
|
return {"result": self.open_orders}
|
||||||
|
if path.endswith("cancel_order"):
|
||||||
|
oid = payload["order_id"]
|
||||||
|
self.cancellati.append(oid)
|
||||||
|
if oid in self.boom_cancel:
|
||||||
|
# forma reale di client.py::cancel_order su un fallimento del venue: il gateway
|
||||||
|
# avvolge il dict del tool in {"result": ...}, MAI in {"result": None, "error"}
|
||||||
|
return {"result": {"order_id": oid, "state": "error", "error": "cancel rifiutata dal venue"}}
|
||||||
|
return {"result": {"order_id": oid, "state": "cancelled"}}
|
||||||
|
if path.endswith("place_order"):
|
||||||
|
if not self.risposte_place:
|
||||||
|
raise AssertionError("place_order chiamato senza risposte in coda")
|
||||||
|
return self.risposte_place.pop(0)
|
||||||
|
raise AssertionError(f"chiamata inattesa: {path}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unwrap(resp):
|
||||||
|
return resp.get("result", resp) if isinstance(resp, dict) else resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancella_residui_solo_le_nostre_label():
|
||||||
|
client = _ClientOrdini(open_orders=[
|
||||||
|
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||||
|
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, # NON nostro
|
||||||
|
{"instrument": "BTC_USDC-PERPETUAL", "label": "usde-quota-0.64", "order_id": "R3"},
|
||||||
|
])
|
||||||
|
out = C.cancella_residui(client)
|
||||||
|
assert out == ["R1"]
|
||||||
|
assert client.cancellati == ["R1"], "l'ordine con altra label o su altro strumento non si tocca"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancella_residui_trovato_solo_sotto_USDE():
|
||||||
|
"""Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: se lo elenca
|
||||||
|
sotto la base (USDE) invece che sotto la quotazione (USDC), la sola lettura USDC non lo
|
||||||
|
vedrebbe mai e il residuo si accumulerebbe per sempre."""
|
||||||
|
client = _ClientOrdini(
|
||||||
|
open_orders=[],
|
||||||
|
open_orders_usde=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R9"}],
|
||||||
|
)
|
||||||
|
out = C.cancella_residui(client)
|
||||||
|
assert out == ["R9"]
|
||||||
|
assert client.cancellati == ["R9"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancella_residui_senza_residui_non_cancella_nulla():
|
||||||
|
client = _ClientOrdini(open_orders=[
|
||||||
|
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"},
|
||||||
|
])
|
||||||
|
assert C.cancella_residui(client) == []
|
||||||
|
assert client.cancellati == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancella_residui_lettura_fallita_solleva():
|
||||||
|
"""P5: non si ordina al buio. Se la LETTURA degli ordini aperti fallisce, la funzione
|
||||||
|
deve sollevare invece di procedere come se non ci fosse nulla da cancellare."""
|
||||||
|
class _ClientRotto:
|
||||||
|
def _post(self, path, payload):
|
||||||
|
raise RuntimeError("gateway giu'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unwrap(resp):
|
||||||
|
return resp
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
C.cancella_residui(_ClientRotto())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancella_residui_un_residuo_che_non_si_cancella_solleva():
|
||||||
|
"""Se non riusciamo a liberare un residuo NOSTRO, non si procede: si solleva (non si piazza
|
||||||
|
un nuovo ordine sopra un residuo che non siamo riusciti a togliere)."""
|
||||||
|
client = _ClientOrdini(open_orders=[
|
||||||
|
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||||
|
], boom_cancel={"R1"})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
C.cancella_residui(client)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# _cancella — l'helper di basso livello usato sia da cancella_residui sia dal ciclo di main()
|
||||||
|
# =============================================================================================
|
||||||
|
def test__cancella_ok():
|
||||||
|
client = _ClientOrdini()
|
||||||
|
ok, motivo = C._cancella(client, "X1")
|
||||||
|
assert ok and motivo == "" and client.cancellati == ["X1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test__cancella_errore_nel_payload_non_solleva():
|
||||||
|
client = _ClientOrdini(boom_cancel={"X1"})
|
||||||
|
ok, motivo = C._cancella(client, "X1")
|
||||||
|
assert not ok and "rifiutata" in motivo
|
||||||
|
|
||||||
|
|
||||||
|
def test__cancella_eccezione_di_rete_si_registra_non_si_propaga():
|
||||||
|
class _Rotto:
|
||||||
|
def _post(self, path, payload):
|
||||||
|
raise RuntimeError("timeout")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unwrap(resp):
|
||||||
|
return resp
|
||||||
|
|
||||||
|
ok, motivo = C._cancella(_Rotto(), "X1")
|
||||||
|
assert not ok and "timeout" in motivo
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# main() con --esegui: cancellazione dei residui PRIMA del ciclo, e dopo ogni ordine parziale
|
||||||
|
# =============================================================================================
|
||||||
|
def _piano_finto(**over):
|
||||||
|
base = dict(ok=True, side="buy", amount=10.0, px=1.0, px_ordine=1.0000, motivi=[],
|
||||||
|
quota_ora=0.20, quota_target=0.64, cap_usd=None, cap_frac=0.0,
|
||||||
|
val_usde=500.0, equity_tot=1500.0, delta_usd=10.0,
|
||||||
|
usdc_dopo=990.0, cuscino=300.0, cuscino_come="test")
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara_main(monkeypatch, client, tmp_path, argv_extra=()):
|
||||||
|
monkeypatch.setattr(C, "DeribitRead", lambda: client)
|
||||||
|
monkeypatch.setattr(C, "leggi_stato", lambda c: (1000.0, 500.0, 1.0, "test"))
|
||||||
|
monkeypatch.setattr(C, "book_top", lambda *a, **k: (0.9999, 1.0001))
|
||||||
|
monkeypatch.setattr(C, "piano", lambda *a, **k: _piano_finto())
|
||||||
|
monkeypatch.setattr(C.time, "sleep", lambda s: None)
|
||||||
|
monkeypatch.setattr(C, "LOG", tmp_path / "usde_convert.jsonl")
|
||||||
|
monkeypatch.setattr(C, "ROOT", tmp_path) # solo per il print finale (LOG.relative_to(ROOT))
|
||||||
|
monkeypatch.setattr(sys, "argv", ["usde_convert.py", "--quota", "0.64", "--esegui", *argv_extra])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordine_con_filled_zero_viene_cancellato_e_il_ciclo_si_ferma(monkeypatch, tmp_path, capsys):
|
||||||
|
client = _ClientOrdini(
|
||||||
|
open_orders=[],
|
||||||
|
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "open",
|
||||||
|
"filled_amount": 0}, "trades": []}}],
|
||||||
|
)
|
||||||
|
_prepara_main(monkeypatch, client, tmp_path)
|
||||||
|
rc = C.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert client.cancellati == ["O1"], "un ordine non fillato deve essere cancellato, non lasciato aperto"
|
||||||
|
assert "residuo cancellato" in out
|
||||||
|
assert rc == 1 # nessun fill -> esito negativo, comportamento invariato
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_che_fallisce_ferma_il_ciclo_e_non_manda_un_secondo_ordine(monkeypatch, tmp_path, capsys):
|
||||||
|
client = _ClientOrdini(
|
||||||
|
open_orders=[],
|
||||||
|
risposte_place=[
|
||||||
|
{"result": {"order": {"order_id": "BOOM", "order_state": "open",
|
||||||
|
"filled_amount": 0}, "trades": []}},
|
||||||
|
{"result": {"order": {"order_id": "O2", "order_state": "open",
|
||||||
|
"filled_amount": 10}, "trades": []}}, # non deve mai partire
|
||||||
|
],
|
||||||
|
boom_cancel={"BOOM"},
|
||||||
|
)
|
||||||
|
_prepara_main(monkeypatch, client, tmp_path)
|
||||||
|
rc = C.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert client.cancellati == ["BOOM"]
|
||||||
|
assert "FALLITA" in out and "mai un secondo ordine" in out
|
||||||
|
assert len(client.risposte_place) == 1, "il secondo ordine non deve essere stato piazzato"
|
||||||
|
assert rc == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordine_fillato_per_intero_non_si_cancella(monkeypatch, tmp_path, capsys):
|
||||||
|
"""filled == amt: nessun residuo, nessuna cancellazione (P3: non si spreca una chiamata su
|
||||||
|
un ordine che non ha lasciato nulla aperto)."""
|
||||||
|
client = _ClientOrdini(
|
||||||
|
open_orders=[],
|
||||||
|
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||||
|
"filled_amount": 10.0}, "trades": []}}],
|
||||||
|
)
|
||||||
|
_prepara_main(monkeypatch, client, tmp_path)
|
||||||
|
# piano() e' chiamato una volta PRIMA del ciclo (piano dry-run) e una volta per ogni giro:
|
||||||
|
# le prime due chiamate (piano pre-ciclo + 1° giro) sono valide, la terza (2° giro) dice
|
||||||
|
# 'gia' a bersaglio' per fermare il ciclo dopo il primo (e unico) ordine.
|
||||||
|
chiamate = {"n": 0}
|
||||||
|
|
||||||
|
def piano_poi_fine(*a, **k):
|
||||||
|
chiamate["n"] += 1
|
||||||
|
if chiamate["n"] <= 2:
|
||||||
|
return _piano_finto()
|
||||||
|
return _piano_finto(ok=False, motivi=["gia' a bersaglio"])
|
||||||
|
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||||
|
C.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert client.cancellati == []
|
||||||
|
assert "residuo cancellato" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_residui_all_avvio_vengono_cancellati_prima_del_ciclo(monkeypatch, tmp_path, capsys):
|
||||||
|
client = _ClientOrdini(
|
||||||
|
open_orders=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "VECCHIO"}],
|
||||||
|
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||||
|
"filled_amount": 10.0}, "trades": []}}],
|
||||||
|
)
|
||||||
|
_prepara_main(monkeypatch, client, tmp_path)
|
||||||
|
# piano() e' chiamato una volta PRIMA del ciclo e una volta per giro: le prime due chiamate
|
||||||
|
# sono valide (piano pre-ciclo + 1° giro, che piazza l'unico ordine), la terza ferma il ciclo.
|
||||||
|
chiamate = {"n": 0}
|
||||||
|
|
||||||
|
def piano_poi_fine(*a, **k):
|
||||||
|
chiamate["n"] += 1
|
||||||
|
return _piano_finto() if chiamate["n"] <= 2 else _piano_finto(ok=False, motivi=["fine"])
|
||||||
|
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||||
|
C.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "VECCHIO" in client.cancellati
|
||||||
|
assert "residui cancellati all'avvio: 1" in out
|
||||||
|
# e il residuo pre-esistente e' stato cancellato PRIMA che partisse il primo place_order
|
||||||
|
assert client.cancellati.index("VECCHIO") == 0
|
||||||
+44
-12
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
|
|||||||
assert r["apr"] is None and r["finestre"] == 0
|
assert r["apr"] is None and r["finestre"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
|
def test_quota_da_decidere_e_stata_rimossa():
|
||||||
from datetime import datetime, timezone
|
"""#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC
|
||||||
|
(usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni
|
||||||
def q(giorno, v):
|
giorno finche' non la si toglie a mano."""
|
||||||
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
|
assert not hasattr(W, "quota_da_decidere")
|
||||||
|
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
|
||||||
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
|
|
||||||
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
|
|
||||||
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
|
|
||||||
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
|
|
||||||
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
|
|
||||||
assert q("2026-08-31", "NON_IDONEO") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
|
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
|
||||||
@@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano():
|
|||||||
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
|
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
|
||||||
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
|
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
|
||||||
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
|
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch)
|
||||||
|
# =============================================================================================
|
||||||
|
|
||||||
|
def test_notify_fallito_la_condizione_si_ripresenta_come_nuova():
|
||||||
|
"""Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva ->
|
||||||
|
un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva
|
||||||
|
l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'."""
|
||||||
|
att = {"QUOTA_OVER"}
|
||||||
|
prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO
|
||||||
|
assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_riuscito_non_si_ripete():
|
||||||
|
att = {"QUOTA_OVER"}
|
||||||
|
prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta
|
||||||
|
assert W.nuove_condizioni(att, att, prev) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_vecchio_senza_allertati_vale_come_tutto_allertato():
|
||||||
|
"""Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non
|
||||||
|
deve produrre un rispam retroattivo delle condizioni gia' viste."""
|
||||||
|
att = {"QUOTA_OVER", "BLIND"}
|
||||||
|
prev = dict(stato="OK") # nessun campo `allertati`
|
||||||
|
assert W.nuove_condizioni(att, att, prev) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_senza_record_precedente_tutto_e_nuovo():
|
||||||
|
att = {"QUOTA_OVER", "DEPEG_WARN"}
|
||||||
|
assert W.nuove_condizioni(att, set(), None) == att
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_condizione_appena_comparsa_e_sempre_nuova():
|
||||||
|
"""Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per
|
||||||
|
definizione — l'insieme confermato non la riguarda."""
|
||||||
|
assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}
|
||||||
|
|||||||
@@ -499,3 +499,84 @@ def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
|
|||||||
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
||||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
||||||
assert "non tentato" in rep["invio"]
|
assert "non tentato" in rep["invio"]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default
|
||||||
|
# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non
|
||||||
|
# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo).
|
||||||
|
# =============================================================================================
|
||||||
|
|
||||||
|
def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path):
|
||||||
|
sp = tmp_path / "vw.json"
|
||||||
|
|
||||||
|
def fake():
|
||||||
|
return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False,
|
||||||
|
"assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None)
|
||||||
|
for a in VW.ASSETS},
|
||||||
|
"deribit_errors": []}
|
||||||
|
monkeypatch.setattr(VW, "observe", fake)
|
||||||
|
|
||||||
|
rep = None
|
||||||
|
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||||
|
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||||
|
assert any(l == "BLIND" for l in rep["levels"].values())
|
||||||
|
assert not any(l == "ALERT" for l in rep["levels"].values())
|
||||||
|
assert rep["severity"] == "blind"
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path):
|
||||||
|
"""Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9).
|
||||||
|
|
||||||
|
BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`);
|
||||||
|
ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS`
|
||||||
|
round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND."""
|
||||||
|
sp = tmp_path / "vw.json"
|
||||||
|
letture = {"n": 0}
|
||||||
|
soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak
|
||||||
|
|
||||||
|
def fake():
|
||||||
|
letture["n"] += 1
|
||||||
|
eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0
|
||||||
|
return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false",
|
||||||
|
"maintenance": False,
|
||||||
|
"assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None),
|
||||||
|
"ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)},
|
||||||
|
"deribit_errors": []}
|
||||||
|
monkeypatch.setattr(VW, "observe", fake)
|
||||||
|
|
||||||
|
rep = None
|
||||||
|
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||||
|
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||||
|
assert rep["levels"]["BTC"] == "BLIND"
|
||||||
|
assert rep["levels"]["ETH"] == "ALERT"
|
||||||
|
assert rep["severity"] == "alert"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica_script_venue_watch():
|
||||||
|
"""scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome
|
||||||
|
del modulo `src.live.venue_watch` gia' importato sopra come `VW`."""
|
||||||
|
import importlib.util
|
||||||
|
sp = importlib.util.spec_from_file_location(
|
||||||
|
"venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py")
|
||||||
|
m = importlib.util.module_from_spec(sp)
|
||||||
|
sp.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def test_titolo_blind_non_dice_prelievo():
|
||||||
|
SVW = _carica_script_venue_watch()
|
||||||
|
t = SVW._titolo({"severity": "blind"})
|
||||||
|
assert "prelievo" not in t.lower()
|
||||||
|
assert "CIECO" in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_titolo_alert_dice_ancora_prelievo():
|
||||||
|
SVW = _carica_script_venue_watch()
|
||||||
|
assert "prelievo" in SVW._titolo({"severity": "alert"}).lower()
|
||||||
|
assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta
|
||||||
|
|
||||||
|
|
||||||
|
def test_titolo_warn_resta_quello_di_prima():
|
||||||
|
SVW = _carica_script_venue_watch()
|
||||||
|
assert "Nessuna azione" in SVW._titolo({"severity": "warn"})
|
||||||
|
|||||||
Reference in New Issue
Block a user