Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Calculator from the book · C06

C06 — Tax friction, carried-forward losses and expired losses

Calculator 6 — Tax friction

Code language

The code, its comments and its outputs are in Italian: they are the book’s code, kept identical to what the reader runs.

Notebook for the chapter "The friction no backtest includes". Three numbers on your annual results: final capital paying every year, paying at the end, and losses that expire unused. This notebook is not tax advice. The tax rate and the carry-forward years are two fields to fill in with the ones that apply to you, verified at the source — the mechanism the notebook shows (tax paid early stops compounding) doesn't depend on those numbers. For reference, the Italian framework verified in August 2026 (recheck it, it changes): shares/bonds/ETFs/funds/derivatives 26%, Italian and white-list government bonds 12.5%, PIR investments held ≥5 years 0%, crypto-assets (gains realized from January 1, 2026) 33%. Watch the asymmetry of UCITS ETFs: gains are capital income, losses are "other income", and the two categories don't offset each other.

The lines marked TRY are the ones to change: edit them and rerun to see the effect. Everything else — including lines marked DO NOT CHANGE — exists to keep the result comparable with the one printed in the book.

Show the script for this step
calc_06_fisco.py
python
import matplotlib.pyplot as plt
import numpy as np

from cvbook.dati import carica
from cvbook.lingua import t
from cvbook.regole import esegui, rottura

ALIQUOTA = 0.26        # ← l'aliquota che ti riguarda (0.33 sulle cripto dal 2026)
                       # PROVA / TRY: 0,26 · 0,33 · quella in vigore quando leggi
ANNI_RIPORTO = 4       # ← per quanti anni si possono riportare le perdite
                       # PROVA / TRY: 0 (nessun riporto, vedi esercizio 3) · 4
CAPITALE = 100_000.0   # ← il capitale di partenza
                       # PROVA / TRY: il tuo capitale reale

1. It's not the rate, it's the timing

Two identical people, same return, same tax rate. The only difference: the first realizes and pays every year, the second lets it run and pays at the end.

Two side-by-side panels over a 30-year horizon. On the left two curves of net capital starting from 1,000 euros: paying tax only at the end ends above 12,000, paying it every year stays below, and the area between the two is shaded. On the right the extra capital obtained by deferring, for three gross annual rates: at 5% it stays under 20%, at 10% it reaches about 55%, at 20% it passes 175% over thirty years.

Two identical people on the same tax rate: one pays every year, the other at the end.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Constant-rate compounding with a 26% tax applied every year or once at maturity, and the difference between the two net capitals.

Output

aliquota usata: 26%

 orizzonte               5% lordo             10% lordo             20% lordo
        5a                  0.4%                 1.6%                 5.4%
       10a                  1.9%                 6.7%                21.8%
       20a                  7.5%                25.6%                81.1%
       30a                 16.3%                54.7%               179.9%

Il divario cresce con l'orizzonte e soprattutto con il RENDIMENTO. Chi ha rendimenti alti e orizzonti lunghi — cioe' esattamente la situazione che ogni strategia attiva promette — e' chi paga di piu' questo attrito.
Show the script for this step
calc_06_fisco.py
python
def confronto(rendimento: float, anni: int, aliquota: float = ALIQUOTA):
    annuale = (1 + rendimento * (1 - aliquota)) ** anni
    lordo = (1 + rendimento) ** anni
    differita = 1 + (lordo - 1) * (1 - aliquota)
    return annuale, differita


anni = np.arange(0, 31)
annuale = np.array([confronto(0.10, int(a))[0] for a in anni]) * 1000
differita = np.array([confronto(0.10, int(a))[1] for a in anni]) * 1000

