← Files italian-investorARCHIVED FILE

skills/italian-investor/scripts/tax_engine.py

16.3 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Motore fiscale deterministico per strumenti finanziari, residente in Italia.

Non contiene giudizi: classifica lo strumento, applica l'aritmetica e dichiara
esplicitamente cosa resta da verificare. Nessuna dipendenza esterna.

    python scripts/tax_engine.py classifica --tipo etf
    python scripts/tax_engine.py vendita --tipo etf --pmc 90 --prezzo 120 \
        --quantita 100 --minus 2000
"""

import argparse
import json
import sys

# Aliquota e frazione imponibile agevolata: valori di riferimento, NON fonte.
# Vanno riverificati (references/regole-correnti.md) prima dell'uso.
ALIQUOTA_ORDINARIA = 0.26
FRAZIONE_IMPONIBILE_AGEVOLATA = 0.4808  # 26% * 0.4808 = 12,5% effettivo

CAPITALE = "reddito_di_capitale"
DIVERSO = "reddito_diverso"

# categoria_plus: natura del provento positivo
# categoria_minus: natura della differenza negativa
# agevolato: la componente beneficia dell'imposizione ridotta
CATEGORIE = {
    "etf": dict(categoria_plus=CAPITALE, categoria_minus=DIVERSO, agevolato=False,
                nota="OICR armonizzato: plus reddito di capitale, minus reddito diverso"),
    "oicr": dict(categoria_plus=CAPITALE, categoria_minus=DIVERSO, agevolato=False,
                 nota="Fondo comune / OICR armonizzato"),
    "azione": dict(categoria_plus=DIVERSO, categoria_minus=DIVERSO, agevolato=False,
                   nota="Partecipazione non qualificata"),
    "obbligazione": dict(categoria_plus=DIVERSO, categoria_minus=DIVERSO, agevolato=False,
                         nota="Obbligazione corporate: capital gain reddito diverso"),
    "titolo_stato": dict(categoria_plus=DIVERSO, categoria_minus=DIVERSO, agevolato=True,
                         nota="Titolo di Stato IT / White List / ente assimilato"),
    "certificate": dict(categoria_plus=DIVERSO, categoria_minus=DIVERSO, agevolato=False,
                        nota="Certificate: reddito diverso"),
    "liquidita": dict(categoria_plus=CAPITALE, categoria_minus=None, agevolato=False,
                      nota="Interessi: reddito di capitale, nessuna minusvalenza"),
}

# Tipi per cui il motore NON calcola: il regime dipende da elementi da accertare.
DA_ACCERTARE = {
    "etc_etn": "ETC/ETN: il trattamento ETF non e' applicabile automaticamente. "
               "Verificare la sezione 'Taxation in Italy' del prospetto del singolo "
               "strumento prima di classificarne i proventi.",
    "etf_non_armonizzato": "OICR non armonizzato: il regime puo differire e concorrere "
                           "al reddito complessivo. Verificare caso per caso.",
    "cripto": "Regime delle cripto-attivita modificato piu volte: verificare l'anno "
              "d'imposta prima di qualunque calcolo.",
    "fondo_pensione": "Previdenza complementare: regime autonomo, fuori dallo schema "
                      "redditi di capitale / redditi diversi.",
    "pir": "PIR: possibile esenzione subordinata a requisiti di durata e composizione.",
}

FONTI_DA_CITARE = [
    "TUIR art. 44 (redditi di capitale) - DPR 917/1986, Normattiva: "
    "https://www.normattiva.it/uri-res/N2Ls?urn:nir:stato:"
    "decreto.del.presidente.della.repubblica:1986-12-22;917",
    "TUIR art. 67-68 (redditi diversi, minusvalenze) - DPR 917/1986, Normattiva",
    "DL 66/2014 art. 3 c. 5 (redditi diversi da titoli pubblici computati al 48,08%) - "
    "Normattiva: https://www.normattiva.it/uri-res/N2Ls?urn:nir:stato:"
    "decreto.legge:2014-04-24;66~art3",
    "Agenzia delle Entrate, Circolare 19/E del 27/06/2014 (OICR: proventi e perdite "
    "riferibili a titoli pubblici computati al netto del 51,92%): "
    "https://def.finanze.it/DocTribFrontend/getPrassiDetail.do?id="
    "%7B7953D773-A884-4630-A7EB-EF5187839207%7D",
    "Istruzioni ai modelli dichiarativi / prassi Agenzia delle Entrate: "
    "https://www.agenziaentrate.gov.it/portale/",
    "Borsa Italiana, ETC/ETN - Valori Ufficiali: per la fiscalita' rimanda alla "
    "sezione 'Taxation in Italy' dei Supplementi ai Prospetti di emissione: "
    "https://www.borsaitaliana.it/etc-etn/statisticheetc/valoriufficialicopy/"
    "valoriufficialicopy.htm",
    "Vigenza: dal 01/01/2027 si applica il nuovo testo unico D.Lgs. 117/2026 e la "
    "numerazione degli articoli cambia. Verificare il periodo d'imposta del caso.",
]


def classifica(tipo):
    tipo = tipo.lower().strip()
    if tipo in DA_ACCERTARE:
        return {
            "tipo": tipo,
            "calcolabile": False,
            "motivo": DA_ACCERTARE[tipo],
            "verificare": [DA_ACCERTARE[tipo]],
            "fonti": FONTI_DA_CITARE,
        }
    if tipo not in CATEGORIE:
        return {
            "tipo": tipo,
            "calcolabile": False,
            "motivo": "Tipo non riconosciuto. Risalire da ISIN alla natura giuridica "
                      "prima di procedere.",
            "tipi_noti": sorted(CATEGORIE) + sorted(DA_ACCERTARE),
            "verificare": ["Natura giuridica dello strumento non determinata"],
            "fonti": FONTI_DA_CITARE,
        }
    c = CATEGORIE[tipo]
    return {
        "tipo": tipo,
        "calcolabile": True,
        "categoria_plusvalenza": c["categoria_plus"],
        "categoria_minusvalenza": c["categoria_minus"],
        "plus_compensabile_con_zainetto": c["categoria_plus"] == DIVERSO,
        "minus_alimenta_zainetto": c["categoria_minus"] == DIVERSO,
        "componente_agevolata": c["agevolato"],
        "nota": c["nota"],
        "fonti": FONTI_DA_CITARE,
    }


def simula_vendita(tipo, pmc, prezzo, quantita, minus_disponibili=0.0,
                   quota_stato=None, costi=0.0):
    """Simula la vendita di una posizione.

    Importi in euro. `quota_stato` e' la quota agevolata di un OICR (0..1):
    se None il dato e' mancante e per gli OICR viene restituito uno scenario
    min/max invece di un singolo importo.

    Due meccanismi distinti, da non confondere:

    - Titoli pubblici agevolati: il REDDITO DIVERSO e' computato nella misura
      del 48,08% dell'ammontare realizzato (art. 3 c. 5 DL 66/2014, che
      modifica gli artt. 5, 6 e 7 del D.Lgs. 461/1997). La riduzione precede
      quindi la compensazione con lo zainetto, e vale anche per le perdite.
    - OICR/ETF con componente in titoli pubblici: proventi e perdite riferibili
      alla componente pubblica sono computati al 48,08%; sulla parte restante
      si applica il regime ordinario.
    """
    info = classifica(tipo)
    if not info["calcolabile"]:
        return {"errore": info["motivo"], "verificare": info["verificare"],
                "fonti": info["fonti"], "imposta_stimata": None}

    if quota_stato is not None:
        try:
            quota_stato = float(quota_stato)
        except (TypeError, ValueError):
            return {
                "errore": "quota_stato deve essere un numero compreso tra 0 e 1.",
                "verificare": ["Quota agevolata non valida"],
                "fonti": info["fonti"],
                "imposta_stimata": None,
            }
        if not 0.0 <= quota_stato <= 1.0:
            return {
                "errore": "quota_stato deve essere compresa tra 0 e 1, non %.4g." % quota_stato,
                "verificare": ["Quota agevolata fuori intervallo: non viene corretta automaticamente"],
                "fonti": info["fonti"],
                "imposta_stimata": None,
            }

    verificare = []
    costi = max(0.0, float(costi))
    controvalore = prezzo * quantita
    costo = pmc * quantita
    risultato = controvalore - costo - costi

    out = {
        "tipo": tipo,
        "controvalore": r(controvalore),
        "costo_carico": r(costo),
        "risultato_lordo": r(risultato),
        "categoria_reddito": (info["categoria_plusvalenza"] if risultato > 0
                              else info["categoria_minusvalenza"]),
        "verificare": verificare,
        "fonti": info["fonti"],
    }

    minus_disponibili = max(0.0, float(minus_disponibili))
    e_oicr = tipo in ("etf", "oicr")

    if risultato <= 0:
        perdita = -risultato
        if not info["minus_alimenta_zainetto"]:
            minus_generate = 0.0
            verificare.append("La differenza negativa su questo strumento potrebbe non "
                              "essere deducibile: verificare.")
        elif info["componente_agevolata"]:
            # La perdita e' un reddito diverso, computato al 48,08%.
            minus_generate = perdita * FRAZIONE_IMPONIBILE_AGEVOLATA
            verificare.append("Perdita su titoli pubblici agevolati: computata nella "
                              "misura del 48,08%% dell'ammontare realizzato (%.2f su "
                              "%.2f), art. 3 c. 5 DL 66/2014."
                              % (minus_generate, perdita))
        elif e_oicr:
            if quota_stato is None:
                minus_max = perdita
                minus_min = perdita * FRAZIONE_IMPONIBILE_AGEVOLATA
                out.update({
                    "imposta_stimata": 0.0,
                    "minusvalenza_generata": None,
                    "zainetto_dopo": None,
                    "dato_mancante": "quota_stato",
                    "minusvalenza_scenario": {
                        "quota_agevolata_0": r(minus_max),
                        "quota_agevolata_100": r(minus_min),
                    },
                })
                verificare.append(
                    "Quota di titoli di Stato/White List del fondo NON FORNITA: la "
                    "minusvalenza deducibile non e' puntualmente calcolabile. La "
                    "Circolare 19/E del 27/06/2014 riduce del 51,92% la parte della "
                    "perdita riferibile ai titoli pubblici."
                )
                verificare.append("Minusvalenze utilizzabili entro il 4o anno successivo a quello "
                                  "di realizzo: verificare il termine vigente.")
                return out
            quota_ordinaria = 1.0 - quota_stato
            minus_generate = perdita * (
                quota_ordinaria + quota_stato * FRAZIONE_IMPONIBILE_AGEVOLATA
            )
            verificare.append(
                "Perdita OICR: la parte riferibile a titoli pubblici (%.2f%%) e' "
                "computata al 48,08%%; minus deducibile %.2f su perdita %.2f. "
                "Circolare Agenzia Entrate 19/E del 27/06/2014."
                % (quota_stato * 100, minus_generate, perdita)
            )
        else:
            minus_generate = perdita

        out.update({
            "imposta_stimata": 0.0,
            "minusvalenza_generata": r(minus_generate),
            "zainetto_dopo": r(minus_disponibili + minus_generate),
        })
        verificare.append("Minusvalenze utilizzabili entro il 4o anno successivo a quello "
                          "di realizzo: verificare il termine vigente.")
        return out

    compensabile = info["plus_compensabile_con_zainetto"]

    if info["componente_agevolata"]:
        # Il reddito diverso e' ridotto PRIMA della compensazione.
        rilevante = risultato * FRAZIONE_IMPONIBILE_AGEVOLATA
        minus_usate = min(minus_disponibili, rilevante)
        imponibile = rilevante - minus_usate
        imposta = imponibile * ALIQUOTA_ORDINARIA
        out["reddito_diverso_rilevante"] = r(rilevante)
        out["quota_agevolata_applicata"] = 1.0
        verificare.append("Reddito diverso computato al 48,08%% dell'ammontare realizzato "
                          "(%.2f su %.2f) e solo dopo compensato con lo zainetto: "
                          "art. 3 c. 5 DL 66/2014." % (rilevante, risultato))
    else:
        minus_usate = min(minus_disponibili, risultato) if compensabile else 0.0
        imponibile = risultato - minus_usate
        if e_oicr and quota_stato is None:
            # Dato mancante: nessun importo singolo, solo lo scenario.
            imposta_max = imponibile * ALIQUOTA_ORDINARIA
            imposta_min = imponibile * FRAZIONE_IMPONIBILE_AGEVOLATA * ALIQUOTA_ORDINARIA
            out.update({
                "minusvalenze_disponibili": r(minus_disponibili),
                "minusvalenze_utilizzate": r(minus_usate),
                "zainetto_residuo": r(minus_disponibili - minus_usate),
                "imponibile": r(imponibile),
                "quota_agevolata_applicata": None,
                "dato_mancante": "quota_stato",
                "imposta_stimata": None,
                "imposta_scenario": {"quota_agevolata_0": r(imposta_max),
                                     "quota_agevolata_100": r(imposta_min)},
            })
            verificare.append("Quota di titoli di Stato/White List del fondo NON FORNITA: "
                              "nessun importo singolo calcolabile. Riportare l'intervallo "
                              "%.2f - %.2f e recuperare la percentuale comunicata "
                              "dall'emittente o dall'intermediario."
                              % (imposta_min, imposta_max))
            if not compensabile and minus_disponibili > 0:
                verificare.append("Plusvalenza qualificata come reddito di capitale: NON "
                                  "abbattuta dalle minusvalenze in zainetto.")
            verificare.append("Aliquota e frazione imponibile sono valori di riferimento "
                              "non verificati (references/regole-correnti.md).")
            return out

        quota_agev = quota_stato or 0.0
        base_agevolata = imponibile * quota_agev
        base_ordinaria = imponibile - base_agevolata
        imposta = (base_ordinaria * ALIQUOTA_ORDINARIA
                   + base_agevolata * FRAZIONE_IMPONIBILE_AGEVOLATA * ALIQUOTA_ORDINARIA)
        out["quota_agevolata_applicata"] = quota_agev
        if e_oicr and quota_agev > 0.0:
            verificare.append("Quota agevolata assunta pari a %.2f%%: confermarla sulla "
                              "comunicazione dell'emittente/intermediario."
                              % (quota_agev * 100))

    if not compensabile and minus_disponibili > 0:
        verificare.append("Plusvalenza qualificata come reddito di capitale: NON abbattuta "
                          "dalle minusvalenze in zainetto. Verificare la qualificazione "
                          "dello strumento prima di considerare definitivo il risultato.")
    verificare.append("Aliquota e frazione imponibile sono valori di riferimento non "
                      "verificati (references/regole-correnti.md).")

    out.update({
        "minusvalenze_disponibili": r(minus_disponibili),
        "minusvalenze_utilizzate": r(minus_usate),
        "zainetto_residuo": r(minus_disponibili - minus_usate),
        "imponibile": r(imponibile),
        "imposta_stimata": r(imposta),
        "netto_incassato": r(controvalore - imposta - costi),
        "aliquota_effettiva_su_risultato": r(imposta / risultato),
    })
    return out


def r(x):
    return round(float(x), 2)


def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    sub = p.add_subparsers(dest="cmd", required=True)

    c = sub.add_parser("classifica", help="natura fiscale di un tipo di strumento")
    c.add_argument("--tipo", required=True)

    v = sub.add_parser("vendita", help="simula la vendita di una posizione")
    v.add_argument("--tipo", required=True)
    v.add_argument("--pmc", type=float, required=True, help="prezzo medio di carico")
    v.add_argument("--prezzo", type=float, required=True, help="prezzo di vendita")
    v.add_argument("--quantita", type=float, required=True)
    v.add_argument("--minus", type=float, default=0.0, help="minusvalenze in zainetto")
    v.add_argument("--quota-stato", type=float, default=None,
                   help="quota agevolata del fondo, da 0 a 1; se omessa per un OICR "
                        "il risultato e' uno scenario min/max")
    v.add_argument("--costi", type=float, default=0.0, help="commissioni di negoziazione")

    a = p.parse_args(argv)
    if a.cmd == "classifica":
        res = classifica(a.tipo)
    else:
        res = simula_vendita(a.tipo, a.pmc, a.prezzo, a.quantita,
                             a.minus, a.quota_stato, a.costi)
    print(json.dumps(res, indent=2, ensure_ascii=False))
    return 1 if "errore" in res else 0


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

SHA-256: 34a44e2ae93e9279ab7e255b26104803f023d3e7d213bd5fd0fc91ea15853bc6