← Files italian-investorARCHIVED FILE

skills/italian-investor/tests/run_extended_tests.py

12.4 KB · Oct 5, 2026 · 18:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Test estesi per eventi fiscali, lot-aware sale e qualita' dati."""

import os
import sys
import tempfile

BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE, "scripts"))

from cost_basis import carica_lotti  # noqa: E402
from event_tax import simula_provento  # noqa: E402
from lot_sale import TIPI_LOT_AWARE, simula_vendita_lotti  # noqa: E402
from portfolio import analizza, leggi, ribilancia  # noqa: E402
from portfolio_basis import riconcilia_portafoglio  # noqa: E402
from portfolio_lots import (  # noqa: E402
    carica_lotti_portafoglio,
    indicizza,
    valida_copertura,
)
from portfolio_validator import valida_portafoglio  # noqa: E402


def near(value, expected, tol=0.01):
    return value is not None and abs(float(value) - float(expected)) <= tol


def test_eventi():
    errori = []

    az = simula_provento("azione", "dividendo", 100)
    if not near(az.get("imposta_stimata"), 26):
        errori.append("dividendo azione: imposta attesa 26, ottenuta %r" % az.get("imposta_stimata"))
    if az.get("categoria_reddito") != "reddito_di_capitale":
        errori.append("dividendo azione: categoria errata")

    italia = simula_provento("azione", "dividendo", 100, paese_fonte="IT")
    if not near(italia.get("imposta_stimata"), 26):
        errori.append("paese_fonte=IT non deve attivare il ramo estero")

    btp = simula_provento("titolo_stato", "cedola", 100)
    if not near(btp.get("imposta_stimata"), 12.5):
        errori.append("cedola titolo Stato: imposta attesa 12.5")

    etf = simula_provento("etf", "distribuzione", 100, quota_stato=0.5)
    if not near(etf.get("imposta_stimata"), 19.25):
        errori.append("distribuzione ETF 50%% Stato: atteso 19.25, ottenuto %r" % etf.get("imposta_stimata"))

    etf_unknown = simula_provento("etf", "distribuzione", 100)
    sc = etf_unknown.get("imposta_scenario") or {}
    if (etf_unknown.get("imposta_stimata") is not None or
            not near(sc.get("quota_agevolata_0"), 26) or
            not near(sc.get("quota_agevolata_100"), 12.5)):
        errori.append("ETF senza quota_stato deve restituire scenario 12.5-26")

    foreign = simula_provento(
        "azione", "dividendo", 100, ritenuta_estera=15, paese_fonte="US"
    )
    if (foreign.get("imposta_stimata") is not None or
            foreign.get("dato_mancante") != "trattamento_doppia_imposizione_estera"):
        errori.append("dividendo con ritenuta estera deve fare hard-stop")

    foreign_no_withholding = simula_provento("azione", "dividendo", 100, paese_fonte="US")
    if (foreign_no_withholding.get("imposta_stimata") is not None or
            foreign_no_withholding.get("dato_mancante") != "trattamento_doppia_imposizione_estera"):
        errori.append("fonte estera deve fare hard-stop anche se la ritenuta non e' stata indicata")

    cert = simula_provento("certificate", "cedola", 100)
    if "errore" not in cert:
        errori.append("provento certificate deve richiedere verifica prodotto")

    return errori


def test_lotti():
    errori = []
    lotti = carica_lotti(os.path.join(BASE, "examples", "lotti-esempio.csv"))

    amm = simula_vendita_lotti("azione", "amministrato", lotti, 140, 15)
    if amm.get("metodo_base") != "cmp":
        errori.append("amministrato deve scegliere CMP nei casi coperti")
    if not near(amm.get("base_fiscale", {}).get("base_costo_vendita_eur"), 1359):
        errori.append("base CMP attesa 1359")
    if not near(amm.get("imposta_stimata"), 192.66):
        errori.append("imposta vendita CMP attesa 192.66, ottenuta %r" % amm.get("imposta_stimata"))
    residui_amm = amm.get("lotti_residui") or []
    if not near(sum(x["quantita"] for x in residui_amm), 85, 0.000001):
        errori.append("CMP deve lasciare 85 unita residue")
    if not near(sum(x["costo_totale_eur"] for x in residui_amm), 7701, 0.001):
        errori.append("CMP deve preservare il costo residuo 7701")

    dic = simula_vendita_lotti("azione", "dichiarativo", lotti, 140, 15)
    if dic.get("metodo_base") != "lifo":
        errori.append("dichiarativo deve scegliere LIFO nei casi coperti")
    if not near(dic.get("base_fiscale", {}).get("base_costo_vendita_eur"), 1651.5):
        errori.append("base LIFO attesa 1651.5")
    if not near(dic.get("imposta_stimata"), 116.61):
        errori.append("imposta vendita LIFO attesa 116.61, ottenuta %r" % dic.get("imposta_stimata"))
    residui_dic = dic.get("lotti_residui") or []
    if not near(sum(x["quantita"] for x in residui_dic), 85, 0.000001):
        errori.append("LIFO deve lasciare 85 unita residue")
    seconda = simula_vendita_lotti("azione", "dichiarativo", residui_dic, 140, 10)
    if not near(seconda.get("base_fiscale", {}).get("base_costo_vendita_eur"), 1026):
        errori.append("vendita LIFO sequenziale: base attesa 1026")

    etf = simula_vendita_lotti("etf", "dichiarativo", lotti, 140, 15)
    if "errore" not in etf or etf.get("imposta_stimata") is not None:
        errori.append("ETF lot-aware automatico deve fare hard-stop")

    return errori