with avvio.figura("schermo"):
    fig, (sx, dx) = plt.subplots(1, 2, figsize=(12, 4.5))
    sx.plot(anni, differita, linewidth=2, label=t("imposta alla fine", "tax at the end"))
    sx.plot(anni, annuale, linewidth=2, linestyle="--", label=t("imposta ogni anno", "tax every year"))
    sx.fill_between(anni, annuale, differita, alpha=0.2)
    sx.set_xlabel(t("Anni", "Years"))
    sx.set_ylabel(t("Capitale netto (euro, da 1.000)", "Net capital (euros, starting from 1,000)"))
    sx.legend()

    for r in (0.05, 0.10, 0.20):
        divario = [(confronto(r, int(a))[1] / confronto(r, int(a))[0] - 1) * 100
                   for a in anni[1:]]
        dx.plot(anni[1:], divario, linewidth=2, label=t(f"{r:.0%} lordo annuo", f"{r:.0%} gross a year"))
    dx.set_xlabel(t("Orizzonte (anni)", "Horizon (years)"))
    dx.set_ylabel(t("Capitale in piu' differendo (%)", "Extra capital by deferring (%)"))
    dx.legend()
    plt.show()

print(t(f"aliquota usata: {ALIQUOTA:.0%}\n", f"tax rate used: {ALIQUOTA:.0%}\n"))
print(f"{t('orizzonte', 'horizon'):>10s} " + "".join(
    f"{r:>16.0%}" + t(" lordo", " gross") for r in (0.05, 0.10, 0.20)))
for a in (5, 10, 20, 30):
    valori = "".join(f"{confronto(r, a)[1] / confronto(r, a)[0] - 1:21.1%}"
                     for r in (0.05, 0.10, 0.20))
    print(f"{a:9d}" + t("a", "y") + f" {valori}")

print(t("\nIl divario cresce con l'orizzonte e soprattutto con il RENDIMENTO. Chi "
        "ha rendimenti alti e orizzonti lunghi — cioe' esattamente la situazione "
        "che ogni strategia attiva promette — e' chi paga di piu' questo attrito.",
        "\nThe gap grows with the horizon and above all with the RETURN. Whoever "
        "has high returns and long horizons — exactly the situation every active "
        "strategy promises — pays the most for this friction."))

2. Taxable gain is not gain

Year by year, with carry-forward losses and their expiry.

Paired bars for each year from 2017 to 2026, in thousands of euros starting from 100,000: above zero the gross result of the year, below it the tax paid. 2020 is the tallest year, with a gross result above 1,000 thousand euros; 2018, 2022, 2025 and 2026 close in the red and pay nothing, and 2022 reaches about 750 below zero.

Year by year, the gross result and the tax that follows from it.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Annual result of the capital with a 26% tax on the taxable base, carried-forward losses and the expiry of the carry-forward applied year by year.

Output

  anno          lordo     imponibile      imposta       capitale
  2017        183,434        183,434       47,693        235,741
  2018        -90,871              0            0        144,870
  2019        161,681         70,811       18,411        288,141
  2020      1,134,546      1,134,546      294,982      1,127,705
  2021        455,166        455,166      118,343      1,464,528
  2022       -675,768              0            0        788,760
  2023        685,611          9,843        2,559      1,471,811
  2024      1,015,488      1,015,488      264,027      2,223,272
  2025       -209,113              0            0      2,014,159
  2026       -189,684              0            0      1,824,475

capitale finale pagando ogni anno:       1,824,475
capitale finale pagando alla fine:       2,664,982
differenza:                                  46.1%

imposte versate:                           746,015
guadagno lordo complessivo:              3,466,193
guadagno realizzato da chi ha pagato:    2,470,490
aliquota EFFETTIVA sul guadagno:             30.2%
perdite mai usate (scadute):               398,797

