← Files italian-investorARCHIVED FILE
skills/italian-investor/scripts/portfolio_validator.py
7.18 KB · Oct 5, 2026 · 18:32 UTC
#!/usr/bin/env python3
"""Valida un CSV portfolio prima di analisi fiscali o di rischio.
Il validatore non corregge i dati: segnala errori bloccanti e warning. In
particolare intercetta duplicati che falserebbero metriche di concentrazione,
valori numerici impossibili, unita' sospette sulle obbligazioni e dati fiscali
mancanti.
python scripts/portfolio_validator.py valida examples/portafoglio-esempio.csv
"""
import argparse
import csv
import json
import math
import re
import sys
from collections import defaultdict
from instrument_resolver import TIPI_NOTI, normalizza_isin, valida_isin
RICHIESTE = {"isin", "nome", "tipo", "quantita", "pmc", "prezzo", "asset_class"}
VALUTA_RE = re.compile(r"^[A-Z]{3}$")
def _numero(row, campo, n_riga, errori, minimo=None, strettamente=False):
raw = str(row.get(campo) or "").strip()
try:
val = float(raw)
except ValueError:
errori.append("riga %d: %s non numerico" % (n_riga, campo))
return None
if not math.isfinite(val):
errori.append("riga %d: %s deve essere finito" % (n_riga, campo))
return None
if minimo is not None:
if strettamente and val <= minimo:
errori.append("riga %d: %s deve essere > %s" % (n_riga, campo, minimo))
elif not strettamente and val < minimo:
errori.append("riga %d: %s deve essere >= %s" % (n_riga, campo, minimo))
return val
def valida_portafoglio(path):
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
colonne = set(reader.fieldnames or [])
righe = list(reader)
errori = []
warning = []
mancanti = []
if not righe:
return {"azionabile": False, "errori": ["CSV vuoto"], "warning": [], "dati_mancanti": []}
missing_cols = sorted(RICHIESTE - colonne)
if missing_cols:
return {
"azionabile": False,
"errori": ["colonne mancanti: %s" % ", ".join(missing_cols)],
"warning": [],
"dati_mancanti": [],
}
per_isin = defaultdict(list)
per_isin_broker = defaultdict(list)
tipi_isin = defaultdict(set)
for idx, row in enumerate(righe, start=2):
isin = normalizza_isin(row.get("isin"))
tipo = str(row.get("tipo") or "").strip().lower()
nome = str(row.get("nome") or "").strip()
broker = str(row.get("broker") or "").strip()
asset_class = str(row.get("asset_class") or "").strip().lower()
if not nome:
errori.append("riga %d: nome mancante" % idx)
if not tipo:
errori.append("riga %d: tipo mancante" % idx)
elif tipo not in TIPI_NOTI:
errori.append("riga %d: tipo non riconosciuto: %s" % (idx, tipo))
if not asset_class:
errori.append("riga %d: asset_class mancante" % idx)
if isin.startswith("CASH-"):
if tipo != "liquidita":
errori.append("riga %d: identificatore CASH-* richiede tipo=liquidita" % idx)
elif not valida_isin(isin):
errori.append("riga %d: ISIN formalmente non valido: %s" % (idx, isin))
quantita = _numero(row, "quantita", idx, errori, 0.0, strettamente=True)
pmc = _numero(row, "pmc", idx, errori, 0.0)
prezzo = _numero(row, "prezzo", idx, errori, 0.0)
quota_raw = str(row.get("quota_stato") or "").strip()
if quota_raw:
try:
quota = float(quota_raw)
if not math.isfinite(quota) or not 0.0 <= quota <= 1.0:
errori.append("riga %d: quota_stato deve essere tra 0 e 1" % idx)
except ValueError:
errori.append("riga %d: quota_stato non numerica" % idx)
elif tipo in ("etf", "oicr"):
mancanti.append("riga %d %s: quota_stato non indicata" % (idx, nome or isin))
valuta = str(row.get("valuta_esposizione") or "").strip().upper()
if not valuta:
mancanti.append("riga %d %s: valuta_esposizione non indicata" % (idx, nome or isin))
elif not VALUTA_RE.match(valuta):
warning.append("riga %d: valuta_esposizione sospetta: %s" % (idx, valuta))
if not broker:
mancanti.append("riga %d %s: broker non indicato" % (idx, nome or isin))
if tipo in ("obbligazione", "titolo_stato") and prezzo is not None and pmc is not None:
if prezzo > 2.0 or pmc > 2.0:
warning.append(
"riga %d %s: pmc/prezzo obbligazionario sembrano espressi in corso percentuale; "
"la convenzione del portfolio richiede frazione (101,30 -> 1.0130)" % (idx, nome or isin)
)
if tipo == "liquidita" and asset_class != "liquidita":
warning.append("riga %d %s: tipo=liquidita ma asset_class=%s" % (idx, nome or isin, asset_class))
per_isin[isin].append(idx)
per_isin_broker[(isin, broker or "<mancante>")].append(idx)
tipi_isin[isin].add(tipo)
for isin, tipi in tipi_isin.items():
if len(tipi) > 1:
errori.append("ISIN %s ha tipi incoerenti: %s" % (isin, ", ".join(sorted(tipi))))
for (isin, broker), linee in sorted(per_isin_broker.items()):
if len(linee) > 1:
errori.append(
"ISIN %s duplicato sul broker %s alle righe %s: portfolio.py lo tratterebbe "
"come posizioni separate e potrebbe falsare HHI/pesi. Consolidare la posizione "
"e conservare i lotti in un CSV lotti separato." %
(isin, broker, ",".join(map(str, linee)))
)
for isin, linee in sorted(per_isin.items()):
if len(linee) > 1:
brokers = {str(righe[n - 2].get("broker") or "").strip() for n in linee}
if len(brokers) > 1:
warning.append(
"ISIN %s presente su piu' broker (righe %s): e' corretto mantenerli separati "
"fiscalmente, ma per la concentrazione economica va aggregato per ISIN." %
(isin, ",".join(map(str, linee)))
)
warning.append(
"Il campo pmc non e' automaticamente una base fiscale verificata: per vendite parziali usare i lotti e il criterio applicabile al regime."
)
return {
"azionabile": not errori,
"righe": len(righe),
"errori": errori,
"warning": warning,
"dati_mancanti": mancanti,
"qualita": {
"errori": len(errori),
"warning": len(warning),
"dati_mancanti": len(mancanti),
},
}
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = p.add_subparsers(dest="cmd", required=True)
v = sub.add_parser("valida")
v.add_argument("csv")
args = p.parse_args(argv)
try:
out = valida_portafoglio(args.csv)
except OSError as exc:
out = {"azionabile": False, "errori": [str(exc)], "warning": [], "dati_mancanti": []}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0 if out.get("azionabile") else 1
if __name__ == "__main__":
sys.exit(main())
SHA-256: 2bc567b3200e0abb6631de032b63de6b41c2dcae465b238bb8474eeb8d0a87ea