merge lavoro/goldrake-u10 (secondo giro 27/09)
This commit is contained in:
@@ -25,17 +25,63 @@ import pandas as pd
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.live.monitor_health import MONITORS
|
||||||
from src.live.notifier import send
|
from src.live.notifier import send
|
||||||
|
|
||||||
HORIZONS = (30, 90, 180)
|
HORIZONS = (30, 90, 180)
|
||||||
GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"),
|
|
||||||
("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")]
|
|
||||||
|
def _gate_date(nome_monitor: str, cablata: date) -> date:
|
||||||
|
"""(#35 C14) la data del gate viene da monitor_health.MONITORS (`MonitorSpec.gate`), la
|
||||||
|
STESSA tabella che "METTE A RISCHIO il gate" negli alert dei sorveglianti: una fonte, non
|
||||||
|
una seconda dichiarazione duplicata di CLAUDE.md §4. Il campo e' prosa libera
|
||||||
|
("YYYY-MM-DD — descrizione"): si legge la data solo se e' nella forma attesa. `cablata' e'
|
||||||
|
rete di sicurezza se il monitor sparisse o il campo cambiasse forma — non deve succedere per
|
||||||
|
i due gate qui sotto, oggi allineati (verificato a mano contro CLAUDE.md §4)."""
|
||||||
|
for m in MONITORS:
|
||||||
|
if m.name == nome_monitor and m.gate:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(m.gate[:10])
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
return cablata
|
||||||
|
|
||||||
|
|
||||||
|
GATES = [("STATARB-RESID", _gate_date("paper_statarb", date(2026, 9, 27)),
|
||||||
|
"data/paper_statarb/returns.jsonl", "net_modeled"),
|
||||||
|
("XSR01", _gate_date("paper_xsr", date(2026, 10, 23)),
|
||||||
|
"data/paper_xsr/returns.jsonl", "net_modeled")]
|
||||||
|
|
||||||
|
|
||||||
def _fmt_usd(x: float) -> str:
|
def _fmt_usd(x: float) -> str:
|
||||||
return f"${x:,.2f}"
|
return f"${x:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _soglia_accensione(segni: list[int], neg_refs: list[float]) -> float | None:
|
||||||
|
"""(#35 C3) Quale riferimento, superato, accende TP01 (mean(segni) > 0)?
|
||||||
|
|
||||||
|
I segni sono sempre in {-1,+1}, mai 0 (trend_state li costruisce con `1 if px>refs[h]
|
||||||
|
else -1`: nessun pareggio possibile). Con k segni negativi da far diventare positivi, la
|
||||||
|
somma cresce di 2 per flip: serve il piu' piccolo k tale che sum(segni)+2k > 0. La soglia
|
||||||
|
e' il k-esimo riferimento negativo piu' basso (k=1 -> il minimo, come prima del fix; k=2 ->
|
||||||
|
il secondo, ecc.) — non sempre il minimo: con TUTTI i riferimenti negativi, superare solo il
|
||||||
|
piu' basso lascia la media <= 0 e TP01 a 0 (bug segnalato in #35 C3).
|
||||||
|
|
||||||
|
None se la media e' gia' positiva (nessuna soglia da dare) o se k supera il numero di
|
||||||
|
riferimenti negativi disponibili (non dovrebbe accadere: con n segni, flippare tutti gli n
|
||||||
|
negativi porta comunque la somma a +n > 0, quindi k <= len(neg_refs) sempre — resta una
|
||||||
|
guardia difensiva, non un caso noto)."""
|
||||||
|
s = sum(segni)
|
||||||
|
if s > 0 or not neg_refs:
|
||||||
|
return None
|
||||||
|
k = 0
|
||||||
|
while s + 2 * k <= 0:
|
||||||
|
k += 1
|
||||||
|
if k > len(neg_refs):
|
||||||
|
return None
|
||||||
|
return sorted(neg_refs)[k - 1]
|
||||||
|
|
||||||
|
|
||||||
def trend_state() -> list[str]:
|
def trend_state() -> list[str]:
|
||||||
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
|
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
|
||||||
|
|
||||||
@@ -59,32 +105,51 @@ def trend_state() -> list[str]:
|
|||||||
tgt = max(0.0, m)
|
tgt = max(0.0, m)
|
||||||
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
||||||
f" → TP01 {tgt:.2f}"
|
f" → TP01 {tgt:.2f}"
|
||||||
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0]
|
if tgt == 0.0:
|
||||||
if neg and tgt == 0.0:
|
r = _soglia_accensione(list(sg.values()), [refs[h] for h in HORIZONS if sg[h] < 0])
|
||||||
h, r = min(neg, key=lambda t: t[1])
|
if r is not None:
|
||||||
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
||||||
out.append(line)
|
out.append(line)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def book_state() -> list[str]:
|
def book_state() -> tuple[list[str], bool | None]:
|
||||||
"""Conto reale, posizioni e target netto correnti. Sola lettura."""
|
"""Conto reale, posizioni e target netto correnti. Sola lettura.
|
||||||
|
|
||||||
|
(#35 C14) Ritorna anche se il libro e' DAVVERO flat (nessuna posizione, nessun target: stessa
|
||||||
|
condizione che questa funzione gia' usa riga per riga per stampare "flat" invece di "pos ->
|
||||||
|
target"): serve a `build()` per decidere se l'header "Perche' non opera" e' vero o
|
||||||
|
fuorviante, SENZA una lettura nuova — None se lo stato del libro non e' leggibile (in quel
|
||||||
|
caso non sappiamo dire se opera)."""
|
||||||
try:
|
try:
|
||||||
from src.live.book import book_report
|
from src.live.book import book_report
|
||||||
r = book_report()
|
r = book_report()
|
||||||
eq = r.get("equity")
|
eq = r.get("equity")
|
||||||
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
||||||
|
flat = True
|
||||||
for a in r.get("assets", []):
|
for a in r.get("assets", []):
|
||||||
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
|
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
|
||||||
stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
|
is_flat = abs(pos) < 1 and abs(net) < 1
|
||||||
|
flat = flat and is_flat
|
||||||
|
stato = "flat" if is_flat else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
|
||||||
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
|
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
|
||||||
if r.get("skh_error"):
|
if r.get("skh_error"):
|
||||||
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
||||||
return lines
|
return lines, flat
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"]
|
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"], None
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_giorni(giorni: int) -> str:
|
||||||
|
"""(#35 C14) tre casi distinti, non un segno "−" cablato davanti a un intero che puo' essere
|
||||||
|
negativo: con giorni=-1 quel cablaggio stampava "(−-1g)", un doppio segno illeggibile."""
|
||||||
|
if giorni > 0:
|
||||||
|
return f"-{giorni}g"
|
||||||
|
if giorni == 0:
|
||||||
|
return "oggi"
|
||||||
|
return f"scaduto da {-giorni}g"
|
||||||
|
|
||||||
|
|
||||||
def monitors() -> list[str]:
|
def monitors() -> list[str]:
|
||||||
@@ -92,20 +157,21 @@ def monitors() -> list[str]:
|
|||||||
for name, dec, path, key in GATES:
|
for name, dec, path, key in GATES:
|
||||||
p = ROOT / path
|
p = ROOT / path
|
||||||
giorni = (dec - date.today()).days
|
giorni = (dec - date.today()).days
|
||||||
|
gate_txt = f"gate {dec} ({_fmt_giorni(giorni)})"
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
out.append(f" {name}: in attesa della prima barra · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: in attesa della prima barra · {gate_txt}")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
||||||
if not rows:
|
if not rows:
|
||||||
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: 0 barre · {gate_txt}")
|
||||||
continue
|
continue
|
||||||
r = np.array([x[key] for x in rows], float)
|
r = np.array([x[key] for x in rows], float)
|
||||||
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
|
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
|
||||||
tot = float(np.prod(1 + r) - 1) * 100
|
tot = float(np.prod(1 + r) - 1) * 100
|
||||||
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · {gate_txt}")
|
||||||
except Exception:
|
except Exception:
|
||||||
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: log illeggibile · {gate_txt}")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +195,13 @@ def last_trade() -> str:
|
|||||||
def build() -> str:
|
def build() -> str:
|
||||||
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
||||||
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
||||||
L += book_state()
|
righe_conto, flat = book_state()
|
||||||
L += ["", "<b>Perche' non opera</b>"]
|
L += righe_conto
|
||||||
|
# (#35 C14) "Perche' non opera" e' vero solo se il libro e' DAVVERO flat (nessuna posizione,
|
||||||
|
# nessun target: gia' letto sopra da book_state, nessuna lettura nuova). Se non e' flat (ha
|
||||||
|
# posizioni o un target diverso da zero) o non si sa (book non leggibile), l'header fisso
|
||||||
|
# mentirebbe: si usa un titolo neutro.
|
||||||
|
L += ["", "<b>Perche' non opera</b>" if flat else "<b>Stato trend</b>"]
|
||||||
L += trend_state()
|
L += trend_state()
|
||||||
L += ["", "<b>Ordini</b>", last_trade()]
|
L += ["", "<b>Ordini</b>", last_trade()]
|
||||||
L += ["", "<b>Monitor in osservazione</b>"]
|
L += ["", "<b>Monitor in osservazione</b>"]
|
||||||
|
|||||||
+27
-7
@@ -206,9 +206,22 @@ def testata(voce: dict) -> str:
|
|||||||
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
||||||
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
||||||
eq = p.get("equity_fine")
|
eq = p.get("equity_fine")
|
||||||
|
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: "oggi" e' ieri -> "nel giorno". E se
|
||||||
|
# ci sono movimenti di capitale CERTI (mov_giorno; bool() come journal.scorporo: assente o
|
||||||
|
# 0 -> nessun movimento) la cifra da mostrare e' trading_giorno (al netto), non l'equity
|
||||||
|
# grezza: altrimenti un versamento si legge come P&L (successo il 25/08, +$1.399 letti come
|
||||||
|
# profitto). Se trading_giorno non e' calcolabile si resta su delta_equity, ma con l'etichetta
|
||||||
|
# nuova: non e' "oggi", e non e' comunque il trading al netto.
|
||||||
|
dq_t = p.get("trading_giorno")
|
||||||
|
if p.get("mov_giorno") and dq_t is not None:
|
||||||
|
nota_dq = f" ({dq_t:+.2f} nel giorno, di cui {p['mov_giorno']:+.2f} movimenti)"
|
||||||
|
elif dq is not None:
|
||||||
|
nota_dq = f" ({dq:+.2f} nel giorno)"
|
||||||
|
else:
|
||||||
|
nota_dq = ""
|
||||||
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
||||||
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
||||||
+ ("" if dq is None else f" ({dq:+.2f} oggi)")
|
+ nota_dq
|
||||||
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
|
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
|
||||||
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
||||||
f"{p['trading_da_arming']:+.2f})"
|
f"{p['trading_da_arming']:+.2f})"
|
||||||
@@ -228,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None
|
|||||||
"""
|
"""
|
||||||
testo = testata(voce)
|
testo = testata(voce)
|
||||||
if analisi.strip():
|
if analisi.strip():
|
||||||
corpo = _b(analisi.strip())
|
grezzo = analisi.strip()
|
||||||
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
|
|
||||||
if stato == "sospetta":
|
if stato == "sospetta":
|
||||||
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
|
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
|
||||||
testo += "\n\n" + corpo
|
# (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **-><b>: un
|
||||||
|
# taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un <b>
|
||||||
|
# aperto, e Telegram rifiuta l'INTERO messaggio ("can't parse entities") — il taglio si
|
||||||
|
# perde tutto, non si accorcia. Il marcatore si aggiunge dopo, fuori dall'escape (e'
|
||||||
|
# testo nostro, senza `&`/`<`: non ha bisogno di escape).
|
||||||
|
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
|
||||||
|
budget = LIMITE_TELEGRAM - len(testo) - len("\n\n") - len(taglio)
|
||||||
|
tagliata = len(grezzo) > budget
|
||||||
|
if tagliata:
|
||||||
|
grezzo = grezzo[:max(budget, 0)]
|
||||||
|
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", _b(grezzo)) # il grassetto del giornale
|
||||||
|
testo += "\n\n" + corpo + (taglio if tagliata else "")
|
||||||
else:
|
else:
|
||||||
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
||||||
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
|
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
|
||||||
if len(testo) > LIMITE_TELEGRAM:
|
|
||||||
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
|
|
||||||
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
|
|
||||||
return testo
|
return testo
|
||||||
|
|||||||
@@ -258,10 +258,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date
|
|||||||
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
|
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
|
||||||
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
|
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
|
||||||
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
|
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
|
||||||
corpo = html.escape(sintesi, quote=False)
|
|
||||||
tagliato = ""
|
tagliato = ""
|
||||||
if len(corpo) > LIMITE_TELEGRAM:
|
# (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
|
||||||
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
|
# un'entita' a meta' (es. "&am") e Telegram rifiuta l'intero messaggio ("can't parse
|
||||||
|
# entities") invece di accorciarlo. Il marcatore di taglio si aggiunge dopo, fuori dall'escape
|
||||||
|
# (e' testo nostro, senza `&`/`<`: non ha bisogno di escape).
|
||||||
|
if len(sintesi) > LIMITE_TELEGRAM:
|
||||||
|
sintesi, tagliato = sintesi[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
|
||||||
|
corpo = html.escape(sintesi, quote=False)
|
||||||
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
|
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
|
||||||
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
|
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
|
||||||
|
|
||||||
|
|||||||
@@ -285,6 +285,39 @@ def test_la_testata_porta_i_numeri_che_si_leggono_dalla_notifica():
|
|||||||
t = A.testata(_voce_piena())
|
t = A.testata(_voce_piena())
|
||||||
assert "$637.36" in t and "+2.36" in t and "+39.30" in t
|
assert "$637.36" in t and "+2.36" in t and "+39.30" in t
|
||||||
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
|
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
|
||||||
|
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: non e' "oggi", e' "nel giorno".
|
||||||
|
assert "nel giorno" in t and "oggi" not in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_testata_con_movimento_mostra_trading_giorno_non_il_grezzo():
|
||||||
|
"""(#35 C8) Un versamento di $1.399 non deve leggersi come P&L: con mov_giorno != 0 e
|
||||||
|
trading_giorno calcolabile, la cifra mostrata e' trading_giorno, non delta_equity."""
|
||||||
|
voce = _voce_piena()
|
||||||
|
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=2.36)
|
||||||
|
t = A.testata(voce)
|
||||||
|
assert "+2.36 nel giorno" in t
|
||||||
|
assert "di cui +1399.00 movimenti" in t
|
||||||
|
assert "+1401.36" not in t # il grezzo non compare piu' in testata
|
||||||
|
|
||||||
|
|
||||||
|
def test_testata_con_movimento_ma_trading_giorno_none_resta_sul_grezzo():
|
||||||
|
"""(#35 C8) Se trading_giorno non e' calcolabile (None) si resta su delta_equity, ma con
|
||||||
|
l'etichetta nuova, non "oggi"."""
|
||||||
|
voce = _voce_piena()
|
||||||
|
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=None)
|
||||||
|
t = A.testata(voce)
|
||||||
|
assert "+1401.36 nel giorno" in t
|
||||||
|
assert "di cui" not in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_testata_senza_movimento_mostra_il_grezzo_con_etichetta_nuova():
|
||||||
|
"""(#35 C8) mov_giorno assente o 0: nessun movimento da scorporare, stesso trattamento di
|
||||||
|
journal.scorporo (bool())."""
|
||||||
|
voce = _voce_piena()
|
||||||
|
voce["pnl"] = dict(voce["pnl"], mov_giorno=0.0, trading_giorno=2.36)
|
||||||
|
t = A.testata(voce)
|
||||||
|
assert "+2.36 nel giorno" in t
|
||||||
|
assert "di cui" not in t
|
||||||
|
|
||||||
|
|
||||||
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
|
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
|
||||||
@@ -312,6 +345,21 @@ def test_il_taglio_a_4096_si_DICHIARA():
|
|||||||
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
|
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
|
||||||
|
|
||||||
|
|
||||||
|
def test_taglio_a_cavallo_di_entita_e_tag_resta_HTML_valido():
|
||||||
|
"""(#35 C13) Il taglio e' sul GREZZO, prima di _b/html.escape e prima di **-><b>: un '&' o
|
||||||
|
un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata
|
||||||
|
(es. '&am') ne' un <b> senza chiusura -- Telegram rifiuta l'INTERO messaggio altrimenti
|
||||||
|
("can't parse entities"). Il pattern si ripete per tutta la lunghezza, cosi' il punto di
|
||||||
|
taglio (qualunque sia) cade sempre dentro o a cavallo di un '&' o di '**...**'."""
|
||||||
|
pattern = "parola & altra parola **in grassetto** ancora testo "
|
||||||
|
analisi = pattern * 200 # ben oltre qualunque budget qui
|
||||||
|
m = A.per_telegram(_voce_piena(), analisi, "ok", [])
|
||||||
|
assert "tagliato" in m
|
||||||
|
assert re.search(r"&(?!amp;|lt;|gt;)", m) is None # nessuna entita' spezzata o '&' nudo
|
||||||
|
assert m.count("<b>") == m.count("</b>") # ogni <b> aperto e' chiuso
|
||||||
|
assert m.count("<i>") == m.count("</i>")
|
||||||
|
|
||||||
|
|
||||||
def test_un_messaggio_normale_non_viene_toccato():
|
def test_un_messaggio_normale_non_viene_toccato():
|
||||||
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
|
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
|
||||||
assert "tagliato" not in m and m.endswith("Analisi breve.")
|
assert "tagliato" not in m and m.endswith("Analisi breve.")
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
|
|||||||
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
|
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
|
||||||
|
|
||||||
|
|
||||||
|
def test_taglio_a_cavallo_di_una_entita_resta_HTML_valido():
|
||||||
|
"""(#35 C13) Un '&' esattamente al taglio non deve spezzarsi in un'entita' a meta' (es.
|
||||||
|
'&am'): il taglio e' sul GREZZO, prima di html.escape, quindi il carattere e' incluso o
|
||||||
|
escluso per intero — mai spezzato a meta' entita'."""
|
||||||
|
sintesi = "a" * (R.LIMITE_TELEGRAM - 1) + "&resto che verra' tagliato via di sicuro"
|
||||||
|
tg = R.per_telegram(_revisione(sintesi=sintesi), None, "ok", ADESSO, Path("p.md"))
|
||||||
|
assert "[… sintesi tagliata" in tg
|
||||||
|
corpo = tg.split("\n\n", 1)[1].split("\n[…", 1)[0]
|
||||||
|
assert corpo == "a" * (R.LIMITE_TELEGRAM - 1) + "&" # il '&' e' incluso PER INTERO
|
||||||
|
assert "&am;" not in tg and "resto che verra'" not in tg # niente entita' a meta', niente coda
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
|
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
|
||||||
|
|
||||||
def _albero(root: Path) -> set[str]:
|
def _albero(root: Path) -> set[str]:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ e non invia niente in dry-run.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -21,8 +22,33 @@ telegram_daily = pytest.importorskip("telegram_daily")
|
|||||||
def test_build_non_solleva_e_ha_le_sezioni():
|
def test_build_non_solleva_e_ha_le_sezioni():
|
||||||
txt = telegram_daily.build()
|
txt = telegram_daily.build()
|
||||||
assert isinstance(txt, str) and len(txt) > 50
|
assert isinstance(txt, str) and len(txt) > 50
|
||||||
for sezione in ("Conto", "Perche' non opera", "Ordini", "Monitor in osservazione"):
|
for sezione in ("Conto", "Ordini", "Monitor in osservazione"):
|
||||||
assert sezione in txt, f"sezione mancante: {sezione}"
|
assert sezione in txt, f"sezione mancante: {sezione}"
|
||||||
|
# (#35 C14) l'header sul trend e' condizionale: esattamente uno dei due, non piu' fisso.
|
||||||
|
assert ("Perche' non opera" in txt) != ("Stato trend" in txt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_perche_non_opera_solo_se_il_libro_e_flat(monkeypatch):
|
||||||
|
"""(#35 C14) L'header fisso mentiva quando il libro NON e' fermo (posizioni o target != 0):
|
||||||
|
con flat=True resta "Perche' non opera"."""
|
||||||
|
monkeypatch.setattr(telegram_daily, "book_state", lambda: ([" equity $1.00"], True))
|
||||||
|
assert "Perche' non opera" in telegram_daily.build()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_neutro_se_il_libro_non_e_flat(monkeypatch):
|
||||||
|
monkeypatch.setattr(telegram_daily, "book_state",
|
||||||
|
lambda: ([" BTC: pos $300 → target $300"], False))
|
||||||
|
txt = telegram_daily.build()
|
||||||
|
assert "Stato trend" in txt and "Perche' non opera" not in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_neutro_se_il_libro_non_e_leggibile(monkeypatch):
|
||||||
|
"""Se book_state non sa dire se e' flat (eccezione -> None), niente header "non opera"
|
||||||
|
dichiarato senza saperlo davvero: si usa il neutro."""
|
||||||
|
monkeypatch.setattr(telegram_daily, "book_state",
|
||||||
|
lambda: ([" ⚠️ stato libro non leggibile: Errore"], None))
|
||||||
|
txt = telegram_daily.build()
|
||||||
|
assert "Stato trend" in txt and "Perche' non opera" not in txt
|
||||||
|
|
||||||
|
|
||||||
def test_nessuna_scrittura_ne_ordine():
|
def test_nessuna_scrittura_ne_ordine():
|
||||||
@@ -52,3 +78,77 @@ def test_convenzione_trend_e_documentata():
|
|||||||
"""La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su:
|
"""La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su:
|
||||||
se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
|
se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
|
||||||
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
|
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
|
||||||
|
|
||||||
|
|
||||||
|
def test_soglia_accensione_2_negativi_1_positivo():
|
||||||
|
"""2 negativi + 1 positivo: basta 1 flip -> soglia = il riferimento negativo PIU' BASSO."""
|
||||||
|
r = telegram_daily._soglia_accensione([-1, -1, 1], [100.0, 200.0])
|
||||||
|
assert r == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_soglia_accensione_3_negativi():
|
||||||
|
"""3 negativi: servono 2 flip -> soglia = il SECONDO riferimento negativo piu' basso, non il
|
||||||
|
minimo (il bug di #35 C3: superare solo il minimo lascia la media <= 0)."""
|
||||||
|
r = telegram_daily._soglia_accensione([-1, -1, -1], [100.0, 200.0, 300.0])
|
||||||
|
assert r == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_soglia_accensione_1_negativo_2_positivi_gia_acceso():
|
||||||
|
"""1 negativo + 2 positivi: la media e' gia' positiva (TP01 > 0) -> nessuna soglia da dare."""
|
||||||
|
assert telegram_daily._soglia_accensione([-1, 1, 1], [100.0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_soglia_accensione_nessun_negativo():
|
||||||
|
"""Tutti positivi: nessun riferimento negativo, nessuna soglia."""
|
||||||
|
assert telegram_daily._soglia_accensione([1, 1, 1], []) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_soglia_accensione_servono_tutti_i_flip():
|
||||||
|
"""2 negativi su 2 (nessun positivo): servono ENTRAMBI i flip -> k == len(neg_refs), soglia
|
||||||
|
= il piu' alto dei due (l'ultimo a bastare), non il minimo."""
|
||||||
|
r = telegram_daily._soglia_accensione([-1, -1], [200.0, 100.0])
|
||||||
|
assert r == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (#35 C14) gate: data, formato
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_date_legge_da_monitor_health():
|
||||||
|
"""La data non e' una seconda dichiarazione: viene da monitor_health.MONITORS (`gate`),
|
||||||
|
la stessa tabella che gli alert dei sorveglianti citano come "METTE A RISCHIO il gate"."""
|
||||||
|
from src.live.monitor_health import MONITORS
|
||||||
|
m = next(x for x in MONITORS if x.name == "paper_statarb")
|
||||||
|
assert m.gate[:10] == "2026-09-27"
|
||||||
|
assert telegram_daily._gate_date("paper_statarb", date(2030, 1, 1)) == date(2026, 9, 27)
|
||||||
|
m2 = next(x for x in MONITORS if x.name == "paper_xsr")
|
||||||
|
assert telegram_daily._gate_date("paper_xsr", date(2030, 1, 1)) == date.fromisoformat(m2.gate[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_date_usa_la_cablata_se_il_monitor_non_esiste():
|
||||||
|
"""Rete di sicurezza: se il nome non e' in MONITORS (o cambia), non si inventa una data."""
|
||||||
|
assert telegram_daily._gate_date("non_esiste_piu'", date(2030, 1, 1)) == date(2030, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_giorni_tre_casi_mai_il_doppio_segno():
|
||||||
|
"""(#35 C14) giorni>0 -> '-Ng'; 0 -> 'oggi'; <0 -> 'scaduto da Ng'. Mai '(−-1g)'."""
|
||||||
|
assert telegram_daily._fmt_giorni(5) == "-5g"
|
||||||
|
assert telegram_daily._fmt_giorni(0) == "oggi"
|
||||||
|
assert telegram_daily._fmt_giorni(-1) == "scaduto da 1g"
|
||||||
|
assert "-1g" not in telegram_daily._fmt_giorni(-1).replace("scaduto da 1g", "") # niente doppio segno
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitors_formatta_i_tre_casi_del_giorno(monkeypatch, tmp_path):
|
||||||
|
"""I rami di monitors() (barra assente qui: file inesistenti in tmp_path) usano tutti
|
||||||
|
_fmt_giorni, non il vecchio '(−{giorni}g)' cablato: futuro, oggi e passato si distinguono."""
|
||||||
|
from datetime import timedelta
|
||||||
|
oggi = date.today()
|
||||||
|
monkeypatch.setattr(telegram_daily, "ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(telegram_daily, "GATES",
|
||||||
|
[("FUTURO", oggi + timedelta(days=10), "data/nonexiste1/r.jsonl", "k"),
|
||||||
|
("OGGI", oggi, "data/nonexiste2/r.jsonl", "k"),
|
||||||
|
("PASSATO", oggi - timedelta(days=3), "data/nonexiste3/r.jsonl", "k")])
|
||||||
|
righe = telegram_daily.monitors()
|
||||||
|
assert "(-10g)" in righe[0]
|
||||||
|
assert "(oggi)" in righe[1]
|
||||||
|
assert "(scaduto da 3g)" in righe[2]
|
||||||
|
assert "−-" not in "".join(righe) and "(−" not in "".join(righe) # mai il doppio segno
|
||||||
|
|||||||
Reference in New Issue
Block a user