Guarda l'aliquota effettiva: e' PIU' ALTA di quella nominale, e non per un'aliquota diversa. E' l'effetto delle perdite scadute — anni in rosso che non hanno mai trovato un anno positivo entro il termine — sommato al fatto che l'imposta versata presto smette di comporre. Il danno non viene dall'aliquota: viene dal momento.
Show the script for this step
calc_06_fisco.py
python
def simula_imposta(rendimenti_annui, capitale=CAPITALE, aliquota=ALIQUOTA,
                   anni_riporto=ANNI_RIPORTO):
    """Imposta anno per anno, con riporto delle perdite e loro scadenza."""
    crediti: list[list[float]] = []
    righe, imposte_totali = [], 0.0

    for anno, r in rendimenti_annui:
        lordo = capitale * r
        if lordo >= 0:
            usato = 0.0
            for c in crediti:
                if anno - c[0] <= anni_riporto:
                    quota = min(c[1], lordo - usato)
                    c[1] -= quota
                    usato += quota
                    if usato >= lordo:
                        break
            imponibile = max(lordo - usato, 0.0)
            imposta = imponibile * aliquota
        else:
            crediti.append([anno, -lordo])
            imponibile, imposta = 0.0, 0.0

        crediti = [c for c in crediti if c[1] > 1e-9 and anno - c[0] < anni_riporto]
        imposte_totali += imposta
        capitale += lordo - imposta
        righe.append({"anno": anno, "lordo": lordo, "imponibile": imponibile,
                      "imposta": imposta, "capitale": capitale})

    return righe, imposte_totali, sum(c[1] for c in crediti)


# I risultati annuali di una regola meccanica su Bitcoin. Sostituiscili con i TUOI.
# Annual results of a mechanical rule on Bitcoin. Replace them with YOUR OWN.
# PROVA / TRY: sostituisci l'intero blocco RENDIMENTI_ANNUI qui sotto con le
# tue coppie (anno, rendimento) — vedi l'esercizio 1
df = carica("btcusdt").sort("data")
prezzi = df["chiusura"].to_numpy()
anni_serie = np.array([d.year for d in df["data"].to_list()])
curva = esegui(prezzi, rottura(prezzi, 20))["curva"]

# Il rendimento di un anno si misura dalla chiusura dell'anno PRECEDENTE, non
# dalla sua prima seduta. Partendo dalla prima seduta si perde il movimento del
# passaggio d'anno — dieci passaggi in dieci anni — e i rendimenti annuali non
# ricompongono piu' il risultato della regola: il prodotto usciva 33,66 dove la
# curva vale 35,66, e con esso tutte le cifre del capitolo (1.740.075 invece di
# 1.824.475). Con questa base il prodotto degli anni coincide con la curva.
# A year's return is measured from the PREVIOUS year's close, not from its own
# first session: otherwise the turn-of-year move is lost and the annual returns
# no longer multiply back to the rule's result.
RENDIMENTI_ANNUI = []
base_anno = float(curva[0])
for a in sorted(set(anni_serie.tolist())):
    ultimo = int(np.where(anni_serie == a)[0][-1])
    RENDIMENTI_ANNUI.append((int(a), float(curva[ultimo]) / base_anno - 1.0))
    base_anno = float(curva[ultimo])

righe, imposte, mai_usate = simula_imposta(RENDIMENTI_ANNUI)

lordo_composto = CAPITALE
for _, r in RENDIMENTI_ANNUI:
    lordo_composto *= 1 + r
differito = CAPITALE + (lordo_composto - CAPITALE) * (1 - ALIQUOTA)

with avvio.figura("schermo"):
    fig, ax = plt.subplots(figsize=(10, 4.5))
    x = np.arange(len(righe))
    ax.bar(x - 0.2, [r["lordo"] / 1000 for r in righe], width=0.4,
           label=t("risultato lordo", "gross result"))
    ax.bar(x + 0.2, [-r["imposta"] / 1000 for r in righe], width=0.4,
           label=t("imposta versata", "tax paid"))
    ax.axhline(0, linewidth=1, color="black")
    ax.set_xticks(x)
    ax.set_xticklabels([str(r["anno"]) for r in righe], rotation=45)
    ax.set_ylabel(t(f"Migliaia di euro (da {CAPITALE / 1000:.0f}.000 iniziali)",
                     f"Thousands of euros (starting from {CAPITALE / 1000:.0f},000)"))
    ax.legend()
    plt.show()

print(f"{t('anno', 'year'):>6s} {t('lordo', 'gross'):>14s} {t('imponibile', 'taxable'):>14s} "
      f"{t('imposta', 'tax'):>12s} {t('capitale', 'capital'):>14s}")
for r in righe:
    print(f"{r['anno']:6d} {r['lordo']:14,.0f} {r['imponibile']:14,.0f} "
          f"{r['imposta']:12,.0f} {r['capitale']:14,.0f}")

