← Files italian-investorARCHIVED FILE

skills/italian-investor/scripts/portfolio_basis.py

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

↓ Download file

#!/usr/bin/env python3
"""Riconcilia il PMC del portfolio con il costo fiscale ricostruito dai lotti.

Il modulo NON decide quale base usare per uno specifico evento. Per una
liquidazione integrale dei tipi coperti, tuttavia, la somma del costo dei lotti
fornisce un controllo deterministico molto utile sul `pmc` dichiarato.

Input:
- posizioni già normalizzate da portfolio.py;
- indice lotti per `ISIN + broker` prodotto da portfolio_lots.py.

Non modifica le posizioni e non corregge automaticamente il portfolio.
"""

import argparse
import json
import sys

from lot_sale import TIPI_LOT_AWARE
from portfolio_lots import carica_lotti_portafoglio, indicizza
from portfolio_validator import valida_portafoglio

EPS_EUR = 0.01


def riconcilia_posizione(posizione, lotti):
    tipo = str(posizione.get("tipo") or "").strip().lower()
    if tipo not in TIPI_LOT_AWARE:
        return {
            "isin": posizione.get("isin"),
            "broker": posizione.get("broker"),
            "nome": posizione.get("nome"),
            "tipo": tipo,
            "coperta": False,
            "motivo": "tipo fuori dal lot engine automatico",
        }

    if not lotti:
        return {
            "isin": posizione.get("isin"),
            "broker": posizione.get("broker"),
            "nome": posizione.get("nome"),
            "tipo": tipo,
            "coperta": True,
            "riconciliabile": False,
            "motivo": "lotti fiscali mancanti",
        }

    quantita_lotti = sum(float(x["quantita"]) for x in lotti)
    costo_lotti = sum(float(x["costo_totale_eur"]) for x in lotti)
    quantita_port = float(posizione.get("quantita") or 0.0)
    costo_pmc = quantita_port * float(posizione.get("pmc") or 0.0)
    differenza = costo_lotti - costo_pmc
    pmc_lotti = costo_lotti / quantita_lotti if quantita_lotti else None
    tolleranza_q = max(1e-7, abs(quantita_port) * 1e-8)
    quantita_coerente = abs(quantita_lotti - quantita_port) <= tolleranza_q

    return {
        "isin": posizione.get("isin"),
        "broker": posizione.get("broker"),
        "nome": posizione.get("nome"),
        "tipo": tipo,
        "coperta": True,
        "riconciliabile": quantita_coerente,
        "quantita_portfolio": round(quantita_port, 8),
        "quantita_lotti": round(quantita_lotti, 8),
        "quantita_coerente": quantita_coerente,
        "pmc_csv": round(float(posizione.get("pmc") or 0.0), 8),
        "costo_da_pmc_eur": round(costo_pmc, 2),
        "pmc_fiscale_lotti_eur": round(pmc_lotti, 8) if pmc_lotti is not None else None,
        "costo_fiscale_lotti_eur": round(costo_lotti, 2),
        "differenza_costo_eur": round(differenza, 2),
        "coincide_al_centesimo": quantita_coerente and abs(differenza) <= EPS_EUR,
        "azione": (
            "nessuna" if quantita_coerente and abs(differenza) <= EPS_EUR
            else "verificare_pmc_e_base_fiscale"
        ),
    }


def riconcilia_portafoglio(posizioni, indice_lotti):
    righe = []
    costo_pmc_coperto = 0.0
    costo_lotti_coperto = 0.0
    differenze = []
    mancanti = []

    for p in posizioni:
        key = (str(p.get("isin") or "").strip().upper(), str(p.get("broker") or "").strip())
        esito = riconcilia_posizione(p, indice_lotti.get(key))
        righe.append(esito)
        if not esito.get("coperta"):
            continue
        if not esito.get("riconciliabile"):
            mancanti.append("%s / %s" % key)
            continue
        costo_pmc_coperto += float(esito["costo_da_pmc_eur"])
        costo_lotti_coperto += float(esito["costo_fiscale_lotti_eur"])
        if not esito["coincide_al_centesimo"]:
            differenze.append("%s / %s" % key)

    return {
        "posizioni": righe,
        "riepilogo": {
            "costo_pmc_posizioni_coperte_eur": round(costo_pmc_coperto, 2),
            "costo_fiscale_lotti_posizioni_coperte_eur": round(costo_lotti_coperto, 2),
            "differenza_eur": round(costo_lotti_coperto - costo_pmc_coperto, 2),
            "posizioni_con_differenza": differenze,
            "posizioni_coperte_non_riconciliabili": mancanti,
            "tutto_coerente": not differenze and not mancanti,
        },
        "avvertenze": [
            "La riconciliazione non sostituisce automaticamente il PMC del portfolio.",
            "Per ETF/OICR e tipi fuori dal lot engine la base fiscale resta da verificare con la disciplina specifica.",
            "Una differenza puo' dipendere da commissioni, trasferimenti, corporate action, valuta o dati del broker: va spiegata prima di correggere il portfolio.",
        ],
    }


def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("portfolio")
    p.add_argument("lotti")
    args = p.parse_args(argv)
    try:
        qualita = valida_portafoglio(args.portfolio)
        if not qualita.get("azionabile"):
            raise ValueError("portfolio non valido: %s" % "; ".join(qualita.get("errori", [])))
        # Import locale per evitare un ciclo a import-time: portfolio importa questo
        # helper solo nel flusso applicativo.
        from portfolio import leggi
        posizioni, _ = leggi(args.portfolio)
        indice = indicizza(carica_lotti_portafoglio(args.lotti))
        out = riconcilia_portafoglio(posizioni, indice)
    except (OSError, ValueError) as exc:
        out = {"errore": str(exc)}
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 1 if out.get("errore") else 0


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

SHA-256: 4ec9a8812d7a382151397a6a5c694352da5fd6b8540f000d271acc85777d81f4