def test_riconciliazione_base():
    errori = []
    posizioni, _ = leggi(os.path.join(BASE, "examples", "portafoglio-esempio.csv"))
    indice = indicizza(carica_lotti_portafoglio(
        os.path.join(BASE, "examples", "lotti-portafoglio-esempio.csv")
    ))
    out = riconcilia_portafoglio(posizioni, indice)
    riepilogo = out.get("riepilogo", {})
    if not near(riepilogo.get("differenza_eur"), 34):
        errori.append("differenza costo lotti-PMC attesa 34 EUR, ottenuta %r" % riepilogo.get("differenza_eur"))
    if riepilogo.get("tutto_coerente"):
        errori.append("fixture deve segnalare differenze fra PMC e lotti")
    if len(riepilogo.get("posizioni_con_differenza", [])) != 3:
        errori.append("attese tre posizioni lot-aware con differenza")
    apple = [
        x for x in out.get("posizioni", [])
        if x.get("isin") == "US0378331005"
    ]
    if not apple or not near(apple[0].get("differenza_costo_eur"), 4):
        errori.append("Apple: differenza costo attesa 4 EUR")
    etf = [
        x for x in out.get("posizioni", [])
        if x.get("tipo") == "etf"
    ]
    if not etf or any(x.get("coperta") for x in etf):
        errori.append("ETF devono restare fuori dalla riconciliazione lot engine")
    return errori


def test_registry_lotti_e_ribilanciamento():
    errori = []
    portfolio_path = None
    lotti_path = None
    try:
        portfolio = (
            "isin,nome,tipo,quantita,pmc,prezzo,asset_class,valuta_esposizione,valuta_quotazione,area,settore,broker,quota_stato\n"
            "US0378331005,Apple,azione,40,145,212,azionario,USD,USD,usa,tecnologia,BrokerA,0\n"
            "CASH-EUR,Liquidita,liquidita,1,1520,1520,liquidita,EUR,EUR,italia,cash,BrokerA,0\n"
        )
        lotti_txt = (
            "isin,broker,data_acquisto,quantita,costo_unitario_eur,costi_acquisto_eur\n"
            "US0378331005,BrokerA,2024-01-10,20,130,2\n"
            "US0378331005,BrokerA,2026-06-10,20,160,2\n"
        )
        with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, encoding="utf-8") as f:
            f.write(portfolio)
            portfolio_path = f.name
        with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, encoding="utf-8") as f:
            f.write(lotti_txt)
            lotti_path = f.name

        posizioni, _ = leggi(portfolio_path)
        idx = indicizza(carica_lotti_portafoglio(lotti_path))
        copertura = valida_copertura(posizioni, idx, TIPI_LOT_AWARE)
        if not copertura.get("azionabile"):
            errori.append("copertura lotti valida rifiutata: %r" % copertura.get("errori"))

        target = {"azionario": 0.5, "liquidita": 0.5}
        legacy = ribilancia(posizioni, target, regime="dichiarativo")
        lot_aware = ribilancia(posizioni, target, regime="dichiarativo", lotti_posizioni=idx)
        if lot_aware.get("base_fiscale_modalita") != "lotti_per_isin_broker":
            errori.append("ribilanciamento non segnala modalita lot-aware")
        det = lot_aware.get("strategie", [{}])[0].get("dettagli_vendite", [])
        if not det or det[0].get("metodo_base") != "lifo":
            errori.append("strategia A dichiarativa deve usare LIFO sulla vendita Apple")
        if not det or det[0].get("modalita_base") != "lot_aware":
            errori.append("dettaglio vendita deve indicare base lot-aware")
        if (lot_aware["strategie"][0]["imposta_stimata"] >=
                legacy["strategie"][0]["imposta_stimata"]):
            errori.append("con lotto recente a costo 160 la simulazione LIFO dovrebbe tassare meno del PMC 145")
        residui = lot_aware["strategie"][0].get("lotti_posizioni_dopo", [])
        apple = [x for x in residui if x.get("isin") == "US0378331005"]
        if not apple or not (0 < apple[0]["quantita"] < 40):
            errori.append("la strategia deve riportare la quantita Apple residua")

        idx_bad = dict(idx)
        key = ("US0378331005", "BrokerA")
        idx_bad[key] = idx_bad[key][:-1]
        bad = valida_copertura(posizioni, idx_bad, TIPI_LOT_AWARE)
        if bad.get("azionabile"):
            errori.append("mismatch quantita portfolio/lotti deve essere bloccante")
    finally:
        for path in (portfolio_path, lotti_path):
            if path and os.path.exists(path):
                os.unlink(path)
    return errori