print(t(f"\ncapitale finale pagando ogni anno:  {righe[-1]['capitale']:14,.0f}",
        f"\nfinal capital paying every year:    {righe[-1]['capitale']:14,.0f}"))
print(t(f"capitale finale pagando alla fine:  {differito:14,.0f}",
        f"final capital paying at the end:    {differito:14,.0f}"))
print(t(f"differenza:                         {differito / righe[-1]['capitale'] - 1:14.1%}",
        f"difference:                         {differito / righe[-1]['capitale'] - 1:14.1%}"))
print(t(f"\nimposte versate:                    {imposte:14,.0f}",
        f"\ntax paid:                           {imposte:14,.0f}"))
# ALIQUOTA EFFETTIVA: numeratore e denominatore devono venire dallo STESSO
# percorso. Dividere le imposte del percorso TASSATO per il guadagno del
# percorso LORDO significa mettere insieme due capitali diversi — chi paga ogni
# anno, da li' in poi, capitalizza su meno — e il rapporto usciva 21,7%, cioe'
# SOTTO l'aliquota nominale. Il guadagno lordo di chi paga lungo la strada e'
# quello che gli resta piu' quello che ha versato: su quello si misura quanta
# parte se n'e' andata, e viene il 30,2%, cioe' SOPRA la nominale.
# Numerator and denominator must come from the SAME path: the gross gain of
# whoever pays along the way is what they keep plus what they paid.
guadagno_realizzato = righe[-1]["capitale"] - CAPITALE + imposte

print(t(f"guadagno lordo complessivo:         {lordo_composto - CAPITALE:14,.0f}",
        f"total gross gain:                   {lordo_composto - CAPITALE:14,.0f}"))
print(t(f"guadagno realizzato da chi ha pagato:{guadagno_realizzato:13,.0f}",
        f"gain realized by whoever paid:      {guadagno_realizzato:14,.0f}"))
print(t(f"aliquota EFFETTIVA sul guadagno:    {imposte / guadagno_realizzato:14.1%}",
        f"EFFECTIVE rate on the gain:         {imposte / guadagno_realizzato:14.1%}"))
print(t(f"perdite mai usate (scadute):        {mai_usate:14,.0f}",
        f"losses never used (expired):        {mai_usate:14,.0f}"))

print(t("\nGuarda l'aliquota effettiva: e' PIU' ALTA di quella nominale, e non "
        "per un'aliquota diversa. E' l'effetto delle perdite scadute — anni in "
        "rosso che non hanno mai trovato un anno positivo entro il termine — "
        "sommato al fatto che l'imposta versata presto smette di comporre. "
        "Il danno non viene dall'aliquota: viene dal momento.",
        "\nLook at the effective rate: it is HIGHER than the nominal one, and "
        "not because of a different rate. It is the effect of expired losses — "
        "red years that never met a positive year within the window — plus the "
        "fact that tax paid early stops compounding. The damage doesn't come "
        "from the rate: it comes from the timing."))

3. The most useful exercise: shuffle the order

Same sequence of annual results, different order. The overall gross result doesn't change — multiplication is commutative — but the tax does.

Histogram of the tax paid across 2,000 different orderings of the same annual results, with the horizontal axis in thousands of euros from about 500 to 1,800 and the count reaching a little past 400. The mass sits between 550 and 800; a black vertical line marks the ordering that actually happened, at 710 thousand, while the minimum is 535 and the maximum 1,785.

The same annual results in a different order: the tax changes, the gross gain does not.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Two thousand permutations of the sequence of annual results, each run through the same tax rule; the black line is the ordering that actually happened.

Output

imposte versate nell'ordine reale:      746,015
mescolando l'ordine — minimo:           563,378
                      mediana:          635,853
                      massimo:        1,872,862

capitale finale: da 1,474,935 a 1,939,241

