#!/usr/bin/env python3 # ============================================================================= # Le prix des voitures : téléchargement des données et calculs. # # cd ~/Dropbox/pouvoir-achat/prix-des-voitures # python3 prix_des_voitures.py # télécharge puis calcule # python3 prix_des_voitures.py --hors-ligne # calcule à partir de data/ # # Sources téléchargées (dans data/) : # - Insee, BDM : IPC base 2025, indices et pondérations (ensemble, achats de # véhicules, automobiles, automobiles neuves, automobiles d'occasion) ; # - Eurostat : IPCH ECOICOP ver. 2, indices mensuels (prc_hicp_minr, base # 2025 = 100) et pondérations (prc_hicp_iw) ; # - FRED (Bureau of Labor Statistics) : CPI-U des États-Unis, ensemble, # véhicules neufs, voitures et camionnettes d'occasion. # Données relevées à la main (dans data/, avec leurs sources) : # prix_catalogue_moyen.csv, prix_entree_gamme.csv, prix_occasion_marche.csv. # # Résultats : prix-des-voitures-*.csv et resultats/. # Dépendances : pandas, numpy. # ============================================================================= import io import json import re import sys import urllib.request from pathlib import Path import numpy as np import pandas as pd ICI = Path(__file__).resolve().parent DATA = ICI / "data" RES = ICI / "resultats" DATA.mkdir(exist_ok=True) RES.mkdir(exist_ok=True) PAYS = ["FR", "DE", "IT", "ES", "NL", "BE", "AT", "EA20"] VOISINS = ["DE", "IT", "ES", "NL"] # grands pays de la zone euro comparés à la France POSTES = ["CP00", "CP071", "CP0711", "CP07111", "CP07112"] DERNIERE_ANNEE = 2025 # dernière année complète def lire_url(url, timeout=180): req = urllib.request.Request(url, headers={"User-Agent": "prix-des-voitures/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() # ---- Insee : BDM (SDMX) ------------------------------------------------------- def insee_ipc(): """IPC base 2025 : indices annuels et mensuels, pondérations annuelles.""" lignes = [] requetes = [ ("A", "00+071+0711+07111+07112", "INDICE"), ("M", "00+071+0711+07111+07112", "INDICE"), ("A", "00+071+0711+07111+07112", "POND"), ] for freq, coicop, nature in requetes: url = (f"https://bdm.insee.fr/series/sdmx/data/IPC-2025/" f"{freq}...{coicop}..{nature}.ENSEMBLE.FE....") xml = lire_url(url).decode("utf-8") for m in re.finditer(r"]*)>(.*?)", xml, re.S): a = dict(re.findall(r'(\S+?)="([^"]*)"', m.group(1))) titre = a.get("TITLE_FR", "") if "imputés" in titre or a.get("CORRECTION", "BRUT") != "BRUT": continue for per, val in re.findall(r'TIME_PERIOD="([^"]+)" OBS_VALUE="([^"]+)"', m.group(2)): if val.lower() == "nan": continue lignes.append(dict(freq=freq, nature=nature, coicop=a["COICOP2018"], periode=per, valeur=float(val), idbank=a["IDBANK"], titre=titre)) df = pd.DataFrame(lignes).sort_values(["freq", "nature", "coicop", "periode"]) df.to_csv(DATA / "insee_ipc.csv", index=False) return df # ---- Eurostat (JSON-stat) ----------------------------------------------------- def eurostat(dataset, **params): q = "&".join(f"{k}={v}" for k, vs in params.items() for v in (vs if isinstance(vs, list) else [vs])) url = f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{dataset}?format=JSON&lang=en&{q}" d = json.loads(lire_url(url)) dims, tailles = d["id"], d["size"] cats = [sorted(d["dimension"][k]["category"]["index"].items(), key=lambda x: x[1]) for k in dims] lignes = [] for cle, val in d["value"].items(): i, pos = int(cle), [] for t in reversed(tailles): pos.append(i % t) i //= t pos = pos[::-1] lignes.append({dims[j]: cats[j][pos[j]][0] for j in range(len(dims))} | {"valeur": val}) return pd.DataFrame(lignes) def eurostat_ipch(): # dans l'ECOICOP ver. 2, l'ensemble de l'indice est codé TOTAL (CP00 auparavant) ind = eurostat("prc_hicp_minr", unit="I25", coicop18=["TOTAL"] + POSTES[1:], geo=PAYS) ind = ind.rename(columns={"coicop18": "coicop", "time": "mois"})[["geo", "coicop", "mois", "valeur"]] ind["coicop"] = ind.coicop.replace({"TOTAL": "CP00"}) ind.sort_values(["geo", "coicop", "mois"]).to_csv(DATA / "eurostat_ipch_mensuel.csv", index=False) pw = eurostat("prc_hicp_iw", coicop18=POSTES[1:], geo=PAYS) pw = pw.rename(columns={"coicop18": "coicop", "time": "annee"})[["geo", "coicop", "annee", "valeur"]] pw.sort_values(["geo", "coicop", "annee"]).to_csv(DATA / "eurostat_ipch_poids.csv", index=False) return ind, pw # ---- États-Unis : FRED --------------------------------------------------------- def fred_cpi(): series = {"CPIAUCNS": "ensemble", "CUUR0000SETA01": "vehicules_neufs", "CUUR0000SETA02": "voitures_occasion"} out = [] for s, nom in series.items(): df = pd.read_csv(io.BytesIO(lire_url(f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={s}"))) df.columns = ["date", "valeur"] df["serie"], df["code"] = nom, s out.append(df[pd.to_numeric(df["valeur"], errors="coerce").notna()]) df = pd.concat(out) df["valeur"] = df["valeur"].astype(float) df["mois"] = df["date"].str[:7] df = df[df["mois"] >= "1996-01"][["serie", "code", "mois", "valeur"]] df.to_csv(DATA / "fred_cpi_etats_unis.csv", index=False) return df # ---- Outils ------------------------------------------------------------------- def cumul(serie, debut, fin): """Hausse cumulée en % entre deux années (ou mois).""" try: a, b = serie.loc[debut], serie.loc[fin] except KeyError: return np.nan return 100 * (b / a - 1) if pd.notna(a) and pd.notna(b) else np.nan def par_an(c, debut, fin): n = int(str(fin)[:4]) - int(str(debut)[:4]) return 100 * ((1 + c / 100) ** (1 / n) - 1) if pd.notna(c) and n > 0 else np.nan def prix_moyen_chaine(pm): """Série L'argus jusqu'en 2019, prolongée par IMT/C-Ways (raccord en 2019), interpolée en log pour les années manquantes. Euros courants, niveau L'argus.""" arg = pm[pm.serie == "L'argus"].set_index("annee")["prix_eur"] cw = pm[pm.serie == "IMT/C-Ways"].set_index("annee")["prix_eur"] raccord = arg.loc[2019] / cw.loc[2019] s = pd.concat([arg, (cw * raccord).loc[cw.index > 2019]]) annees = range(int(s.index.min()), int(s.index.max()) + 1) s = np.exp(np.log(s).reindex(annees).interpolate()) return s, raccord # ---- Calculs ------------------------------------------------------------------ def calculs(insee, ind, pw, us): param = {} # 1. France, IPC Insee (base 2025), moyennes annuelles ia = insee[(insee.freq == "A") & (insee.nature == "INDICE")].copy() ia["annee"] = ia.periode.astype(int) fr = ia.pivot_table(index="annee", columns="coicop", values="valeur") fr.columns = [{"00": "ipc_ensemble", "071": "ipc_achat_vehicules", "0711": "ipc_automobiles", "07111": "ipc_automobiles_neuves", "07112": "ipc_automobiles_occasion"}[c] for c in fr.columns] po = insee[(insee.freq == "A") & (insee.nature == "POND")].copy() po["annee"] = po.periode.astype(int) po = po.pivot_table(index="annee", columns="coicop", values="valeur") po = po.rename(columns={"071": "pond_ipc_achat_vehicules", "0711": "pond_ipc_automobiles", "07111": "pond_ipc_automobiles_neuves", "07112": "pond_ipc_automobiles_occasion"}) po = po[[c for c in po.columns if c.startswith("pond")]] pm = pd.read_csv(DATA / "prix_catalogue_moyen.csv") pm_ch, raccord = prix_moyen_chaine(pm) param["raccord_largus_cways_2019"] = raccord france = fr.join(po, how="left") france["prix_catalogue_moyen_eur"] = pm_ch.reindex(france.index) france["prix_catalogue_moyen_interpole"] = ~france.index.isin(pm.annee.unique()) & france.prix_catalogue_moyen_eur.notna() france = france.loc[france.index <= DERNIERE_ANNEE] france.index.name = "annee" france.reset_index().to_csv(ICI / "prix-des-voitures-france.csv", index=False, float_format="%.4f") # tableau des hausses cumulées, France s = france lignes = [] for d in [1996, 2001, 2006, 2010, 2019]: f = DERNIERE_ANNEE lignes.append(dict( debut=d, fin=f, ipc_ensemble=cumul(s.ipc_ensemble, d, f), ipc_automobiles_neuves=cumul(s.ipc_automobiles_neuves, d, f), ipc_automobiles_occasion=cumul(s.ipc_automobiles_occasion, d, f), ipc_automobiles=cumul(s.ipc_automobiles, d, f), prix_catalogue_moyen=cumul(s.prix_catalogue_moyen_eur, d, f) if d >= 2006 else np.nan)) tab_fr = pd.DataFrame(lignes) tab_fr.to_csv(RES / "tableau_france.csv", index=False, float_format="%.2f") # prix publiés aux deux extrémités (sans interpolation) p = pm.set_index(["serie", "annee"])["prix_eur"] param["prix_largus_2010"] = p.loc[("L'argus", 2010)] param["prix_cways_2019"] = p.loc[("IMT/C-Ways", 2019)] param["prix_cways_2024"] = p.loc[("IMT/C-Ways", 2024)] param["prix_cways_2025"] = p.loc[("IMT/C-Ways", 2025)] # hausses entre les prix publiés (L'argus au départ, IMT/C-Ways à l'arrivée, sans raccord) param["hausse_prix_moyen_2010_2025_pct"] = 100 * (p.loc[("IMT/C-Ways", 2025)] / p.loc[("L'argus", 2010)] - 1) param["hausse_prix_moyen_2006_2025_pct"] = 100 * (p.loc[("IMT/C-Ways", 2025)] / p.loc[("L'argus", 2006)] - 1) param["hausse_prix_moyen_2019_2025_pct"] = 100 * (p.loc[("IMT/C-Ways", 2025)] / p.loc[("IMT/C-Ways", 2019)] - 1) # entrée de gamme, déflatée par l'IPC eg = pd.read_csv(DATA / "prix_entree_gamme.csv") ipc_m = insee[(insee.freq == "M") & (insee.nature == "INDICE") & (insee.coicop == "00")].set_index("periode")["valeur"] ipc_a = s.ipc_ensemble.copy() ipc_a.loc[2026] = ipc_m[ipc_m.index.str.startswith("2026")].mean() # moyenne des mois disponibles eg["ipc"] = eg.annee.map(ipc_a) eg["prix_eur_2025"] = eg.prix_eur * 100 / eg.ipc eg.to_csv(RES / "entree_de_gamme.csv", index=False, float_format="%.1f") # 2. IPCH, moyennes annuelles par pays (Eurostat, base 2025) ind = ind.copy() ind["annee"] = ind.mois.str[:4].astype(int) n = ind.groupby(["geo", "coicop", "annee"]).valeur.agg(["mean", "count"]).reset_index() an = n[n["count"] == 12].rename(columns={"mean": "indice"}).drop(columns="count") an = an[an.annee <= DERNIERE_ANNEE] pw = pw.copy() pw["annee"] = pw.annee.astype(int) an = an.merge(pw.rename(columns={"valeur": "poids_pour_mille"}), on=["geo", "coicop", "annee"], how="left") an.to_csv(ICI / "prix-des-voitures-pays.csv", index=False, float_format="%.4f") A = an.pivot_table(index="annee", columns=["geo", "coicop"], values="indice") W = an.pivot_table(index="annee", columns=["geo", "coicop"], values="poids_pour_mille") lignes = [] for d, f in [(1996, 2025), (2010, 2025), (2015, 2025), (2019, 2025), (2020, 2023)]: for g in PAYS: row = dict(geo=g, debut=d, fin=f) for c in POSTES: row[c] = cumul(A[(g, c)], d, f) if (g, c) in A else np.nan row["CP0711_relatif"] = 100 * ((1 + row["CP0711"] / 100) / (1 + row["CP00"] / 100) - 1) lignes.append(row) # États-Unis (CPI-U, moyennes annuelles) us = us.copy() us["annee"] = us.mois.str[:4].astype(int) U = us.groupby(["serie", "annee"]).valeur.mean().unstack(0) for d, f in [(1996, 2025), (2010, 2025), (2015, 2025), (2019, 2025), (2020, 2023)]: lignes.append(dict(geo="US", debut=d, fin=f, CP00=cumul(U.ensemble, d, f), CP07111=cumul(U.vehicules_neufs, d, f), CP07112=cumul(U.voitures_occasion, d, f))) tab_pays = pd.DataFrame(lignes) tab_pays.to_csv(RES / "tableau_pays.csv", index=False, float_format="%.2f") # poids (pour mille du panier de l'IPCH) poids = an[an.coicop.isin(["CP0711", "CP07111", "CP07112"])].pivot_table( index=["geo", "annee"], columns="coicop", values="poids_pour_mille").reset_index() poids["part_occasion_pct"] = 100 * poids.CP07112 / poids.CP0711 poids.to_csv(RES / "poids.csv", index=False, float_format="%.2f") # 3. Occasion, données mensuelles : IPCH, CPI américain, base 2019 = 100 occ = ind[ind.coicop == "CP07112"].pivot_table(index="mois", columns="geo", values="valeur") occ["US"] = us[us.serie == "voitures_occasion"].set_index("mois").valeur occ = occ.loc[occ.index >= "2015-01"] base = occ.loc[occ.index.str.startswith("2019")].mean() occ = 100 * occ / base occ.index.name = "mois" occ.reset_index().to_csv(ICI / "prix-des-voitures-occasion-mensuel.csv", index=False, float_format="%.3f") m = ind.pivot_table(index="mois", columns=["geo", "coicop"], values="valeur") r = [] for g in PAYS: if (g, "CP07112") in m: r.append(dict(source=f"IPCH {g}", variation_pct=cumul(m[(g, "CP07112")], "2020-01", "2022-12"))) us_occ = us[us.serie == "voitures_occasion"].set_index("mois").valeur r.append(dict(source="CPI États-Unis", variation_pct=cumul(us_occ, "2020-01", "2022-12"))) r.append(dict(source="La Centrale (prix médian)", variation_pct=43.7)) pd.DataFrame(r).to_csv(RES / "occasion_2020_2022.csv", index=False, float_format="%.2f") insee_occ = insee[(insee.freq == "M") & (insee.nature == "INDICE") & (insee.coicop == "07112")].set_index("periode").valeur param["ipc_occasion_jan2020_dec2022_pct"] = cumul(insee_occ, "2020-01", "2022-12") param["ipc_occasion_dec2020_dec2021_pct"] = cumul(insee_occ, "2020-12", "2021-12") param["ipc_occasion_t42021_t42022_pct"] = 100 * (insee_occ.loc["2022-10":"2022-12"].mean() / insee_occ.loc["2021-10":"2021-12"].mean() - 1) param["ipc_occasion_2023_2024_pct"] = cumul(s.ipc_automobiles_occasion, 2023, 2024) param["ipc_occasion_2024_2025_pct"] = cumul(s.ipc_automobiles_occasion, 2024, 2025) # 4. Indice alternatif des voitures (IPCH, France, annuel) g = np.log(A).diff() # croissance logarithmique annuelle w = W.shift(0) # poids de l'année t (dépenses t-1 ou t-2) gfr = {c: g[("FR", c)] for c in POSTES} wN, wU, wA = w[("FR", "CP07111")], w[("FR", "CP07112")], w[("FR", "CP0711")] var = pd.DataFrame(index=g.index) var["publie"] = gfr["CP0711"] # V1 : l'occasion évolue comme les voitures neuves var["occasion_comme_neuves"] = gfr["CP07111"] # V2 : l'occasion évolue comme dans la zone euro, relativement à l'ensemble (depuis 2018) gU_ea = g[("EA20", "CP07112")] - g[("EA20", "CP00")] + gfr["CP00"] gU2 = gU_ea.where(gU_ea.notna(), gfr["CP07112"]) var["occasion_comme_zone_euro"] = (wN * gfr["CP07111"] + wU * gU2) / (wN + wU) # V3 : les voitures évoluent comme chez les grands voisins, relativement à l'ensemble rel = pd.concat([g[(k, "CP0711")] - g[(k, "CP00")] for k in VOISINS], axis=1).mean(axis=1) var["comme_voisins"] = rel + gfr["CP00"] # V4 : borne haute, prix catalogue moyen (neuves) et occasion au même rythme (depuis 2007) gpm = np.log(pm_ch).diff().reindex(var.index) var["prix_catalogue_moyen"] = gpm.where(gpm.notna(), np.nan) # V0 recalé : on garde le publié avant 1997 var = var.loc[(var.index >= 1997) & (var.index <= DERNIERE_ANNEE)] noms = {"publie": "IPCH publié", "occasion_comme_neuves": "Occasion au rythme des voitures neuves", "occasion_comme_zone_euro": "Occasion au rythme de la zone euro", "comme_voisins": "Voitures au rythme de l'Allemagne, de l'Italie, de l'Espagne et des Pays-Bas", "prix_catalogue_moyen": "Prix catalogue moyen (borne haute)"} # niveaux (1996 = 100) de l'indice des voitures, et IPCH d'ensemble alternatif gP = gfr["CP00"].loc[var.index] wt = (wA / 1000).loc[var.index] niveaux = {} for k in noms: gk = var[k].fillna(var["publie"]) if k != "prix_catalogue_moyen" else var[k] niveaux[f"voitures_{k}"] = 100 * np.exp(gk.cumsum()) niveaux[f"ipch_{k}"] = 100 * np.exp((gP + wt * (gk - var["publie"])).cumsum()) alt = pd.DataFrame(niveaux) alt.loc[1996] = 100.0 alt = alt.sort_index() for k in ["prix_catalogue_moyen"]: # série qui ne commence qu'en 2006 for pref in ["voitures_", "ipch_"]: col = pref + k alt[col] = np.where(alt.index < 2006, np.nan, alt[col]) if alt[col].notna().any(): g2 = (var[k] if pref == "voitures_" else gP + wt * (var[k] - var["publie"])).loc[2007:] lev = pd.Series(100 * np.exp(g2.cumsum()), index=g2.index) lev.loc[2006] = 100.0 alt[col] = lev.sort_index().reindex(alt.index) alt.index.name = "annee" alt.reset_index().to_csv(ICI / "indice-voitures-alternatif.csv", index=False, float_format="%.4f") lignes = [] for d in [1996, 2006, 2010, 2019]: f = DERNIERE_ANNEE for k, lab in noms.items(): if k == "prix_catalogue_moyen" and d < 2006: continue base_v = alt[f"voitures_{k}"] base_i = alt[f"ipch_{k}"] cv, ci = cumul(base_v, d, f), cumul(base_i, d, f) cv0, ci0 = cumul(alt.voitures_publie, d, f), cumul(alt.ipch_publie, d, f) lignes.append(dict(variante=lab, code=k, debut=d, fin=f, voitures_cumul_pct=cv, voitures_par_an_pct=par_an(cv, d, f), ipch_cumul_pct=ci, ipch_par_an_pct=par_an(ci, d, f), ecart_ipch_pt_par_an=par_an(ci, d, f) - par_an(ci0, d, f), ecart_ipch_cumul_pt=ci - ci0)) tab_alt = pd.DataFrame(lignes) tab_alt.to_csv(RES / "tableau_alternatif.csv", index=False, float_format="%.3f") pd.DataFrame({"code": list(noms), "variante": list(noms.values())}).to_csv( ICI / "indice-voitures-alternatif-codes.csv", index=False) param["poids_ipch_fr_voitures_2025"] = W[("FR", "CP0711")].loc[2025] param["poids_ipch_fr_neuves_2025"] = W[("FR", "CP07111")].loc[2025] param["poids_ipch_fr_occasion_2025"] = W[("FR", "CP07112")].loc[2025] param["dernier_mois_ipch"] = ind.mois.max() pd.Series(param, name="valeur").to_csv(RES / "parametres.csv", index_label="nom") return france, tab_fr, tab_pays, tab_alt def main(): if "--hors-ligne" in sys.argv: insee = pd.read_csv(DATA / "insee_ipc.csv", dtype={"coicop": str, "periode": str}) ind = pd.read_csv(DATA / "eurostat_ipch_mensuel.csv") pw = pd.read_csv(DATA / "eurostat_ipch_poids.csv") us = pd.read_csv(DATA / "fred_cpi_etats_unis.csv") else: insee = insee_ipc() ind, pw = eurostat_ipch() us = fred_cpi() france, tab_fr, tab_pays, tab_alt = calculs(insee, ind, pw, us) pd.set_option("display.width", 200) print(tab_fr.round(1).to_string(index=False)) print(tab_pays[tab_pays.debut.isin([1996, 2010, 2020])].round(1).to_string(index=False)) print(tab_alt.round(2).to_string(index=False)) if __name__ == "__main__": main()