merge lavoro/goldrake-u4 (revisione 27/09)
This commit is contained in:
@@ -73,7 +73,8 @@ def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str],
|
||||
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
|
||||
telegram: bool = True) -> str:
|
||||
voce = J.costruisci(con, g)
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
if not secco: # --secco: stampa e NON salva (#34) — niente scrittura
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
pagina = J.rendi_markdown(con, voce)
|
||||
st = storico(con, g)
|
||||
prompt = A.costruisci_prompt(pagina, st)
|
||||
|
||||
@@ -125,7 +125,9 @@ def report() -> None:
|
||||
if ap:
|
||||
print(" posizioni aperte :")
|
||||
for a, d in sorted(ap.items()):
|
||||
print(f" {a} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} (dal {d['dal'][:16]})")
|
||||
marca = "" if d.get("direzione", "long") == "long" else " SHORT" # #32 C2
|
||||
print(f" {a}{marca} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} "
|
||||
f"(dal {d['dal'][:16]})")
|
||||
else:
|
||||
print(" posizioni aperte : nessuna (flat)")
|
||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||
|
||||
+49
-13
@@ -248,12 +248,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
||||
+ (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,
|
||||
# (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
|
||||
# cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
|
||||
# intervallo troverebbe il primo (gia' "dichiarato") e lo SOVRASCRIVEREBBE, perdendo
|
||||
# il primo importo invece di somarli.
|
||||
rilevato = next((e for e in eventi
|
||||
if e["ts_dopo"] == cur["ts_utc"] and e["fonte"] == "rilevato"), None)
|
||||
if rilevato is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
rilevato.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
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]
|
||||
and e["fonte"] in ("dichiarato", "dichiarato+rilevato")), None)
|
||||
if gia is not None: # un altro dichiarato nello stesso intervallo: si SOMMA
|
||||
nuovo_delta = gia["delta"] + d["delta"]
|
||||
gia.update(delta=nuovo_delta,
|
||||
pct=(nuovo_delta / float(gia["prima"]) if gia["prima"] else None),
|
||||
dopo=float(gia["prima"]) + nuovo_delta,
|
||||
dichiarazione=(gia["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"],
|
||||
@@ -376,16 +390,23 @@ def pnl_giorno(con, giorno: date) -> dict:
|
||||
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
|
||||
|
||||
|
||||
MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Il giro dell'ora CORRENTE scatta al minuto `MINUTO_GIRO` (il cron e' `47 * * * *`): prima
|
||||
di quel minuto non e' ancora avvenuto, e contarlo comunque (#35 C9) segnalava un giro
|
||||
"mancante" che semplicemente non era ancora il suo turno.
|
||||
"""
|
||||
lb = stato_libro(con, giorno)
|
||||
adesso = datetime.now(timezone.utc)
|
||||
in_corso = giorno == adesso.date()
|
||||
atteso = (adesso.hour + 1) if in_corso else 24
|
||||
atteso = (adesso.hour + (1 if adesso.minute >= MINUTO_GIRO + 1 else 0)) 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))
|
||||
@@ -591,16 +612,19 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
# 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)
|
||||
# (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
|
||||
# prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
|
||||
# data (>= giorno-7) include anche le ore del giorno-7 gia' trascorse, cioe' 8 giorni di
|
||||
# calendario, non 7.
|
||||
g7_cutoff = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() + "T23:59:59+00:00"
|
||||
base7 = next((r for r in reversed(eq) if r["ts_utc"] <= g7_cutoff), None)
|
||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"] > g7_cutoff)
|
||||
# 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 rilevante and base7 is not None:
|
||||
quota = (e1 - base7["equity"] - mv7) / tot
|
||||
if quota > 0.60:
|
||||
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
||||
"e' un evento.")
|
||||
@@ -609,10 +633,22 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
"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
|
||||
# (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
|
||||
# TWR scorpora): sulla serie grezza un prelievo si legge come drawdown di trading e un
|
||||
# versamento alza il picco, mascherando un DD vero appena prima del versamento.
|
||||
mv_ord = sorted(mv_ev, key=lambda e: e["ts_dopo"])
|
||||
cum_mov, i_mov, eq_netto = 0.0, 0, []
|
||||
for r in eq:
|
||||
while i_mov < len(mv_ord) and r["ts_utc"] >= mv_ord[i_mov]["ts_dopo"]:
|
||||
cum_mov += mv_ord[i_mov]["delta"]
|
||||
i_mov += 1
|
||||
eq_netto.append(r["equity"] - cum_mov)
|
||||
picco_netto = max(eq_netto)
|
||||
if picco_netto > 0:
|
||||
dd = (eq_netto[-1] - picco_netto) / picco_netto
|
||||
if dd < -0.005:
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). "
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco_netto:,.2f}, "
|
||||
"al netto dei movimenti di capitale certi). "
|
||||
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
|
||||
|
||||
# --- volatilita' implicita contro realizzata ---------------------------------------------
|
||||
|
||||
+45
-25
@@ -262,31 +262,43 @@ def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0.
|
||||
|
||||
|
||||
def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]:
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty."""
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty.
|
||||
|
||||
Lotti CON SEGNO (#32 C2): ogni lotto e' long (+1) o short (-1). Un fill chiude PRIMA i
|
||||
lotti di segno OPPOSTO (FIFO), e l'eccedenza — se resta qty dopo aver chiuso tutto il
|
||||
lato opposto — apre (o accoda) un lotto nel segno del fill stesso. Prima di questa
|
||||
riparazione un SELL senza lotti long da chiudere spariva in silenzio (nessun lotto,
|
||||
nessun round-trip): l'inversione long -> short in un solo fill e' lo stesso meccanismo,
|
||||
solo che il lato opposto si esaurisce a meta' fill invece che all'inizio.
|
||||
"""
|
||||
from collections import deque, defaultdict
|
||||
lotti: dict[str, deque] = defaultdict(deque)
|
||||
lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno]
|
||||
rts: list[dict] = []
|
||||
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
|
||||
fee_u = f.fee / f.qty if f.qty else 0.0
|
||||
if f.side == "buy":
|
||||
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
|
||||
else:
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and lotti[f.asset]:
|
||||
lq, lp, lts, lfee_u = lotti[f.asset][0]
|
||||
usa = min(resto, lq)
|
||||
lordo = usa * (f.price - lp)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
lotti[f.asset].popleft()
|
||||
else:
|
||||
lotti[f.asset][0][0] = lq
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
|
||||
segno_fill = 1 if f.side == "buy" else -1
|
||||
dq = lotti[f.asset]
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
|
||||
lq, lp, lts, lfee_u, lseg = dq[0]
|
||||
usa = min(resto, lq)
|
||||
# long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
|
||||
lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
|
||||
direzione=("long" if lseg == 1 else "short")))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
dq.popleft()
|
||||
else:
|
||||
dq[0][0] = lq
|
||||
if resto > 1e-12: # l'eccedenza apre/accoda nel segno del fill
|
||||
dq.append([resto, f.price, f.ts_utc, fee_u, segno_fill])
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t, direzione=("long" if s == 1 else "short"))
|
||||
for q, p, t, _, s in dq] for a, dq in lotti.items() if dq}
|
||||
return rts, aperti
|
||||
|
||||
|
||||
@@ -332,8 +344,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]])
|
||||
|
||||
|
||||
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
|
||||
|
||||
`qty > 0` (#32 C10): un fill "NON VERIFICATO" da `place_order error` puo' arrivare a
|
||||
qty 0 — non e' un trade, resta nel DB marcato (`verified=0`) ma fuori dal FIFO.
|
||||
"""
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
rts, _ = fifo_roundtrips(fills)
|
||||
@@ -358,7 +374,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None):
|
||||
|
||||
|
||||
def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
# qty > 0 (#32 C10): stessa esclusione di rebuild_roundtrips, un fill a qty 0 non e' posizione.
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
_, aperti = fifo_roundtrips(fills)
|
||||
@@ -366,7 +383,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
for a, lotti in aperti.items():
|
||||
q = sum(x["qty"] for x in lotti)
|
||||
costo = sum(x["qty"] * x["price"] for x in lotti)
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti))
|
||||
# un asset ha un solo segno di lotti aperti alla volta (fifo_roundtrips chiude prima
|
||||
# il lato opposto): la direzione del primo lotto vale per tutti (#32 C2).
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti),
|
||||
direzione=lotti[0]["direzione"])
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -210,6 +210,21 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
|
||||
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
|
||||
|
||||
|
||||
def test_secco_stampa_e_non_salva(db, monkeypatch, tmp_path, capsys):
|
||||
"""#34: prima del fix `J.salva()` veniva chiamato comunque, prima del controllo su
|
||||
`secco` — un giro "a secco" scriveva lo stesso le sezioni misurate nel DB."""
|
||||
import analista as CLI
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi che non deve finire nel DB.", None))
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
g = date(2026, 8, 21)
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB vuoto in partenza
|
||||
stato = CLI.scrivi(db, g, modello="finto", secco=True, verbose=False, telegram=False)
|
||||
assert stato == "ok"
|
||||
capsys.readouterr() # non e' l'oggetto del test
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB ANCORA vuoto
|
||||
assert not (tmp_path / f"{g}.md").exists() # scrivi_file non chiamato
|
||||
|
||||
|
||||
def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path):
|
||||
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
|
||||
import analista as CLI
|
||||
|
||||
@@ -31,12 +31,16 @@ def db(tmp_path):
|
||||
|
||||
def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch):
|
||||
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
|
||||
oggi = datetime.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
|
||||
feed_skh_min=0))
|
||||
class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
oggi = _OraFissa.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, oggi)
|
||||
assert s["giorno_in_corso"] is True
|
||||
assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1
|
||||
assert s["atteso_giri"] == 15 # ore 14:50, il giro delle 14 (:47) e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #27, #35 C9/C15/C7) — journal.py.
|
||||
|
||||
Non ripete la suite esistente: la tocca solo dove il comportamento CAMBIA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
|
||||
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
|
||||
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
|
||||
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
|
||||
giorno_in_corso=False, giri_mancanti=0))
|
||||
|
||||
|
||||
def _libro(**asset):
|
||||
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
|
||||
azione="HOLD (a target)"), **v) for k, v in asset.items()}
|
||||
lordo = sum(abs(v["posizione"]) for v in a.values())
|
||||
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
|
||||
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
|
||||
|
||||
|
||||
def _pnl(**kw):
|
||||
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
|
||||
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
|
||||
cumulato_da_arming=0.0, letture_equity=24)
|
||||
return {**base, **kw}
|
||||
|
||||
|
||||
def _ids(righe):
|
||||
return [r["id"] for r in righe]
|
||||
|
||||
|
||||
def _serie_equity(con, punti):
|
||||
for ts, eq in punti:
|
||||
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test"))
|
||||
con.commit()
|
||||
|
||||
|
||||
def _dichiara(monkeypatch, tmp_path, righe):
|
||||
f = tmp_path / "mov.jsonl"
|
||||
f.write_text("\n".join(json.dumps(r) for r in righe) + "\n")
|
||||
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
|
||||
return f
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #27 — due dichiarati nello stesso intervallo si SOMMANO, non si sovrascrivono
|
||||
# ============================================================================================
|
||||
|
||||
def test_due_dichiarati_nello_stesso_intervallo_si_sommano(db, monkeypatch, tmp_path):
|
||||
"""+25 e +30 dichiarati nello stesso intervallo, trading reale +5: prima del fix il
|
||||
secondo dichiarato sovrascriveva il primo (delta finale 30, non 55)."""
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 1000.0),
|
||||
("2026-08-25T08:07:01+00:00", 1060.0)]) # +60 = 25+30 mov. +5 trading
|
||||
_dichiara(monkeypatch, tmp_path, [
|
||||
dict(ts_utc="2026-08-25T07:20:00+00:00", delta_usd=25.0, nota="primo versamento"),
|
||||
dict(ts_utc="2026-08-25T07:40:00+00:00", delta_usd=30.0, nota="secondo versamento")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1 # UN evento, non due
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato" and e["delta"] == pytest.approx(55.0) # 25+30, non 30
|
||||
assert e["dopo"] == pytest.approx(1055.0)
|
||||
assert "primo versamento" in e["dichiarazione"] and "secondo versamento" in e["dichiarazione"]
|
||||
assert r["certi"] == pytest.approx(55.0)
|
||||
assert r["trading"] == pytest.approx(5.0) # 1060-1000-55
|
||||
# solo due letture: l'intero intervallo e' il movimento, nessun segmento di trading — 0%
|
||||
# e' il limite DICHIARATO di rendimento_twr ("nessun segmento = nessun tempo a mercato"),
|
||||
# non un valore inventato.
|
||||
assert r["twr"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_dichiarato_su_rilevato_resta_come_prima_anche_con_un_altro_dichiarato_altrove(db, monkeypatch, tmp_path):
|
||||
"""Non regressione: l'aggancio dichiarato+rilevato non deve rompersi perche' ora si guarda
|
||||
anche ad altri dichiarati."""
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: __import__("pandas").Series(
|
||||
100.0, index=__import__("pandas").date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")))
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
|
||||
("2026-08-25T11:47:01+00:00", 2066.88),
|
||||
("2026-08-25T12:47:01+00:00", 2064.56)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
|
||||
nota="bonifico")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C9 — il giro dell'ora corrente conta solo dopo che e' scattato (minuto :47 + margine)
|
||||
# ============================================================================================
|
||||
|
||||
def test_atteso_giri_al_minuto_10_non_conta_ancora_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 10, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=14, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 14 # il giro delle 14 (:47) non e' ancora scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
def test_atteso_giri_al_minuto_50_conta_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 15 # dopo le :47 il giro delle 14 e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C15 — "ultimi 7 giorni" deve coprire 7 giorni di calendario, non 8
|
||||
# ============================================================================================
|
||||
|
||||
def test_la_finestra_di_concentrazione_e_di_7_giorni_non_8(db):
|
||||
base = date(2026, 6, 24) # base .. base+13 = 14 giorni prima del giorno
|
||||
righe = []
|
||||
for i in range(7): # giorni 0..6: piatto
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1000.0), (f"{d}T18:00:00+00:00", 1000.0)]
|
||||
d7 = (base + timedelta(days=7)).isoformat() # giorno-7: il salto avviene QUI, a meta' giornata
|
||||
righe += [(f"{d7}T06:00:00+00:00", 1000.0), (f"{d7}T18:00:00+00:00", 1020.0)]
|
||||
for i in range(8, 14): # giorni 8..13: piatto al nuovo livello
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1020.0), (f"{d}T18:00:00+00:00", 1020.0)]
|
||||
d14 = (base + timedelta(days=14)).isoformat() # il giorno della voce
|
||||
righe += [(f"{d14}T06:00:00+00:00", 1020.0), (f"{d14}T18:00:00+00:00", 1100.0)]
|
||||
_serie_equity(db, righe)
|
||||
assert len(righe) == 30 # >24: la sezione si attiva
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[]), giorno=d14)
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "concentrazione")
|
||||
# baseline corretta = ULTIMA lettura di giorno-7 (1020, alle 18:00), non la PRIMA (1000,
|
||||
# alle 06:00): (1100-1020)/(1100-1000) = 80%. Con la finestra sbagliata (8 giorni di
|
||||
# calendario) sarebbe (1100-1000)/100 = 100%.
|
||||
assert "80%" in riga["testo"]
|
||||
assert "100%" not in riga["testo"]
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C7 — il drawdown si legge sulla serie AL NETTO dei movimenti certi
|
||||
# ============================================================================================
|
||||
|
||||
def test_un_prelievo_non_diventa_un_drawdown_di_trading(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
punti = [base + timedelta(hours=i) for i in range(26)] # 26 letture, >24
|
||||
for t in punti[:-1]:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), 1000.0, "t"))
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (punti[-1].isoformat(), 800.0, "t"))
|
||||
db.commit()
|
||||
mov = dict(classe="movimento", delta=-200.0, ts_prima=punti[-2].isoformat(),
|
||||
ts_dopo=punti[-1].isoformat(), prima=1000.0, dopo=800.0, pct=-0.2,
|
||||
fonte="dichiarato", dichiarazione="prelievo di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=punti[-1].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
# sulla serie grezza (picco 1000, ultima 800) sarebbe -20%: un prelievo, non una perdita.
|
||||
assert "drawdown" not in _ids(out)
|
||||
|
||||
|
||||
def test_un_versamento_non_nasconde_un_drawdown_di_trading_vero(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
letture = [(base + timedelta(hours=i), 1000.0) for i in range(20)] # piatto, 20 letture
|
||||
letture.append((base + timedelta(hours=20), 1500.0)) # versamento +500
|
||||
for h, v in ((21, 1490.0), (22, 1480.0), (23, 1475.0), (24, 1470.0)): # trading in calo
|
||||
letture.append((base + timedelta(hours=h), v))
|
||||
for t, val in letture:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), val, "t"))
|
||||
db.commit()
|
||||
assert len(letture) == 25 # >24
|
||||
mov = dict(classe="movimento", delta=500.0, ts_prima=letture[19][0].isoformat(),
|
||||
ts_dopo=letture[20][0].isoformat(), prima=1000.0, dopo=1500.0, pct=0.5,
|
||||
fonte="dichiarato", dichiarazione="versamento di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=letture[-1][0].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "drawdown")
|
||||
# netto: picco 1000 (prima/al versamento), ultima 970 (1470-500) -> -3.00%. Sulla serie
|
||||
# grezza (picco 1500 gonfiato dal versamento) sarebbe -2.00%: sottostima il vero calo.
|
||||
assert "-3.00%" in riga["testo"]
|
||||
assert "-2.00%" not in riga["testo"]
|
||||
@@ -116,7 +116,11 @@ def test_fifo_non_mescola_gli_asset():
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # la vendita ETH non chiude il long BTC
|
||||
assert set(aperti) == {"BTC"}
|
||||
# (#32 C2) una vendita ETH senza lotti long apre un SHORT — non sparisce piu' in silenzio:
|
||||
# e' comunque isolata dal long BTC, che e' quel che il test verifica.
|
||||
assert set(aperti) == {"BTC", "ETH"}
|
||||
assert aperti["BTC"][0]["direzione"] == "long"
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_fifo_riproduce_il_conto_del_libro_vero():
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #32 C2, C10) — tradesdb.py.
|
||||
|
||||
Non ripete la suite esistente (`test_tradesdb.py`): quella resta bit-exact sui casi
|
||||
long-aperto/long-chiuso (vedi il commit). Qui solo cio' che il fix aggiunge: lotti SHORT,
|
||||
inversione in un fill, e i fill a qty<=0 fuori dal FIFO.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def _f(ts, asset, side, qty, price, fee=0.0, verified=1):
|
||||
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee,
|
||||
verified=verified)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- short: apertura e chiusura
|
||||
|
||||
def test_una_vendita_senza_lotti_long_apre_un_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # niente da chiudere: solo apertura
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0)
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(100.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_un_acquisto_chiude_il_short_pnl_entry_meno_exit(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0), # apre short @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 90.0, fee=1.0)] # chiude @90
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
r = rts[0]
|
||||
assert r["direzione"] == "short"
|
||||
assert r["px_in"] == 100.0 and r["px_out"] == 90.0
|
||||
assert r["pnl_lordo"] == pytest.approx(10.0) # venduto alto, comprato basso: profitto
|
||||
assert r["fee_quota"] == pytest.approx(2.0)
|
||||
assert r["pnl_netto"] == pytest.approx(8.0)
|
||||
assert aperti == {} # flat: nessun lotto residuo
|
||||
|
||||
|
||||
def test_chiusura_parziale_di_uno_short_lascia_il_resto_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0),
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 0.25, 80.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1 and rts[0]["qty"] == pytest.approx(0.25)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(0.25 * (100.0 - 80.0))
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(0.75)
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- inversione in un solo fill
|
||||
|
||||
def test_inversione_long_a_short_in_un_fill_non_scarta_il_resto(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), # long 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "sell", 2.0, 150.0)] # chiude + apre short
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "long" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(50.0) # (150-100)*1
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) # il resto: 2.0-1.0
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(150.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short" # nessun resto scartato
|
||||
|
||||
|
||||
def test_inversione_short_a_long_in_un_fill(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), # short 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 3.0, 90.0)] # chiude + apre long
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "short" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(10.0) # (100-90)*1
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(2.0) # il resto: 3.0-1.0
|
||||
assert aperti["ETH"][0]["direzione"] == "long"
|
||||
|
||||
|
||||
def test_stato_aperto_riporta_la_direzione_short(db):
|
||||
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0)
|
||||
assert st["ETH"]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- #32 C10: fill a qty<=0
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_nel_fifo(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
|
||||
_f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)])
|
||||
assert T.rebuild_roundtrips(db) == 1 # solo il round-trip vero, non uno spurio
|
||||
rt = db.execute("SELECT * FROM roundtrips").fetchall()
|
||||
assert len(rt) == 1 and rt[0]["qty"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_in_stato_aperto_ma_resta_nel_db(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0) # il fill a qty 0 non conta come lotto
|
||||
# ma resta leggibile nel DB, marcato NON verificato — non e' sparito, e' escluso dal FIFO
|
||||
righe = db.execute("SELECT qty, verified FROM fills WHERE qty = 0").fetchall()
|
||||
assert len(righe) == 1 and righe[0]["verified"] == 0
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
|
||||
Reference in New Issue
Block a user