cuscino_watch: riacquisto automatico di USDE con isteresi (ECCEDENTE), bande in config, escape HTML nel sink di notify
Decisione dell'operatore 10/09 («riportarla a quota in autonomia»). Verificato da revisione fable (10 segnalazioni, 9 applicate; loop numerico su usde_convert.piano: nessuna sequenza vendita→acquisto). - quarto stato ECCEDENTE: slack > cuscino_riacquisto_frac (0,40) x cuscino => acquisto di USDE fino allo stesso bersaglio della vendita, q* = 1 - 0,30 x (1 + cuscino_margine_frac 0,20) = 0,64. Isteresi [0 ; 0,40] x cuscino con bersaglio unico 0,20: per oscillare servono +-8,6% di equity USDC (~$380); un bonifico alza lo slack di 0,7 x importo e sopra ~9% dell'equity ricompra da solo. - le tre frazioni (preavviso/margine/riacquisto) in config/live.json sezione usde, lette da usde.bande_cuscino() che verifica l'ordine; quota_target 0,70 TOLTO (slack zero per costruzione). - notifier.notify: html.escape su titolo e valori — la prima allerta vera (13:53Z) era andata persa per un '<' nudo; allerta_errore registrato nel record (P3). - da ECCEDENTE un fallimento si ritenta ogni 24h (non 4 Telegram/giorno per sempre); bande fuori ordine => BLIND con motivo, non un traceback ingoiato dal cron. - CLAUDE.md §1/§4/§5.17, config _nota_usde/_nota_cuscino, diario. 0,64 e' una DERIVAZIONE dell'autore, non la quota decisa il 30/08 (0,70): dichiarato, da confermare. - test: 1070 (+8 netti). Fixes #7 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
This commit is contained in:
+113
-12
@@ -1,4 +1,4 @@
|
||||
"""cuscino_watch: il cuscino USDC di regolamento e' sorvegliato, e sotto zero si riconverte.
|
||||
"""cuscino_watch: il cuscino USDC e' sorvegliato; sotto zero si vende USDE, sopra la banda si ricompra.
|
||||
|
||||
Blindano: (a) il cuscino e' DERIVATO da config + book (P1), la stessa formula di usde_convert;
|
||||
(b) i tre stati e la soglia di preavviso; (c) la transizione non si consuma se l'invio fallisce
|
||||
@@ -55,18 +55,59 @@ def test_quota_ripristino_lascia_margine_sul_cuscino():
|
||||
assert abs((usdc_dopo - E * f) - 0.10 * E * f) < 1e-9
|
||||
|
||||
|
||||
# ------------------------------ i tre stati ------------------------------
|
||||
# ------------------------------ i quattro stati (issue #7) ------------------------------
|
||||
|
||||
def test_stati_ok_preavviso_scoperto():
|
||||
assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == "OK" # slack 300 > 120
|
||||
g = W.giudica(1300.0, 4000.0, 1200.0)
|
||||
B = dict(preavviso=0.10, margine=0.20, riacquisto=0.40)
|
||||
|
||||
|
||||
def test_stati_scoperto_preavviso_ok_eccedente():
|
||||
assert W.giudica(1199.0, 4000.0, 1200.0, B)["stato"] == "SCOPERTO"
|
||||
g = W.giudica(1300.0, 4000.0, 1200.0, B)
|
||||
assert g["stato"] == "PREAVVISO" and abs(g["slack"] - 100.0) < 1e-9 and g["preavviso_usd"] == 120.0
|
||||
assert W.giudica(1199.0, 4000.0, 1200.0)["stato"] == "SCOPERTO"
|
||||
assert W.giudica(1500.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 300: dentro [120, 480]
|
||||
assert W.giudica(1680.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 480: sul bordo, non compra
|
||||
g = W.giudica(1700.0, 4000.0, 1200.0, B)
|
||||
assert g["stato"] == "ECCEDENTE" and g["riacquisto_usd"] == 480.0
|
||||
|
||||
|
||||
def test_le_bande_vengono_dalla_config_e_sono_ordinate():
|
||||
b = U.bande_cuscino()
|
||||
assert 0 < b["preavviso"] < b["margine"] < b["riacquisto"]
|
||||
assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == W.giudica(1500.0, 4000.0, 1200.0, b)["stato"]
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
U.bande_cuscino({"cuscino_preavviso_frac": 0.3, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
|
||||
|
||||
|
||||
def test_isteresi_vendita_e_acquisto_convergono_allo_stesso_bersaglio():
|
||||
"""Dopo una vendita da SCOPERTO o un acquisto da ECCEDENTE lo slack e' margine x cuscino: OK,
|
||||
a distanza da entrambe le soglie. Un giro non ne innesca un altro."""
|
||||
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
|
||||
E = 4450.0; cusc = E * f
|
||||
q = U.quota_ripristino(b["margine"])
|
||||
usdc_dopo = E * (1 - q)
|
||||
g = W.giudica(usdc_dopo, E, cusc, b)
|
||||
assert g["stato"] == "OK"
|
||||
assert g["slack"] > g["preavviso_usd"] * 1.5 and g["slack"] < g["riacquisto_usd"] / 1.5
|
||||
|
||||
|
||||
def test_un_bonifico_fa_scattare_il_riacquisto_da_solo():
|
||||
"""Conto a bersaglio (slack = 0,20 x cuscino), bonifico D in USDC: slack +0,7 D. Con D = 12%
|
||||
dell'equity si supera 0,40 x cuscino e si ricompra; con D = 3% no (resta in USDC)."""
|
||||
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
|
||||
E = 4450.0; cusc = E * f; usdc = cusc * (1 + b["margine"])
|
||||
for D, atteso in ((0.03 * E, "OK"), (0.12 * E, "ECCEDENTE")):
|
||||
E2 = E + D; cusc2 = E2 * f
|
||||
assert W.giudica(usdc + D, E2, cusc2, b)["stato"] == atteso, D
|
||||
|
||||
|
||||
def test_non_vedo_e_blind_non_ok():
|
||||
assert W.giudica(None, 4000.0, 1200.0)["stato"] == "BLIND"
|
||||
assert W.giudica(1500.0, None, None)["stato"] == "BLIND"
|
||||
assert W.giudica(None, 4000.0, 1200.0, B)["stato"] == "BLIND"
|
||||
assert W.giudica(1500.0, None, None, B)["stato"] == "BLIND"
|
||||
|
||||
|
||||
def test_gli_stati_che_convertono_sono_due_e_hanno_un_verso():
|
||||
assert W.AZIONE == {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"}
|
||||
|
||||
|
||||
# ------------------------------ transizione e trasporto ------------------------------
|
||||
@@ -77,6 +118,7 @@ def test_ok_non_allerta_mai():
|
||||
|
||||
def test_transizione_alla_prima_e_al_cambio_di_stato():
|
||||
assert W.transizione(None, "PREAVVISO")
|
||||
assert W.transizione({"stato": "OK", "allertato": None}, "ECCEDENTE")
|
||||
assert W.transizione({"stato": "OK", "allertato": None}, "PREAVVISO")
|
||||
assert W.transizione({"stato": "PREAVVISO", "allertato": True}, "SCOPERTO")
|
||||
assert not W.transizione({"stato": "PREAVVISO", "allertato": True}, "PREAVVISO")
|
||||
@@ -146,12 +188,71 @@ def test_un_non_tentativo_non_consuma_il_budget():
|
||||
def test_dopo_il_ripristino_lo_stato_e_OK_non_preavviso():
|
||||
"""Il margine di ripristino deve superare la soglia di preavviso, o ogni 🚨 e' seguito da un ⚠️
|
||||
per costruzione (P14). Include il floor di 1 USDE e la tolleranza di quota di usde_convert."""
|
||||
assert W.MARGINE_RIPRISTINO > W.PREAVVISO_FRAC
|
||||
b = U.bande_cuscino()
|
||||
assert b["margine"] > b["preavviso"]
|
||||
f, _ = U.frazione_cuscino()
|
||||
E = 4450.0
|
||||
q = U.quota_ripristino(W.MARGINE_RIPRISTINO) + 0.005 # usde_convert si ferma entro TOLL
|
||||
usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno
|
||||
assert W.giudica(usdc_dopo, E, E * f)["stato"] == "OK"
|
||||
C = _carica("usde_convert")
|
||||
q = U.quota_ripristino(b["margine"]) + C.TOLL # usde_convert si ferma entro TOLL (importato, P1)
|
||||
usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno (passo del venue)
|
||||
assert W.giudica(usdc_dopo, E, E * f, b)["stato"] == "OK"
|
||||
|
||||
|
||||
def test_il_sink_telegram_fa_escape_di_minore_e_ampersand(monkeypatch):
|
||||
"""Telegram in parse_mode HTML rifiuta un '<' nudo: il primo giro vero (13:53Z del 10/09) ha
|
||||
perso l'allerta PREAVVISO per «(< 10% del cuscino)». L'escape sta nel SINK (`notify`), non nei
|
||||
45 chiamanti — qui si cattura il corpo della POST e si verifica che il '<' sia diventato <."""
|
||||
from src.live import notifier as N
|
||||
catturato = {}
|
||||
|
||||
def finto_urlopen(url, data, timeout=10):
|
||||
import urllib.parse
|
||||
catturato.update(urllib.parse.parse_qs(data.decode()))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
|
||||
monkeypatch.setattr(N.urllib.request, "urlopen", finto_urlopen)
|
||||
assert N.notify("⚠️ prova <x>", {"slack": "$+30 (< 10% del cuscino)", "a&b": "c"})
|
||||
testo = catturato["text"][0]
|
||||
assert "(< 10%" in testo and "<x>" in testo and "a&b" in testo
|
||||
assert testo.startswith("<b>") # il grassetto del titolo resta HTML vero
|
||||
|
||||
|
||||
def test_bande_fuori_ordine_danno_BLIND_con_motivo_non_un_crash():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
U.bande_cuscino({"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
|
||||
monkey = {"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4}
|
||||
orig = U.cfg
|
||||
try:
|
||||
U.cfg = lambda: {**orig(), **monkey}
|
||||
g = W.giudica(1500.0, 4000.0, 1200.0) # bande=None -> config rotta
|
||||
finally:
|
||||
U.cfg = orig
|
||||
assert g["stato"] == "BLIND" and "fuori ordine" in g["motivo"]
|
||||
|
||||
|
||||
def test_da_eccedente_un_fallimento_si_ritenta_una_volta_al_giorno():
|
||||
"""USDC fermo non e' un rischio: un guasto persistente (tetto del venue, book illeggibile) non
|
||||
deve diventare 4 Telegram al giorno per sempre. Da SCOPERTO il ritmo resta 6h."""
|
||||
now = 1_789_000_000_000
|
||||
fallito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
|
||||
ok, m = W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="ECCEDENTE")
|
||||
assert not ok and "una volta al giorno" in m
|
||||
assert W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="SCOPERTO")[0]
|
||||
riuscito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": True}}]
|
||||
assert W.puo_riconvertire(riuscito, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
|
||||
tardi = [{"ts": now - 25 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
|
||||
assert W.puo_riconvertire(tardi, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
|
||||
|
||||
|
||||
def test_i_motivi_delle_guardie_non_hanno_minore_nudo():
|
||||
"""Difesa in profondita' oltre al sink: i motivi finiscono nel campo `azione` delle allerte."""
|
||||
now = 1_789_000_000_000
|
||||
casi = [W.puo_riconvertire([], now, False, 1.0, 0.99), W.puo_riconvertire([], now, True, None, 0.99),
|
||||
W.puo_riconvertire([], now, True, 0.98, 0.99),
|
||||
W.puo_riconvertire([{"ts": now - 1000, "riconversione": {"tentata": True, "ok": True}}], now, True, 1.0, 0.99)]
|
||||
assert all("<" not in m for _, m in casi)
|
||||
|
||||
|
||||
def test_cron_installato_e_dichiarato_fuori_dal_tondo_e_dopo_il_book():
|
||||
|
||||
Reference in New Issue
Block a user