9f55652d99
Su autorizzazione dell'operatore («usa piu' USDE», «cerca % massima raggiungibile»): - usde_convert al 31,8% (774 USDE), poi sonda a saldo crescente r0906_usde_tetto_sonda.py (passi 100->20->4->1, doppio rifiuto prima di scendere, conferma a saldo neutro): da 31,8% a 68,7% in 39 ordini @ 1,0005, ZERO rifiuti. Fermata dal cuscino di regolamento (70%, derivato da config), non dal venue. «Il 70% non e' raggiungibile ne' ora ne' mai» (31/08) e' falsificato: il tetto e' sparito, non scalato. Non spiegato, puo' tornare. - config: venue_cap_frac -> null (misura in venue_cap_misurato), quota_max_frac 0,50 -> 0,85 (il valore scelto dall'operatore il 30/08 per questo scenario). - debito §5.17: nessuno sorveglia il cuscino USDC (slack $58; una perdita del libro lo consuma da sola). Versamento di prova di EUR 25 il 25/08 08:00Z (dichiarato dall'operatore): +3,3% su $647, sotto la soglia del 10% del rilevatore, contato per 12 giorni come trading. - data/live/movimenti_dichiarati.jsonl (append-only, nel backup) letto da journal.movimenti_dichiarati; movimenti_capitale lo fonde coi rilevati (fonte dichiarato, importo dell'operatore, dopo = prima + importo; dichiarato+rilevato se coincide; fuori letture o riga rotta -> avvisi). Report e nota di giornale lo dicono. - trading da arming +87,35 -> +62,45; TWR +11,98% -> +7,83%. CLAUDE.md §2 aggiornato. - tests: +6 in test_journal (conftest isola il file). Suite 916 verdi. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSnordG9FT4q8M4MVm85GQ
796 lines
46 KiB
Python
796 lines
46 KiB
Python
"""Giornale di bordo del libro live: una voce al giorno, MISURATA.
|
|
|
|
REGOLA DI QUESTO MODULO: il giornale registra NUMERI e stati derivati dai numeri.
|
|
Non scrive narrativa di mercato, non interpreta, non prevede. Il campo `nota` esiste
|
|
apposta per il testo libero dell'operatore, ed e' l'unico posto dove puo' finire un'opinione:
|
|
resta vuoto se nessuno lo scrive. *Un giornale che si inventa la lettura del mercato smette
|
|
di essere una misura e diventa un racconto — e fra sei mesi non si distingue piu' quale delle
|
|
due cose si stava leggendo.*
|
|
|
|
TRE STATI, mai due: ogni grandezza e' un numero, oppure `None` con una ragione. Un dato
|
|
mancante NON diventa zero (lezione `paper_dvolspread` / `venue_watch`: "non vedo" non e'
|
|
"va tutto bene").
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from src.live import tradesdb as T
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
JOURNAL_DIR = PROJECT_ROOT / "docs" / "journal"
|
|
# Movimenti di capitale DICHIARATI dall'operatore (append-only, dentro il perimetro di backup —
|
|
# P11). Servono per i movimenti che il rilevatore non puo' vedere: sotto `EQUITY_JUMP_ALERT`
|
|
# (il versamento di prova da ~$25 del 2026-08-25 08:00Z, +3,3% su $647, restato nel P&L per
|
|
# 12 giorni) o dentro un'ora di mercato non misurabile. Una riga per movimento:
|
|
# {"ts_utc": "...", "delta_usd": 24.9, "banda_usd": 0.5, "nota": "...", "dichiarato_il": "..."}
|
|
MOVIMENTI_DICHIARATI = PROJECT_ROOT / "data" / "live" / "movimenti_dichiarati.jsonl"
|
|
ASSETS = ("BTC", "ETH")
|
|
# Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni).
|
|
ORIZZONTI = (30, 90, 180)
|
|
|
|
|
|
def _giornaliero(df: pd.DataFrame) -> pd.Series:
|
|
s = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))["close"]
|
|
return s.resample("1D").last().dropna()
|
|
|
|
|
|
def _rv_annua(px: pd.Series, n: int = 30) -> float | None:
|
|
r = np.log(px).diff().dropna()
|
|
if len(r) < n:
|
|
return None
|
|
return float(r.tail(n).std(ddof=1) * math.sqrt(365) * 100)
|
|
|
|
|
|
def metriche_mercato(giorno: date) -> dict:
|
|
"""Stato del mercato al `giorno`, dal feed CERTIFICATO. Nessuna chiamata di rete."""
|
|
from src.data.downloader import load_data
|
|
out: dict = {}
|
|
for a in ASSETS:
|
|
try:
|
|
px = _giornaliero(load_data(a, "1h"))
|
|
except Exception as e:
|
|
out[a] = dict(errore=f"feed non leggibile: {type(e).__name__}")
|
|
continue
|
|
px = px[px.index.date <= giorno]
|
|
if px.empty:
|
|
out[a] = dict(errore="nessuna barra fino al giorno richiesto")
|
|
continue
|
|
ult = float(px.iloc[-1])
|
|
d = dict(chiusura=ult, barra=str(px.index[-1].date()))
|
|
for k, n in (("ret_1g", 1), ("ret_7g", 7), ("ret_30g", 30)):
|
|
d[k] = float(px.iloc[-1] / px.iloc[-1 - n] - 1) * 100 if len(px) > n else None
|
|
d["rv30_annua"] = _rv_annua(px)
|
|
# gli stessi orizzonti del segnale di produzione: e' lo stato che il libro LEGGE
|
|
d["tsmom"] = {f"{n}g": (None if len(px) <= n else int(np.sign(px.iloc[-1] / px.iloc[-1 - n] - 1)))
|
|
for n in ORIZZONTI}
|
|
segni = [v for v in d["tsmom"].values() if v is not None]
|
|
d["tsmom_su"] = (sum(1 for v in segni if v > 0), len(segni)) if segni else None
|
|
# DVOL: se il file non c'e' o e' vecchio -> None con ragione, mai 0
|
|
f = PROJECT_ROOT / "data" / "raw" / f"dvol_{a.lower()}.parquet"
|
|
if f.exists():
|
|
dv = pd.read_parquet(f)
|
|
dv.index = pd.to_datetime(dv["timestamp"], unit="ms", utc=True)
|
|
dv = dv[dv.index.date <= giorno]["close"]
|
|
if len(dv):
|
|
eta = (giorno - dv.index[-1].date()).days
|
|
d["dvol"] = float(dv.iloc[-1]) if eta <= 2 else None
|
|
d["dvol_eta_g"] = eta
|
|
if len(dv) >= 252 and eta <= 2:
|
|
d["dvol_rank_1a"] = float((dv.tail(252) < dv.iloc[-1]).mean())
|
|
if len(dv) >= 60 and eta <= 2:
|
|
# IV-rank come lo calcola VRP01 (`options_vrp_v2._ivrank`): percentile
|
|
# ESPANDENTE su tutta la storia, non una finestra di un anno. Sono due
|
|
# statistiche diverse e vanno chiamate con nomi diversi.
|
|
d["dvol_rank_exp"] = float((dv.iloc[:-1] < dv.iloc[-1]).mean())
|
|
else:
|
|
d["dvol"] = None; d["dvol_nota"] = "nessuna barra DVOL <= giorno"
|
|
else:
|
|
d["dvol"] = None; d["dvol_nota"] = "file DVOL assente"
|
|
out[a] = d
|
|
return out
|
|
|
|
|
|
def stato_libro(con, giorno: date) -> dict:
|
|
"""Ultimo giro di `book_execute` del giorno: cosa il libro vedeva e teneva."""
|
|
runs = T.parse_cron_log(T.CRON_LOG.read_text(errors="replace")) if T.CRON_LOG.exists() else []
|
|
g = [r for r in runs if r.ts_utc[:10] == giorno.isoformat()]
|
|
if not g:
|
|
return dict(errore="nessun giro di book_execute quel giorno", giri=0)
|
|
ult = g[-1]
|
|
lordo = sum(abs(v.get("pos", 0.0)) for v in ult.stato_asset.values())
|
|
return dict(giri=len(g), ultimo_giro=ult.ts_utc, equity=ult.equity, barra=ult.last_bar,
|
|
feed_skh_min=ult.feed_min, nozionale_lordo=lordo,
|
|
leva_lorda=(lordo / ult.equity if ult.equity else None),
|
|
asset={a: dict(tp_frac=v["tp_frac"], skh_sign=v["skh_sign"],
|
|
skh_entry=v.get("skh_entry"), target=v["net"], posizione=v["pos"],
|
|
azione=v["azione"]) for a, v in ult.stato_asset.items()})
|
|
|
|
|
|
def _px_1h(asset: str) -> pd.Series | None:
|
|
"""Chiusure orarie UTC dal feed certificato; None se non leggibile (P5: si dichiara)."""
|
|
try:
|
|
from src.data.downloader import load_data
|
|
df = load_data(asset, "1h")
|
|
s = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))["close"]
|
|
return s.sort_index()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def movimenti_dichiarati(path: Path | None = None) -> tuple[list[dict], list[str]]:
|
|
"""Righe di `MOVIMENTI_DICHIARATI` -> (movimenti validi, errori di riga). Mai solleva.
|
|
Una riga rotta non azzera le altre e NON sparisce: torna in `errori` (P3 — si registra nel
|
|
punto in cui si ingoia)."""
|
|
path = MOVIMENTI_DICHIARATI if path is None else path
|
|
if not path.exists():
|
|
return [], []
|
|
validi, errori = [], []
|
|
for n, riga in enumerate(path.read_text().splitlines(), 1):
|
|
if not riga.strip():
|
|
continue
|
|
try:
|
|
d = json.loads(riga)
|
|
ts = pd.Timestamp(d["ts_utc"])
|
|
if ts.tzinfo is None:
|
|
raise ValueError("ts_utc senza fuso")
|
|
delta = float(d["delta_usd"])
|
|
if delta == 0:
|
|
raise ValueError("delta_usd zero")
|
|
validi.append(dict(ts=ts, delta=delta, banda=float(d.get("banda_usd") or 0.0),
|
|
nota=str(d.get("nota") or ""), dichiarato_il=d.get("dichiarato_il")))
|
|
except Exception as e: # noqa: BLE001 — la riga rotta si riporta
|
|
errori.append(f"{path.name}:{n}: {type(e).__name__}: {e}")
|
|
validi.sort(key=lambda d: d["ts"])
|
|
return validi, errori
|
|
|
|
|
|
def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
|
"""Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L.
|
|
|
|
PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce
|
|
dentro come se fosse profitto: la voce del 2026-08-25 dichiarava «giorno: +$1.414,57» quando
|
|
$1.399 erano un deposito USDC, e il «cumulato dall'arming» avrebbe mentito per sempre.
|
|
|
|
COME CLASSIFICA (tre stati, mai due):
|
|
- candidato = salto fra due letture consecutive oltre `EQUITY_JUMP_ALERT` del book —
|
|
la soglia e' IMPORTATA, non ridichiarata (P1: il rilevatore di movimenti e' quello);
|
|
- `movimento` solo se il salto supera di >2x il massimo che il MERCATO avrebbe potuto
|
|
produrre nell'intervallo: |Δprezzo| reale dal feed certificato x il tetto di leva letto
|
|
da config. Non un'ipotesi sul mercato: il mercato misurato in quella finestra;
|
|
- `ambiguo` altrimenti, o quando il mercato non e' misurabile: si DICHIARA e non si
|
|
scorpora (P12 — una riparazione silenziosa e' un'invenzione; un crash vero a tutta
|
|
leva non deve trasformarsi in "prelievo").
|
|
|
|
⚠️ LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000)
|
|
non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live.
|
|
Il rimedio NON e' abbassare la soglia (ogni ora di mercato diventerebbe un candidato): e'
|
|
la DICHIARAZIONE dell'operatore in `MOVIMENTI_DICHIARATI` — l'unica fonte che sa l'importo.
|
|
Un movimento dichiarato e' `movimento` con `fonte="dichiarato"`, l'importo e' quello
|
|
dichiarato (non il salto di equity), e `dopo` = `prima` + importo: il P&L di mercato di
|
|
quell'ora resta nel rendimento invece di uscire con il movimento. Se coincide con un salto
|
|
rilevato, l'importo dichiarato VINCE sul salto (fonte "dichiarato+rilevato"); un movimento
|
|
dichiarato fuori dalle letture, o dopo `fino_ts`, non entra e finisce in `avvisi`.
|
|
"""
|
|
from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata
|
|
# `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron
|
|
# possono cadere ai due lati di una scrittura e descrivere due istanti diversi.
|
|
if rows is None:
|
|
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
|
(fino_ts,)).fetchall()
|
|
soglia = float(_book.EQUITY_JUMP_ALERT)
|
|
candidati = [(a, b) for a, b in zip(rows, rows[1:])
|
|
if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia]
|
|
dichiarati, avvisi = movimenti_dichiarati()
|
|
if not candidati and not dichiarati:
|
|
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
|
|
try:
|
|
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
|
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
|
|
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
|
|
# 01/09 entra qui da sola)
|
|
scala = float(cfg.get("book_scale_k") or 1.0)
|
|
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
|
|
float(_book.LEVA_LORDA_MAX))
|
|
except Exception:
|
|
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
|
|
px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
|
|
eventi = []
|
|
for prev, cur in candidati:
|
|
t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"])
|
|
delta = float(cur["equity"] - prev["equity"])
|
|
pct = delta / prev["equity"]
|
|
# Il mercato e' MISURABILE solo se il feed copre entrambe le letture. Il feed 1h
|
|
# certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
|
|
# `asof` restituirebbe la STESSA barra per t0 e t1, max_mkt 0, e qualunque calo >=10%
|
|
# diventerebbe un "movimento" — un crash stampato come prelievo (revisione 02/09).
|
|
# Non misurabile => `ambiguo` con la ragione (P5: "non vedo" non e' "mercato fermo").
|
|
mosse, non_mis = [], []
|
|
for a in ASSETS:
|
|
s = px.get(a)
|
|
if s is None or s.empty:
|
|
non_mis.append(f"{a}: feed 1h non leggibile"); continue
|
|
fine = s.index[-1] + pd.Timedelta(hours=1) # l'ultima barra copre un'ora
|
|
if t0 < s.index[0] or t1 > fine:
|
|
non_mis.append(f"{a}: feed 1h fermo a {s.index[-1]:%Y-%m-%dT%H:%M}Z"); continue
|
|
p0, p1 = s.asof(t0), s.asof(t1)
|
|
if pd.isna(p0) or pd.isna(p1) or not p0 or not p1:
|
|
non_mis.append(f"{a}: barra assente"); continue
|
|
mosse.append(abs(float(p1) / float(p0) - 1.0))
|
|
if non_mis:
|
|
max_mkt, classe, nota = None, "ambiguo", "mercato non misurabile: " + "; ".join(non_mis)
|
|
else:
|
|
max_mkt = max(mosse) * leva_tetto
|
|
classe = "movimento" if abs(pct) > 2.0 * max_mkt else "ambiguo"
|
|
nota = None
|
|
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
|
prima=float(prev["equity"]), dopo=float(cur["equity"]),
|
|
delta=delta, pct=float(pct),
|
|
mercato_max_pct=(None if max_mkt is None else float(max_mkt)),
|
|
mercato_nota=nota, classe=classe, fonte="rilevato"))
|
|
# --- dichiarati dall'operatore: l'importo lo sa solo lui ------------------------------
|
|
ts_rows = [pd.Timestamp(r["ts_utc"]) for r in rows]
|
|
for d in dichiarati:
|
|
if not rows or d["ts"] <= ts_rows[0] or d["ts"] > ts_rows[-1]:
|
|
avvisi.append(f"movimento dichiarato {d['ts']:%Y-%m-%dT%H:%M}Z ${d['delta']:+,.2f} "
|
|
f"fuori dalle letture [{rows[0]['ts_utc'][:16] if rows else '-'} .. "
|
|
f"{rows[-1]['ts_utc'][:16] if rows else '-'}]: non applicato")
|
|
continue
|
|
i = next(k for k in range(1, len(rows)) if ts_rows[k - 1] < d["ts"] <= ts_rows[k])
|
|
prev, cur = rows[i - 1], rows[i]
|
|
nota_d = (f"dichiarato dall'operatore"
|
|
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
|
|
+ (f": {d['nota']}" if d["nota"] else "")
|
|
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
|
|
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None)
|
|
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
|
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
|
classe="movimento", fonte="dichiarato+rilevato",
|
|
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
|
continue
|
|
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
|
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
|
delta=d["delta"],
|
|
pct=(d["delta"] / float(prev["equity"]) if prev["equity"] else None),
|
|
mercato_max_pct=None, mercato_nota=None, classe="movimento",
|
|
fonte="dichiarato", dichiarazione=nota_d))
|
|
eventi.sort(key=lambda e: e["ts_dopo"])
|
|
return dict(eventi=eventi,
|
|
certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")),
|
|
ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")),
|
|
soglia=soglia, leva_tetto=leva_tetto, avvisi=avvisi)
|
|
|
|
|
|
def rendimento_twr(con, fino_ts: str) -> dict:
|
|
"""Rendimento pesato per il tempo (TWR) della serie di equity, spezzata sui movimenti CERTI.
|
|
|
|
PERCHE' ESISTE (debito #14, trovato il 2026-09-01). `e1/e0 - 1` sulla serie grezza era il
|
|
numero che `trades_db.py --report` stampava come performance: «+243%», per il 96,3% un
|
|
bonifico ($1.399 il 25/08 su $667). La riparazione — `movimenti_capitale` — viveva gia' in
|
|
questo modulo e NON aveva attraversato il confine fra i due lettori della stessa serie:
|
|
variante di P1. Da oggi il secondo lettore CHIAMA il primo invece di rifare il conto.
|
|
|
|
COME. Ogni evento di classe `movimento` chiude un segmento all'equity PRIMA del salto e ne
|
|
apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli
|
|
eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto).
|
|
|
|
⚠️ LIMITE DICHIARATO (D5). L'intervallo che CONTIENE un movimento certo esce INTERO dal
|
|
rendimento: il suo P&L di mercato (marcatura delle posizioni aperte, funding; in un buco di
|
|
lettura — fino a 10h nella serie vera — l'intero buco) finisce in `certi`, quindi anche fuori
|
|
da `trading`. E' limitato per costruzione dal margine del classificatore (|salto| >
|
|
2·max_mkt·leva·equity), percio' l'errore massimo vale max_mkt·leva·equity: fino a META' del
|
|
movimento riconosciuto. Il 25/08: libro a ~$263 lordi, mercato a ±0,2% -> ~$0,5 su $1.399,39.
|
|
Non si stima e non si corregge (P12): si dichiara. Un intervallo di sola marcatura fra due
|
|
movimenti consecutivi, o prima del primo, ha lunghezza zero e NON produce un segmento.
|
|
|
|
TRE STATI: `twr` e `trading` sono numeri, oppure None con `motivo`. Mercato NON misurabile
|
|
per almeno un salto (feed 1h fermo, o assente) => entrambi None: non si conosce la classe
|
|
del salto, quindi non si conosce il rendimento — e `e1/e0-1` non torna sotto un'etichetta
|
|
piu' autorevole. Base di equity zero =>
|
|
entrambi None: il salto 0 -> X del primo versamento e' INVISIBILE a `movimenti_capitale`
|
|
(una base zero non ha percentuale), quindi `certi` lo perderebbe e `trading` varrebbe
|
|
l'intero conto — un numero etichettato "al netto dei versamenti" che li contiene tutti.
|
|
"""
|
|
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
|
(fino_ts,)).fetchall()
|
|
if not rows:
|
|
return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[],
|
|
certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0)
|
|
mv = movimenti_capitale(con, fino_ts, rows=rows) # stessa lettura, un solo istante
|
|
e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"])
|
|
if e0 <= 0:
|
|
return dict(twr=None, trading=None,
|
|
motivo="base di equity nulla: il primo versamento non e' classificabile",
|
|
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
|
|
delta_equity=e1 - e0, letture=len(rows))
|
|
non_mis = [e for e in mv["eventi"] if e.get("mercato_nota") and e["classe"] != "movimento"]
|
|
if non_mis:
|
|
# un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un
|
|
# movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe
|
|
# sotto l'etichetta "TWR", il numero che il debito 14 e' nato per uccidere)
|
|
return dict(twr=None, trading=None,
|
|
motivo=(f"{len(non_mis)} salto/i non classificabile/i — "
|
|
+ non_mis[0]["mercato_nota"]),
|
|
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
|
|
delta_equity=e1 - e0, letture=len(rows))
|
|
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
|
|
segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0
|
|
for ev in certi: # gia' in ordine di tempo
|
|
if da_eq and ev["ts_prima"] != da_ts: # lunghezza zero = niente segmento
|
|
segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"],
|
|
ret=ev["prima"] / da_eq - 1.0))
|
|
da_ts, da_eq = ev["ts_dopo"], ev["dopo"]
|
|
if da_eq and rows[-1]["ts_utc"] != da_ts:
|
|
segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1,
|
|
ret=e1 / da_eq - 1.0))
|
|
twr = 1.0
|
|
for sg in segmenti: # nessun segmento = nessun tempo a mercato = 0
|
|
twr *= 1.0 + sg["ret"]
|
|
return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"],
|
|
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
|
|
trading=e1 - e0 - mv["certi"], letture=len(rows), avvisi=mv.get("avvisi") or [])
|
|
|
|
|
|
def pnl_giorno(con, giorno: date) -> dict:
|
|
"""P&L del giorno. `equity` e' l'autorita' (venue); il resto e' scomposizione locale.
|
|
|
|
`delta_equity` e `cumulato_da_arming` restano FATTI (l'equity si e' mossa cosi');
|
|
`trading_giorno` e `trading_da_arming` sono gli stessi numeri al netto dei movimenti
|
|
di capitale CERTI — gli ambigui restano dentro, dichiarati a parte."""
|
|
g = giorno.isoformat()
|
|
eq = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc LIKE ? ORDER BY ts_utc",
|
|
(f"{g}%",)).fetchall()
|
|
prima = con.execute("SELECT equity FROM equity WHERE ts_utc < ? ORDER BY ts_utc DESC LIMIT 1",
|
|
(g,)).fetchone()
|
|
e_ini = prima["equity"] if prima else (eq[0]["equity"] if eq else None)
|
|
e_fin = eq[-1]["equity"] if eq else None
|
|
rt = con.execute("SELECT * FROM roundtrips WHERE ts_out LIKE ?", (f"{g}%",)).fetchall()
|
|
fills = con.execute("SELECT * FROM fills WHERE ts_utc LIKE ?", (f"{g}%",)).fetchall()
|
|
e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone()
|
|
delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None
|
|
cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None
|
|
# il cumulato dall'arming, i movimenti e il trading al netto vengono da rendimento_twr:
|
|
# UNA funzione per i due lettori (report e giornale), non due aritmetiche (revisione 02/09)
|
|
r_twr = rendimento_twr(con, f"{g}T23:59:59+00:00")
|
|
mv = dict(eventi=r_twr["eventi"], certi=r_twr["certi"], ambigui=r_twr["ambigui"])
|
|
ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g]
|
|
mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento")
|
|
amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo")
|
|
return dict(
|
|
equity_inizio=e_ini, equity_fine=e_fin, delta_equity=delta,
|
|
realizzato_lordo=sum(r["pnl_lordo"] for r in rt) if rt else 0.0,
|
|
realizzato_netto=sum(r["pnl_netto"] for r in rt) if rt else 0.0,
|
|
roundtrip_chiusi=len(rt), fill=len(fills),
|
|
fee=sum(f["fee"] for f in fills) if fills else 0.0,
|
|
cumulato_da_arming=cum, letture_equity=len(eq),
|
|
movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g),
|
|
trading_giorno=(delta - mov_g) if delta is not None else None,
|
|
movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"],
|
|
trading_da_arming=(r_twr["trading"] if cum is not None else None),
|
|
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
|
|
|
|
|
|
def salute(con, giorno: date) -> dict:
|
|
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
|
|
|
|
Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un
|
|
allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero.
|
|
"""
|
|
lb = stato_libro(con, giorno)
|
|
adesso = datetime.now(timezone.utc)
|
|
in_corso = giorno == adesso.date()
|
|
atteso = (adesso.hour + 1) if in_corso else 24
|
|
fatti = lb.get("giri", 0)
|
|
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
|
|
giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti))
|
|
|
|
|
|
def costruisci(con, giorno: date) -> dict:
|
|
return dict(giorno=giorno.isoformat(), ts_scritto=T.ora(),
|
|
mercato=metriche_mercato(giorno), libro=stato_libro(con, giorno),
|
|
pnl=pnl_giorno(con, giorno), salute=salute(con, giorno))
|
|
|
|
|
|
def salva(con, voce: dict, nota: str | None = None) -> None:
|
|
"""Ricalcola le sezioni misurate. NON tocca ne' `nota` (operatore) ne' `analisi` (agente):
|
|
sono le uniche due cose in pagina che un ricalcolo non puo' rigenerare."""
|
|
esistente = con.execute("SELECT nota FROM journal WHERE giorno=?", (voce["giorno"],)).fetchone()
|
|
# la nota dell'operatore NON viene mai sovrascritta da un ricalcolo automatico
|
|
nota_finale = nota if nota is not None else (esistente["nota"] if esistente else "")
|
|
con.execute("""INSERT INTO journal (giorno, ts_scritto, mercato, libro, pnl, salute, nota)
|
|
VALUES (?,?,?,?,?,?,?)
|
|
ON CONFLICT(giorno) DO UPDATE SET
|
|
ts_scritto=excluded.ts_scritto, mercato=excluded.mercato,
|
|
libro=excluded.libro, pnl=excluded.pnl, salute=excluded.salute,
|
|
nota=excluded.nota""",
|
|
(voce["giorno"], voce["ts_scritto"], json.dumps(voce["mercato"]),
|
|
json.dumps(voce["libro"]), json.dumps(voce["pnl"]),
|
|
json.dumps(voce["salute"]), nota_finale))
|
|
con.commit()
|
|
|
|
|
|
|
|
# =============================================================================================
|
|
# LETTURA RAGIONATA — a regole DICHIARATE, non a prosa libera
|
|
# =============================================================================================
|
|
# Ogni riga della lettura esce da una regola con un id, e ogni regola puo' solo COMBINARE numeri
|
|
# gia' presenti nella pagina. Non prevede, non consiglia, non interpreta oltre il dato.
|
|
# Le tre proprieta' che la rendono diversa da un racconto:
|
|
# (a) TRACCIABILE — l'id dice quale regola ha parlato;
|
|
# (b) FALSIFICABILE — ogni regola ha un test che la accende E uno che la tiene spenta;
|
|
# (c) MUTA sul non misurato — se il numero e' None la regola tace, non arrotonda a zero.
|
|
# Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano.
|
|
|
|
# =============================================================================================
|
|
# DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE
|
|
# =============================================================================================
|
|
# N9: quando una decisione viene rinviata con un motivo, il motivo va registrato — altrimenti fra
|
|
# tre mesi la stessa analisi si ripresenta come se fosse nuova. E una decisione parcheggiata su un
|
|
# numero che NESSUNO sorveglia e' parcheggiata per sempre: qui la sorveglianza e' l'equity, che il
|
|
# giornale legge comunque ogni giorno.
|
|
#
|
|
# Una riga si TOGLIE quando la decisione e' presa. Finche' c'e', la voce del giorno la ripete: e'
|
|
# voluto — non e' un allarme che chiede un'azione impossibile (P14), e' una domanda che da quel
|
|
# giorno in poi si puo' finalmente rispondere.
|
|
SOGLIE_CAPITALE: tuple[tuple[float, str], ...] = (
|
|
(15_000.0, "**GTAA01: tenere o bloccare.** Decisione rinviata a questa soglia il 2026-08-28 "
|
|
"(diario `2026-08-28-gtaa-fase-e-allarmi`). Sotto $15k di book lo sleeve NON e' "
|
|
"deployabile — `GTAA_MIN_CAPITAL` $3.000 allocati al peso 20% — quindi finora e' "
|
|
"stato manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio "
|
|
"+0,095/+0,124 di Sharpe secondo la fase: reale, sempre positivo, e delle stesse "
|
|
"dimensioni dello spread fra le baseline."),
|
|
(20_000.0, "**«100% Deribit fino a $20k»** (decisione del 26/07): la soglia e' arrivata, e con "
|
|
"essa la domanda del secondo conto (N4: il rischio di venue si compra con un CONTO, "
|
|
"non con uno sleeve)."),
|
|
)
|
|
|
|
|
|
def soglie_superate(equity: float | None) -> list[tuple[float, str]]:
|
|
"""PURA. Le decisioni parcheggiate che a questa equity si possono finalmente prendere."""
|
|
if equity is None:
|
|
return []
|
|
return [(s, t) for s, t in SOGLIE_CAPITALE if equity >= s]
|
|
|
|
|
|
def _bucket_convinzione(tp: float) -> str:
|
|
"""TP01 media tre `np.sign()` e poi taglia a long-flat: la direzione vive in {0, 1/3, 1}.
|
|
|
|
(Il bucket 2/3 NON esiste — verificato al sorgente il 2026-08-23, §41.) Il `tp_frac` che
|
|
il libro stampa e' quella direzione MOLTIPLICATA per il vol-target, quindi non e' il bucket:
|
|
qui se ne riporta solo l'ordine di grandezza.
|
|
"""
|
|
if tp <= 0.0:
|
|
return "flat"
|
|
return "parziale" if tp < 0.60 else "piena"
|
|
|
|
|
|
def lettura(con, voce: dict) -> list[dict]:
|
|
"""Ritorna [{id, testo, livello}] — livello in {info, nota, attenzione}."""
|
|
m, lb, p, s = voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
|
|
out: list[dict] = []
|
|
|
|
def dire(rid, testo, livello="info"):
|
|
out.append(dict(id=rid, testo=testo, livello=livello))
|
|
|
|
# --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ----------------------
|
|
for e in p.get("movimenti_giorno") or []:
|
|
h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16]
|
|
if e["classe"] == "movimento" and e.get("fonte", "rilevato") != "rilevato":
|
|
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
|
|
f"{h1} UTC, {e.get('dichiarazione') or 'dichiarato'}. "
|
|
"Scorporato dal P&L di trading qui sotto.", "nota")
|
|
elif e["classe"] == "movimento":
|
|
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
|
|
f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile "
|
|
f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. "
|
|
"Scorporato dal P&L di trading qui sotto.", "nota")
|
|
else:
|
|
perche = ("il mercato non e' misurabile in quella finestra" if e["mercato_max_pct"]
|
|
is None else f"il mercato avrebbe potuto produrlo (max "
|
|
f"±{100*e['mercato_max_pct']:.1f}% al tetto di leva)")
|
|
dire("movimento_ambiguo", f"Salto di equity **${e['delta']:+,.2f}** fra le {h0} e le "
|
|
f"{h1} UTC che NON si puo' attribuire: {perche}. "
|
|
"Resta nel P&L, dichiarato — non scorporato.", "attenzione")
|
|
|
|
# --- cosa il libro STA FACENDO -----------------------------------------------------------
|
|
# (le regole sui movimenti stanno SOPRA questo return: un versamento in un giorno senza
|
|
# giri di book esiste lo stesso — viene dal DB equity, non dal log del libro)
|
|
if "errore" in lb:
|
|
dire("stato", f"Nessun giro di `book_execute`: {lb['errore']}. "
|
|
"Non e' «il libro era fermo», e' «non e' stato misurato».", "attenzione")
|
|
return out
|
|
|
|
att = {a: v for a, v in lb["asset"].items() if abs(v["posizione"]) >= 1.0}
|
|
if not att:
|
|
dire("stato", "Libro **flat** su entrambe le gambe: nessun capitale a mercato.")
|
|
motivi = []
|
|
for a, v in sorted(lb["asset"].items()):
|
|
pezzi = []
|
|
if v["tp_frac"] <= 0:
|
|
pezzi.append("TP01 a zero (trend giu' o misto)")
|
|
if v["skh_sign"] == 0:
|
|
pezzi.append("SKH01 senza breakout")
|
|
if pezzi:
|
|
motivi.append(f"{a}: " + " e ".join(pezzi))
|
|
if motivi:
|
|
dire("perche_flat", "Motivo, componente per componente — " + "; ".join(motivi) +
|
|
". Per una strategia long-flat stare fuori **e'** una decisione.")
|
|
else:
|
|
pezzi = [f"{a} ${v['posizione']:+,.0f} (TP01 {v['tp_frac']:+.3f}, "
|
|
f"SKH01 {'flat' if v['skh_sign'] == 0 else ('long' if v['skh_sign'] > 0 else 'short')})"
|
|
for a, v in sorted(att.items())]
|
|
dire("stato", "A mercato su " + " e ".join(pezzi) + ".")
|
|
|
|
# --- il segnale contro il mercato --------------------------------------------------------
|
|
for a, v in sorted(lb["asset"].items()):
|
|
d = m.get(a) or {}
|
|
su = d.get("tsmom_su")
|
|
if not su:
|
|
continue
|
|
n_su, n_tot = su
|
|
b = _bucket_convinzione(v["tp_frac"])
|
|
if 0 < n_su < n_tot and v["tp_frac"] > 0:
|
|
dire("disaccordo", f"{a}: gli orizzonti del trend **non concordano** ({n_su}/{n_tot} "
|
|
f"al rialzo) e l'esposizione TP01 e' {b} ({v['tp_frac']:+.3f}) — "
|
|
"e' il meccanismo, non una scelta di oggi.")
|
|
elif n_su == n_tot and v["tp_frac"] <= 0:
|
|
dire("incoerenza", f"{a}: tutti e {n_tot} gli orizzonti sono al rialzo ma TP01 e' a "
|
|
"zero. Da guardare: non e' lo stato atteso.", "attenzione")
|
|
|
|
# --- quanto capitale e' esposto ----------------------------------------------------------
|
|
lv = lb.get("leva_lorda")
|
|
if lv is not None:
|
|
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo
|
|
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura
|
|
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio
|
|
# bersaglio continua a passare il giorno che il bersaglio cambia.
|
|
try:
|
|
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
|
cap_frac = float(cfg["max_notional_per_asset_frac"])
|
|
tetto = cap_frac * len(lb["asset"])
|
|
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
|
|
f"(`max_notional_per_asset_frac` {cap_frac:.2f} x {len(lb['asset'])} "
|
|
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.")
|
|
except Exception as e:
|
|
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
|
|
f"({type(e).__name__} su config/live.json).", "attenzione")
|
|
|
|
# --- P&L del giorno, in proporzione ------------------------------------------------------
|
|
dq, fee = p.get("delta_equity"), p.get("fee", 0.0)
|
|
dq_t = p.get("trading_giorno")
|
|
dq_eff = dq_t if dq_t is not None else dq
|
|
scorporo = bool(p.get("mov_giorno"))
|
|
if dq_eff is not None:
|
|
if p["fill"] and fee > 0 and abs(dq_eff) < fee:
|
|
dire("fee", f"Il movimento di equity del giorno (${dq_eff:+.2f}"
|
|
f"{', al netto dei movimenti di capitale' if scorporo else ''}) e' "
|
|
f"**piu' piccolo delle fee pagate** (${fee:.4f}): la giornata l'ha "
|
|
"decisa il costo, non il segnale.", "nota")
|
|
elif p["fill"] == 0:
|
|
dire("pnl", f"Equity ${dq_eff:+.2f} **senza operare**: e' mark-to-market sulle "
|
|
"posizioni gia' aperte."
|
|
+ (" (Al netto dei movimenti di capitale.)" if scorporo else ""))
|
|
else:
|
|
dire("pnl", (f"P&L di trading ${dq_eff:+.2f} (equity ${dq:+.2f} al lordo del "
|
|
f"movimento di capitale) " if scorporo else f"Equity ${dq_eff:+.2f} ")
|
|
+ f"con {p['fill']} fill e "
|
|
f"{p['roundtrip_chiusi']} round-trip chiusi (realizzato "
|
|
f"${p['realizzato_netto']:+.2f} netto).")
|
|
|
|
# --- il cumulato, e da dove viene --------------------------------------------------------
|
|
eq = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
|
(voce["giorno"] + "T23:59:59+00:00",)).fetchall()
|
|
if len(eq) > 24:
|
|
e0, e1 = eq[0]["equity"], eq[-1]["equity"]
|
|
# il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento
|
|
# diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto)
|
|
mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"]
|
|
g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat()
|
|
tot = e1 - e0 - sum(e["delta"] for e in mv_ev)
|
|
picco = max(r["equity"] for r in eq)
|
|
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s]
|
|
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s)
|
|
# Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica
|
|
# vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione.
|
|
rilevante = abs(tot) >= max(5.0, 0.01 * e1)
|
|
if rilevante and g7:
|
|
quota = (e1 - g7[0]["equity"] - mv7) / tot
|
|
if quota > 0.60:
|
|
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
|
"e' un evento.")
|
|
if quota > 1.0:
|
|
coda = ("Oltre il 100% perche' **il periodo precedente era in perdita**: "
|
|
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
|
|
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
|
|
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
|
|
if picco > 0:
|
|
dd = (e1 - picco) / picco
|
|
if dd < -0.005:
|
|
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). "
|
|
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
|
|
|
|
# --- volatilita' implicita contro realizzata ---------------------------------------------
|
|
try:
|
|
from src.portfolio.sleeves import VRP_CFG
|
|
soglia_ivr = float(VRP_CFG["gate_ivr"]) # derivata dal sleeve, non riscritta
|
|
except Exception:
|
|
soglia_ivr = None
|
|
for a in ASSETS:
|
|
d = m.get(a) or {}
|
|
dv, rv = d.get("dvol"), d.get("rv30_annua")
|
|
if dv is None or rv is None:
|
|
continue
|
|
segno = "sopra" if dv > rv else "sotto"
|
|
riga = f"{a}: implicita {dv:.1f} contro realizzata 30g {rv:.1f} ({segno})"
|
|
if d.get("dvol_rank_1a") is not None:
|
|
riga += f"; DVOL al {100*d['dvol_rank_1a']:.0f}° pctl di un anno"
|
|
ivr = d.get("dvol_rank_exp")
|
|
if ivr is not None and soglia_ivr is not None:
|
|
riga += (f"; IV-rank espandente {ivr:.2f} "
|
|
+ (f"**sopra** la soglia {soglia_ivr:.2f} del gate di VRP01 (a quel gate il "
|
|
"sleeve venderebbe — resta paper, regola «niente short-vol da modello»)"
|
|
if ivr > soglia_ivr else
|
|
f"sotto la soglia {soglia_ivr:.2f} del gate di VRP01 (sleeve fermo)"))
|
|
dire("vol", riga + ".")
|
|
|
|
# --- giornata fuori scala ----------------------------------------------------------------
|
|
for a in ASSETS:
|
|
d = m.get(a) or {}
|
|
r1, rv = d.get("ret_1g"), d.get("rv30_annua")
|
|
if r1 is None or not rv:
|
|
continue
|
|
sd_g = rv / math.sqrt(365)
|
|
if sd_g > 0 and abs(r1) > 2 * sd_g:
|
|
dire("evento", f"{a}: giornata a {r1:+.2f}%, **{abs(r1)/sd_g:.1f} deviazioni** "
|
|
f"giornaliere (sd implicita dalla RV30 = {sd_g:.2f}%).", "nota")
|
|
|
|
# --- salute ------------------------------------------------------------------------------
|
|
if s.get("giri_mancanti"):
|
|
dire("giri", f"**{s['giri_mancanti']} giri di `book_execute` mancanti** su "
|
|
f"{s['atteso_giri']}: in quelle ore il libro non ha ne' letto ne' eseguito.",
|
|
"attenzione")
|
|
if s.get("feed_skh_min") is not None and s["feed_skh_min"] > 30:
|
|
dire("feed", f"Feed SKH01 vecchio di {s['feed_skh_min']} min (soglia 30): la latenza "
|
|
"d'uscita di SKH01 e' quella, non ~1h.", "attenzione")
|
|
|
|
# --- quanto vale questa pagina come evidenza ---------------------------------------------
|
|
n_rt = con.execute("SELECT COUNT(*) c FROM roundtrips WHERE ts_out <= ?",
|
|
(voce["giorno"] + "T23:59:59+00:00",)).fetchone()["c"]
|
|
if len(eq) > 24:
|
|
gg = (date.fromisoformat(eq[-1]["ts_utc"][:10]) - date.fromisoformat(eq[0]["ts_utc"][:10])).days
|
|
dire("evidenza", f"Campione a oggi: **{gg} giorni, {n_rt} round-trip**. A questa taglia "
|
|
"il P&L non distingue l'edge dalla fortuna — il criterio di morte del "
|
|
"libro (`edge_watch`) lavora su una finestra di 36 mesi.", "nota")
|
|
|
|
# --- decisioni che questa equity sblocca -------------------------------------------------
|
|
for soglia, testo in soglie_superate(p.get("equity_fine")):
|
|
dire("soglia_capitale", f"📌 **Soglia ${soglia:,.0f} superata** — {testo}", "attenzione")
|
|
return out
|
|
|
|
def _n(v, fmt="{:+.2f}", vuoto="n/d"):
|
|
return vuoto if v is None else fmt.format(v)
|
|
|
|
|
|
def rendi_markdown(con, voce: dict) -> str:
|
|
g, m, lb, p, s = voce["giorno"], voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
|
|
riga = con.execute("SELECT * FROM journal WHERE giorno=?", (g,)).fetchone()
|
|
nota = (riga["nota"] if riga else "") or ""
|
|
an = dict(riga) if riga else {}
|
|
# Un giorno IN CORSO ha tutte le sezioni di uno chiuso -> passa ogni controllo di
|
|
# completezza e si legge come una giornata intera. La parzialita' va dichiarata nel TITOLO
|
|
# e nella prima riga, non nella quinta sezione su otto: chi apre la pagina — o un modello
|
|
# che se la ritrova nel prompt — legge quelle due. Stessa famiglia di "una barra presente
|
|
# non e' una giornata presente": qui la pagina e' presente, il giorno no.
|
|
if s.get("giorno_in_corso"):
|
|
testata = [
|
|
f"# Giornale di bordo — {g} · ⚠️ PARZIALE (giorno in corso)", "",
|
|
f"*Scritto {voce['ts_scritto']} — la giornata NON e' chiusa: la pagina copre "
|
|
f"{s.get('giri_book', 0)} giri su 24 e **non si aggiorna da sola**. I numeri di "
|
|
f"GIORNO (P&L del giorno, letture, giri) sono di questa frazione, non del giorno; "
|
|
f"i CUMULATI sono corretti. La versione completa la scrive il cron dopo le 00:30 "
|
|
f"UTC del giorno seguente.*"]
|
|
else:
|
|
testata = [
|
|
f"# Giornale di bordo — {g}", "",
|
|
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*"]
|
|
L = testata + ["",
|
|
"## Mercato", "",
|
|
"| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |",
|
|
"|---|---|---|---|---|---|---|---|"]
|
|
for a in ASSETS:
|
|
d = m.get(a) or {}
|
|
# tre stati: misurato / errore dichiarato / blocco assente. Nessuno dei tre e' uno zero,
|
|
# e l'ultimo non deve far cadere la pagina (un giornale che non si scrive non e' un giornale).
|
|
if "errore" in d or "chiusura" not in d:
|
|
perche = d.get("errore", "blocco mercato assente")
|
|
L.append(f"| **{a}** | n/d | n/d | n/d | n/d | n/d | n/d | {perche} |"); continue
|
|
ts = d.get("tsmom", {})
|
|
segni = " ".join("↑" if ts.get(f"{n}g") == 1 else "↓" if ts.get(f"{n}g") == -1 else "·"
|
|
for n in ORIZZONTI)
|
|
su = d.get("tsmom_su")
|
|
dv = f"{d['dvol']:.1f}" if d.get("dvol") is not None else f"n/d ({d.get('dvol_nota','vecchio')})"
|
|
if d.get("dvol_rank_1a") is not None:
|
|
dv += f" ({100*d['dvol_rank_1a']:.0f}° pctl 1a)"
|
|
L.append(f"| **{a}** | ${d['chiusura']:,.2f} | {_n(d.get('ret_1g'),'{:+.2f}%')} | "
|
|
f"{_n(d.get('ret_7g'),'{:+.2f}%')} | {_n(d.get('ret_30g'),'{:+.2f}%')} | "
|
|
f"{_n(d.get('rv30_annua'),'{:.1f}%')} | {segni}"
|
|
f"{f' ({su[0]}/{su[1]} su)' if su else ''} | {dv} |")
|
|
L += ["", "## Libro", ""]
|
|
if "errore" in lb:
|
|
L.append(f"⚠️ {lb['errore']}")
|
|
else:
|
|
L += [f"Equity **${lb['equity']:,.2f}** · nozionale lordo ${lb['nozionale_lordo']:,.0f} "
|
|
f"· leva lorda {_n(lb.get('leva_lorda'),'{:.2f}x')} · barra dati {lb['barra']} "
|
|
f"· {lb['giri']} giri", "",
|
|
"| | TP01 | SKH01 | target | posizione | azione |", "|---|---|---|---|---|---|"]
|
|
for a, v in sorted(lb["asset"].items()):
|
|
if v["skh_sign"] == 0:
|
|
sk = "flat"
|
|
else:
|
|
sk = "LONG" if v["skh_sign"] > 0 else "SHORT"
|
|
if v.get("skh_entry"):
|
|
sk += " @ {:,.1f}".format(v["skh_entry"])
|
|
L.append(f"| **{a}** | {v['tp_frac']:+.3f} | {sk} | ${v['target']:+,.0f} | "
|
|
f"${v['posizione']:+,.0f} | {v['azione']} |")
|
|
riga_g = (f"- giorno: **{_n(p.get('delta_equity'),'${:+,.2f}')}** di equity "
|
|
f"({p['letture_equity']} letture)")
|
|
if p.get("mov_giorno"):
|
|
riga_g += (f" — di cui **${p['mov_giorno']:+,.2f} movimenti di capitale** -> trading "
|
|
f"**{_n(p.get('trading_giorno'),'${:+,.2f}')}**")
|
|
if p.get("mov_giorno_ambiguo"):
|
|
riga_g += f" — ⚠️ salto ambiguo di ${p['mov_giorno_ambiguo']:+,.2f} non attribuito"
|
|
riga_c = f"- cumulato dall'arming: **{_n(p.get('cumulato_da_arming'),'${:+,.2f}')}** di equity"
|
|
if p.get("mov_arming"):
|
|
riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading "
|
|
f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**")
|
|
if p.get("twr_da_arming") is not None:
|
|
riga_c += f" · TWR **{100 * p['twr_da_arming']:+.2f}%**"
|
|
elif p.get("twr_motivo"):
|
|
riga_c += f" · TWR n/d ({p['twr_motivo']})"
|
|
if p.get("mov_arming_ambiguo"):
|
|
riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti"
|
|
L += ["", "## P&L", "",
|
|
riga_g,
|
|
f"- realizzato: {p['realizzato_netto']:+.2f} netto su {p['roundtrip_chiusi']} round-trip "
|
|
f"chiusi · {p['fill']} fill · fee {p['fee']:.4f}",
|
|
riga_c, "",
|
|
"## Salute", "",
|
|
f"- giri di `book_execute`: {s['giri_book']}/{s['atteso_giri']}"
|
|
+ (" *(giorno in corso)*" if s.get("giorno_in_corso") else "")
|
|
+ (f" — **{s['giri_mancanti']} mancanti**" if s["giri_mancanti"] else ""),
|
|
f"- eta' feed SKH all'ultimo giro: "
|
|
+ (f"{s['feed_skh_min']} min" if s["feed_skh_min"] is not None else "non misurata"),
|
|
""]
|
|
righe = lettura(con, voce)
|
|
L += ["## Lettura", "",
|
|
"*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra "
|
|
"e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un "
|
|
"giudizio: quello sta nella Nota.*", ""]
|
|
icona = {"info": "", "nota": "📌 ", "attenzione": "⚠️ "}
|
|
L += [f"- `[{r['id']}]` {icona[r['livello']]}{r['testo']}" for r in righe] or ["- *(nessuna regola accesa)*"]
|
|
testo = (an.get("analisi") or "").strip()
|
|
if testo:
|
|
stato = an.get("analisi_stato") or "?"
|
|
cap = (f"*Scritta da `{an.get('analisi_modello')}` il {an.get('analisi_ts')}. "
|
|
f"Prosa di un modello: puo' sbagliare, e non e' una misura. "
|
|
f"Controllo sui numeri: **{stato}**.*")
|
|
L += ["", "## Analisi (agente)", "", cap, ""]
|
|
if an.get("analisi_motivi"):
|
|
L += [f"> ⚠️ {an['analisi_motivi']}", ""]
|
|
L += [testo]
|
|
L += ["", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
|
|
return "\n".join(L)
|
|
|
|
|
|
def scrivi_file(con, voce: dict) -> Path:
|
|
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
f = JOURNAL_DIR / f"{voce['giorno']}.md"
|
|
f.write_text(rendi_markdown(con, voce))
|
|
return f
|