Stesso guadagno lordo. Il fisco non tassa il tuo guadagno: tassa il modo in cui e' arrivato.
Show the script for this step
calc_06_fisco.py
python
rng = np.random.default_rng(20260816)
# NON TOCCARE / DO NOT CHANGE: il seme fissa quali dei 2.000 rimescolamenti
# vengono disegnati; l'istogramma e il minimo/mediana/massimo citati nel testo
# restano quelli. Cambiare il seme non falsa la dimostrazione (qualunque
# rimescolamento mostra lo stesso fenomeno), ma sposta i numeri esatti stampati.
# The seed fixes which of the 2,000 shuffles get drawn; the histogram and the
# min/median/max quoted in the text stay those. Changing the seed doesn't
# invalidate the demonstration (any shuffle shows the same phenomenon), but
# it shifts the exact printed numbers.
valori = [r for _, r in RENDIMENTI_ANNUI]
anni_etichette = [a for a, _ in RENDIMENTI_ANNUI]

imposte_mescolate, finali_mescolati = [], []
for _ in range(2000):
    ordine = rng.permutation(len(valori))
    sequenza = [(anni_etichette[k], valori[ordine[k]]) for k in range(len(valori))]
    rr, ii, _ = simula_imposta(sequenza)
    imposte_mescolate.append(ii)
    finali_mescolati.append(rr[-1]["capitale"])

imposte_mescolate = np.array(imposte_mescolate)
finali_mescolati = np.array(finali_mescolati)

with avvio.figura("schermo"):
    fig, ax = plt.subplots()
    ax.hist(imposte_mescolate / 1000, bins=60)
    ax.axvline(imposte / 1000, linewidth=2.5, color="black")
    ax.set_xlabel(t("Imposte versate (migliaia di euro)", "Tax paid (thousands of euros)"))
    ax.set_ylabel(t("Su 2.000 ordini diversi", "Out of 2,000 different orderings"))
    plt.show()

print(t(f"imposte versate nell'ordine reale: {imposte:12,.0f}",
        f"tax paid in the real order:        {imposte:12,.0f}"))
print(t(f"mescolando l'ordine — minimo:      {imposte_mescolate.min():12,.0f}",
        f"shuffling the order — minimum:     {imposte_mescolate.min():12,.0f}"))
print(t(f"                      mediana:     {np.median(imposte_mescolate):12,.0f}",
        f"                      median:      {np.median(imposte_mescolate):12,.0f}"))
print(t(f"                      massimo:     {imposte_mescolate.max():12,.0f}",
        f"                      maximum:     {imposte_mescolate.max():12,.0f}"))
print(t(f"\ncapitale finale: da {finali_mescolati.min():,.0f} a {finali_mescolati.max():,.0f}",
        f"\nfinal capital: from {finali_mescolati.min():,.0f} to {finali_mescolati.max():,.0f}"))
print(t("\nStesso guadagno lordo. Il fisco non tassa il tuo guadagno: tassa il modo "
        "in cui e' arrivato.",
        "\nSame gross gain. The taxman doesn't tax your gain: it taxes how it "
        "arrived."))

Exercises

  1. Replace RENDIMENTI_ANNUI with your own annual results — pairs of (year, return) suffice. It's the calculation no backtesting software does.
  2. Change ALIQUOTA and ANNI_RIPORTO to whatever is in force when you read this. The conclusions change in degree, not in sign.
  3. Set ANNI_RIPORTO = 0 — i.e. no carry-forward — and see how much worse it gets for a highly oscillating strategy. It's the tax cost of the volatility of the annual result, which is a different thing from market volatility and doesn't appear in any standard metric.

Reproducibility & downloads

Run on 2026-08-27 from the repository notebook

The notebook

  • calc_06_fisco.ipynb21.4 KB

    sha256 8eba3264b73332f4430935d516c6ccd7c930f443965a618c1135bd1b726541e6

  • calc_06_fisco.py17.2 KB

    sha256 cc85f8229130c75727bbbaf5863622f460edf50f9360b9d10426583bc2ef4f12

The data

  • btcusdt.parquet93.2 KB

    sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329

    Source: Binance Data Vision · Period: 2017-08-17 → 2026-06-30 · 3,240 rows · extracted 2026-08-16

Back to the lab index