def test_validator_e_concentrazione():
    errori = []
    base = valida_portafoglio(os.path.join(BASE, "examples", "portafoglio-esempio.csv"))
    if not base.get("azionabile"):
        errori.append("portafoglio esempio dovrebbe essere strutturalmente valido: %r" % base.get("errori"))

    contenuto_dup = (
        "isin,nome,tipo,quantita,pmc,prezzo,asset_class,valuta_esposizione,broker,quota_stato\n"
        "US0378331005,Apple A,azione,10,100,120,azionario,USD,BrokerA,0\n"
        "US0378331005,Apple B,azione,5,105,120,azionario,USD,BrokerA,0\n"
    )
    path = None
    try:
        with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, encoding="utf-8") as f:
            f.write(contenuto_dup)
            path = f.name
        dup = valida_portafoglio(path)
        if dup.get("azionabile"):
            errori.append("duplicato stesso ISIN/broker deve essere bloccante")
        if not any("falsare HHI" in x for x in dup.get("errori", [])):
            errori.append("duplicato deve spiegare l'impatto sulla concentrazione")
    finally:
        if path and os.path.exists(path):
            os.unlink(path)

    contenuto_multi = (
        "isin,nome,tipo,quantita,pmc,prezzo,asset_class,valuta_esposizione,broker,quota_stato\n"
        "US0378331005,Apple A,azione,5,100,100,azionario,USD,BrokerA,0\n"
        "US0378331005,Apple B,azione,5,100,100,azionario,USD,BrokerB,0\n"
        "US5949181045,Microsoft,azione,10,50,50,azionario,USD,BrokerA,0\n"
    )
    path = None
    try:
        with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, encoding="utf-8") as f:
            f.write(contenuto_multi)
            path = f.name
        check = valida_portafoglio(path)
        if not check.get("azionabile"):
            errori.append("stesso ISIN su broker diversi non deve essere bloccante")
        posizioni, lacune = leggi(path)
        out = analizza(posizioni, lacune)
        if not near(out.get("concentrazione", {}).get("hhi"), 0.5556, 0.0001):
            errori.append("HHI deve aggregare Apple per ISIN: ottenuto %r" % out.get("concentrazione", {}).get("hhi"))
        if out.get("strumenti_unici") != 2:
            errori.append("strumenti_unici atteso 2")
    finally:
        if path and os.path.exists(path):
            os.unlink(path)

    return errori


def main():
    gruppi = (
        ("eventi", test_eventi),
        ("lotti", test_lotti),
        ("riconciliazione_base", test_riconciliazione_base),
        ("registry_lotti_ribilanciamento", test_registry_lotti_e_ribilanciamento),
        ("validator_concentrazione", test_validator_e_concentrazione),
    )
    falliti = 0
    for nome, fn in gruppi:
        errori = fn()
        if errori:
            falliti += 1
            print("FAIL  %s" % nome)
            for e in errori:
                print("      %s" % e)
        else:
            print("ok    %s" % nome)
    print("\n%d gruppi falliti" % falliti)
    return 1 if falliti else 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: 240be337d2a65dad975db75b1a86c6a14bb75e79255942837140434b00ef87ab