Ce projet repose sur un principe : ne demandez à personne de vous croire. Tout ce qui peut être calculé est calculé, et tout ce qui est calculé est publié — code compris. Cette page contient la simulation complète du système Cubi : un million de ménages, calibrés sur la distribution réelle des revenus français (INSEE), 80 000 entreprises, un circuit de crédit.

Et ce que la simulation ne montre pas, dit honnêtement : les comportements réels (psychologie, change, anticipations) ne se simulent pas — ils se mesurent. C'est l'objet du pilote territorial proposé au chapitre 22 du livre.
Vous n'avez pas à télécharger un fichier pour savoir ce qu'il contient : le voici, dans son intégralité — 369 lignes de Python, sans dépendance autre que NumPy. C'est le fichier lui-même, lu sur le serveur au chargement de cette page : ce que vous copiez est exactement ce que vous téléchargez. Lancez-le, changez les paramètres, essayez de le mettre en défaut — c'est fait pour. Si vous trouvez une erreur, signalez-la : elle sera corrigée et créditée.
# -*- coding: utf-8 -*-
"""
Simulation multi-agents du système monétaire Cubi
==================================================
Code de l'annexe A.4 du livre « Cubi — La monnaie ultra fondante »
(Daniel Fichter, avec Claude Fable), publié sur cubishome.com.
Règles simulées (chapitres 8 et 16 du livre) :
- Création : chaque ménage reçoit V cubis/mois (la DIG) ; la puissance
publique reçoit une dotation égale à la somme des DIG, dépensée à 100 %.
- Destruction : tous les soldes fondent de d par mois (modèle à pas
mensuel, équivalent exact de la fonte continue aux bornes des mois).
- Le crédit transfère les soldes (les créances ne fondent pas).
Architecture du circuit (fermé : la monnaie est conservée hors création/fonte) :
Ménages --consommation--> Entreprises --salaires/dividendes--> Ménages
Ménages --prêts (excédents)--> Entreprises --remboursements+intérêts--> Ménages
État --achats publics--> Entreprises ; État --salaires publics--> Ménages
Hypothèses comportementales (annexe A.4) :
- 1 000 000 de ménages ; capacités de gain log-normales calées sur les
déciles INSEE de niveau de vie (médiane ~2 180 €, D9/médiane ~1,91).
- Propension à consommer décroissante par décile : 0,95 (D1) -> 0,55 (D10)
(Dynan-Skinner-Zeldes 2004).
- Encaisse cible des ménages : 1 mois de consommation ; l'excédent est
prêté (créances : rendement 1 %/mois, remboursement sur 12 mois,
défaut 0,1 %/mois de l'encours).
- 80 000 entreprises ; trésorerie cible tirée entre 15 et 90 jours de
décaissements.
Usage :
python cubi_simulation.py # simulation de base, 120 mois
python cubi_simulation.py --choc # ajoute le scénario de choc + contrefactuel
python cubi_simulation.py --menages 100000 # version rapide
Sorties : résumé console + resultats.json (+ graphiques PNG si matplotlib).
Reproductible : graine aléatoire fixée (--seed).
"""
import argparse
import json
import math
import numpy as np
# ----------------------------------------------------------------------------
# Paramètres
# ----------------------------------------------------------------------------
def build_config(args):
n_m = args.menages
cfg = {
# Population
"n_menages": n_m,
"n_entreprises": max(1, int(n_m * 0.08)),
# Règles Cubi
"V": 1000.0, # DIG mensuelle par ménage
"d": 0.10, # taux de fonte mensuel
# Distribution des capacités de gain (log-normale calée INSEE)
# médiane 2180 ; sigma tel que D9/médiane ~ 1.91 => sigma ~ 0.506
"revenu_median": 2180.0,
"revenu_sigma": 0.506,
# Comportement ménages
"mpc_d1": 0.95, # propension à consommer, 1er décile
"mpc_d10": 0.55, # propension à consommer, 10e décile
"conso_min": 150.0, # consommation incompressible c0
"encaisse_cible_mois": 1.0, # cible : 1 mois de consommation
# Crédit
"taux_creance": 0.01, # rendement mensuel des créances
"duree_pret_mois": 12,
"taux_defaut": 0.001, # défaut mensuel sur l'encours
# Entreprises
"treso_min_j": 15, "treso_max_j": 90, # trésorerie cible en jours
"part_dividendes": 0.10, # 10 % des décaissements
# État (dépense 100 % de sa dotation)
"part_salaires_publics": 0.5, # 50 % salaires, 50 % achats
# Simulation
"mois": args.mois,
"seed": args.seed,
}
return cfg
# ----------------------------------------------------------------------------
# Modèle
# ----------------------------------------------------------------------------
class SimulationCubi:
def __init__(self, cfg, avec_cubi=True):
"""avec_cubi=False : contrefactuel « monnaie fixe » (ni DIG ni fonte),
masse initialisée au même niveau, pour comparaison des chocs."""
self.cfg = cfg
self.avec_cubi = avec_cubi
rng = np.random.default_rng(cfg["seed"])
self.rng = rng
n = cfg["n_menages"]
# --- Ménages ---------------------------------------------------------
mu = math.log(cfg["revenu_median"])
self.capacite = rng.lognormal(mu, cfg["revenu_sigma"], n) # poids salarial
ordre = np.argsort(self.capacite)
self.decile = np.empty(n, dtype=np.int8)
self.decile[ordre] = np.arange(n) * 10 // n # 0..9 (pour les stats)
# Hétérogénéité CONTINUE (pas de paliers : les populations réelles n'ont
# ni déciles de comportement, ni frontière binaire riches/pauvres —
# des règles discrètes créeraient des « trous » artificiels dans la
# distribution des soldes).
rang = np.empty(n)
rang[ordre] = np.arange(n) / (n - 1) # rang de revenu 0..1
# propension à consommer : décroissante continûment avec le rang + bruit
self.mpc = cfg["mpc_d1"] + (cfg["mpc_d10"] - cfg["mpc_d1"]) * rang
self.mpc = np.clip(self.mpc + rng.normal(0, 0.03, n), 0.40, 0.99)
# encaisse cible : hétérogène, 0,5 à 1,5 mois de consommation
self.cible_mois = rng.uniform(0.5, 1.5, n) * cfg["encaisse_cible_mois"]
self.solde_m = np.zeros(n) # soldes monétaires des ménages
self.creances = np.zeros(n) # encours de créances détenues (ne fond pas)
self.conso_prec = np.full(n, cfg["conso_min"]) # mémoire pour l'encaisse cible
# détention d'actions : continue et concentrée (poids = capacité²),
# pas de seuil binaire — tout le monde peut détenir, les hauts revenus dominent
self.poids_div = self.capacite ** 2
self.actionnaire = self.decile >= 8 # conservé pour les statistiques seulement
# --- Entreprises -----------------------------------------------------
ne = cfg["n_entreprises"]
self.solde_e = np.zeros(ne)
self.treso_cible_j = rng.uniform(cfg["treso_min_j"], cfg["treso_max_j"], ne)
# parts de marché log-normales (concentration réaliste des ventes)
pm = rng.lognormal(0.0, 1.0, ne)
self.part_marche = pm / pm.sum()
self.dette_e = 0.0 # encours agrégé emprunté aux ménages
self.decaiss_prec = np.zeros(ne)
# --- État -------------------------------------------------------------
self.solde_etat = 0.0
# --- Contrefactuel : initialisation de la masse -----------------------
if not avec_cubi:
# même masse totale que l'équilibre Cubi, répartie au prorata
M = cfg["n_menages"] * cfg["V"] * 2 / cfg["d"] * (1 - cfg["d"])
self.solde_m[:] = M * 0.6 / n
self.solde_e[:] = M * 0.4 * self.part_marche
# --- Journaux ----------------------------------------------------------
self.hist = {"masse": [], "conso": [], "fonte_menages_decile": None,
"fonte_e": 0.0, "fonte_etat": 0.0, "fonte_m": 0.0}
self._fonte_m_par_decile = np.zeros(10)
# ------------------------------------------------------------------ outils
def masse_totale(self):
return self.solde_m.sum() + self.solde_e.sum() + self.solde_etat
# ------------------------------------------------------------------- mois
def tour(self, mois, choc=False):
cfg, rng = self.cfg, self.rng
n = cfg["n_menages"]
# 1) CRÉATION : DIG aux ménages + dotation symétrique à l'État
if self.avec_cubi:
self.solde_m += cfg["V"]
self.solde_etat += cfg["V"] * n
# 2) L'ÉTAT dépense 100 % : salaires publics + achats aux entreprises
dep = self.solde_etat
sal_pub = dep * cfg["part_salaires_publics"]
self.solde_m += sal_pub * (self.capacite / self.capacite.sum())
self.solde_e += (dep - sal_pub) * self.part_marche
self.solde_etat = 0.0
# 3) ENTREPRISES : remboursent les prêts, paient salaires et dividendes
# (décaissement = tout ce qui dépasse la trésorerie cible)
if self.dette_e > 0:
interet = self.dette_e * cfg["taux_creance"]
principal = self.dette_e / cfg["duree_pret_mois"]
service = min(interet + principal, self.solde_e.sum() * 0.5)
part = service / self.solde_e.sum() if self.solde_e.sum() > 0 else 0
self.solde_e *= (1 - part)
# versé aux créanciers au prorata de leurs encours
cr_tot = self.creances.sum()
if cr_tot > 0:
self.solde_m += service * (self.creances / cr_tot)
rembours_principal = max(0.0, service - interet)
self.creances *= (1 - rembours_principal / cr_tot)
self.dette_e = max(0.0, self.dette_e - rembours_principal)
# défauts : pertes des créanciers, dette effacée
defaut = self.dette_e * cfg["taux_defaut"]
self.dette_e -= defaut
if cr_tot > 0:
self.creances *= (1 - defaut / max(cr_tot, 1e-9))
treso_cible = self.decaiss_prec * (self.treso_cible_j / 30.0)
decaisse = np.maximum(0.0, self.solde_e - treso_cible)
self.solde_e -= decaisse
masse_sal = decaisse.sum() * (1 - cfg["part_dividendes"])
masse_div = decaisse.sum() * cfg["part_dividendes"]
self.solde_m += masse_sal * (self.capacite / self.capacite.sum())
self.solde_m += masse_div * (self.poids_div / self.poids_div.sum())
self.decaiss_prec = decaisse
# 4) MÉNAGES : consommation = c0 + mpc × revenu du mois
revenu = cfg["V"] * self.avec_cubi + \
(sal_pub + masse_sal) * (self.capacite / self.capacite.sum()) + \
masse_div * (self.poids_div / self.poids_div.sum())
mpc = self.mpc * (0.9 if choc else 1.0) # choc : -10 % de propension
conso = np.minimum(self.solde_m, cfg["conso_min"] + mpc * revenu)
self.solde_m -= conso
self.solde_e += conso.sum() * self.part_marche
self.conso_prec = conso
# 5) PRÊTS : l'excédent au-delà de l'encaisse cible est prêté
# (le solde est transféré aux entreprises : investissement)
if self.avec_cubi:
cible = self.cible_mois * np.maximum(conso, cfg["conso_min"])
excedent = np.maximum(0.0, self.solde_m - cible)
pret = excedent.sum()
if pret > 0:
self.solde_m -= excedent
self.creances += excedent
self.solde_e += pret * self.part_marche
self.dette_e += pret
# 6) FONTE : tous les soldes × (1-d) ; rien d'autre ne fond
if self.avec_cubi:
d = cfg["d"]
fm = self.solde_m * d
fe = self.solde_e.sum() * d
fs = self.solde_etat * d
np.add.at(self._fonte_m_par_decile, self.decile, fm)
self.hist["fonte_m"] += fm.sum()
self.hist["fonte_e"] += fe
self.hist["fonte_etat"] += fs
self.solde_m *= (1 - d)
self.solde_e *= (1 - d)
self.solde_etat *= (1 - d)
# 7) Journaux
self.hist["masse"].append(self.masse_totale())
self.hist["conso"].append(conso.sum())
def run(self, choc_debut=None, choc_duree=0):
for m in range(self.cfg["mois"]):
choc = (choc_debut is not None and choc_debut <= m < choc_debut + choc_duree)
self.tour(m, choc=choc)
self.hist["fonte_menages_decile"] = self._fonte_m_par_decile
return self.hist
# ----------------------------------------------------------------------------
# Analyses
# ----------------------------------------------------------------------------
def resume(cfg, sim, hist):
n = cfg["n_menages"]
F = cfg["V"] * n * 2 # flux mensuel total (ménages + État)
M_theorique = F / cfg["d"] * (1 - cfg["d"]) # masse de fin de mois (après fonte)
M_finale = hist["masse"][-1]
soldes = sim.solde_m
fonte_tot = hist["fonte_m"] + hist["fonte_e"] + hist["fonte_etat"]
fonte_top10 = hist["fonte_menages_decile"][9]
out = {
"masse_finale": M_finale,
"masse_theorique_F_sur_d": M_theorique,
"ecart_masse_pct": 100 * (M_finale - M_theorique) / M_theorique,
"mois_pour_95pct": int(np.argmax(np.array(hist["masse"]) >= 0.95 * M_theorique)) + 1,
"soldes_menages": {
"mediane": float(np.median(soldes)),
"moyenne": float(soldes.mean()),
"P90": float(np.percentile(soldes, 90)),
"P95": float(np.percentile(soldes, 95)),
"P99": float(np.percentile(soldes, 99)),
},
"part_fonte": {
"menages_pct": 100 * hist["fonte_m"] / fonte_tot,
"entreprises_pct": 100 * hist["fonte_e"] / fonte_tot,
"etat_pct": 100 * hist["fonte_etat"] / fonte_tot,
"top10pct_menages_dans_fonte_menages_pct":
100 * fonte_top10 / hist["fonte_m"],
},
}
return out
def scenario_choc(cfg):
"""Choc de -10 % de propension à consommer pendant 6 mois (mois 60-65),
comparé au contrefactuel « monnaie fixe sans DIG ni fonte »."""
res = {}
for nom, avec in [("cubi", True), ("monnaie_fixe", False)]:
base = SimulationCubi(cfg, avec_cubi=avec)
hb = base.run()
choc = SimulationCubi(cfg, avec_cubi=avec)
hc = choc.run(choc_debut=60, choc_duree=6)
cb = np.array(hb["conso"]); cc = np.array(hc["conso"])
ref = cb[55:60].mean()
creux = 100 * (cc[60:70].min() - ref) / ref
res[nom] = {"creux_conso_pct": float(creux)}
a, b = res["cubi"]["creux_conso_pct"], res["monnaie_fixe"]["creux_conso_pct"]
res["attenuation_pct"] = float(100 * (1 - a / b)) if b != 0 else None
return res
# ----------------------------------------------------------------------------
# Programme principal
# ----------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(description="Simulation multi-agents Cubi (annexe A.4)")
p.add_argument("--menages", type=int, default=1_000_000)
p.add_argument("--mois", type=int, default=120)
p.add_argument("--seed", type=int, default=2026)
p.add_argument("--choc", action="store_true", help="scénario de choc + contrefactuel")
args = p.parse_args()
cfg = build_config(args)
print(f"Simulation Cubi : {cfg['n_menages']:,} ménages, {cfg['n_entreprises']:,} "
f"entreprises, {cfg['mois']} mois, V={cfg['V']:.0f}, d={cfg['d']:.0%}")
sim = SimulationCubi(cfg, avec_cubi=True)
hist = sim.run()
out = resume(cfg, sim, hist)
print(f"\n--- Masse monétaire ---")
print(f" finale {out['masse_finale']/1e9:,.2f} Mds ; théorique F(1-d)/d "
f"{out['masse_theorique_F_sur_d']/1e9:,.2f} Mds ; écart {out['ecart_masse_pct']:+.3f} %")
print(f" 95 % de la masse d'équilibre atteints au mois {out['mois_pour_95pct']}")
s = out["soldes_menages"]
print(f"\n--- Soldes des ménages (fin de simulation, après fonte) ---")
print(f" médiane {s['mediane']:,.0f} ; moyenne {s['moyenne']:,.0f} ; "
f"P90 {s['P90']:,.0f} ; P95 {s['P95']:,.0f} ; P99 {s['P99']:,.0f}")
f = out["part_fonte"]
print(f"\n--- Qui paie la fonte (cumul) ---")
print(f" ménages {f['menages_pct']:.1f} % ; entreprises {f['entreprises_pct']:.1f} % ; "
f"État {f['etat_pct']:.1f} %")
print(f" part du décile supérieur dans la fonte des ménages : "
f"{f['top10pct_menages_dans_fonte_menages_pct']:.1f} %")
if args.choc:
print(f"\n--- Choc de demande (-10 % de mpc, 6 mois) ---")
ch = scenario_choc(cfg)
print(f" creux de consommation : Cubi {ch['cubi']['creux_conso_pct']:.1f} % ; "
f"monnaie fixe {ch['monnaie_fixe']['creux_conso_pct']:.1f} %")
print(f" atténuation du creux par Cubi : {ch['attenuation_pct']:.0f} %")
out["choc"] = ch
with open("resultats.json", "w", encoding="utf-8") as fjs:
json.dump(out, fjs, ensure_ascii=False, indent=2)
print("\nRésultats écrits dans resultats.json")
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].plot(np.array(hist["masse"]) / 1e9)
ax[0].axhline(out["masse_theorique_F_sur_d"] / 1e9, ls="--", c="r")
ax[0].set_title("Masse monétaire (Mds) — convergence vers F(1-d)/d")
ax[0].set_xlabel("mois")
ax[1].hist(sim.solde_m, bins=150, range=(0, 15000))
ax[1].axvline(cfg["V"] / cfg["d"], ls="--", c="r")
ax[1].set_title("Soldes des ménages (pointillé : plafond d'indifférence V/d)")
ax[1].set_xlabel("cubis")
fig.tight_layout()
fig.savefig("resultats.png", dpi=130)
print("Graphiques écrits dans resultats.png")
except ImportError:
pass
if __name__ == "__main__":
main()
Pour l'exécuter : pip install numpy matplotlib puis
python cubi_simulation.py. Le script affiche les agrégats et régénère le graphique ci-dessus.
Une graine aléatoire fixe (2026) rend les résultats reproductibles à l'identique.