<< Sommaire QC | Précédent : QC-Py-28b-Macro-Cycle-Regimes << | Suivant : QC-Py-30-LSTM-Training >>

QC-Py-29 - Valorisation d’un dérivé : trois moteurs, un seul contrat

[LOCAL] Ce notebook s’exécute localement (numpy / scipy), données synthétiques auto-contenues. La valorisation d’options par modèles fermés et simulation ne demande pas de backtest : elle prépare les notebooks stratégies QC qui consomment ces prix (couverture, filter d’univers volatilité, payoff exotiques).

Codage historique : RNCP37437BC01C1.5 - Valoriser un instrument financier via des algorithmes et des simulations (7 h). Le code RNCP37437 est devenu inactif (remplacé par RNCP41881) ; les codes historiques sont conservés sans normalisation. See #16239 (livrable 2).

Le contrat

Un seul instrument traverse tout le notebook : une option de vente (put) et son call symétrique sur une action liquide type SPY, échéance 6 mois. Les trois moteurs de valorisation du cursus - formule fermée de Black-Scholes, arbre binomial CRR, simulation Monte-Carlo - pricing le même contrat avec les mêmes paramètres, puis sont comparés sur quatre axes : précision, convergence, flexibilité, coût de calcul - et un cinquième, souvent oublié : ce que la liquidité ajoute au prix théorique.

Discipline de la série : une seule variable expérimentale par section (le nombre de pas, le nombre de trajectoires, la largeur du spread).

import math
import time
import numpy as np
from dataclasses import dataclass
from scipy.stats import norm

rng = np.random.default_rng(42)


@dataclass(frozen=True)
class Contrat:
    S0: float      # spot de l'action sous-jacente
    K: float       # strike
    r: float       # taux sans risque continu
    q: float       # taux de dividende continu
    sigma: float   # volatilite
    T: float       # maturite en annees

    @property
    def a_terme(self):
        return self.S0 * math.exp((self.r - self.q) * self.T)


CT = Contrat(S0=100.0, K=105.0, r=0.04, q=0.015, sigma=0.25, T=0.5)
print("contrat : put/call", CT)
print(f"forward a 6 mois : {CT.a_terme:.4f}")
print(f"moneyness K/F    : {CT.K / CT.a_terme:.4f} (put legerement dans la monnaie)")
contrat : put/call Contrat(S0=100.0, K=105.0, r=0.04, q=0.015, sigma=0.25, T=0.5)
forward a 6 mois : 101.2578
moneyness K/F    : 1.0370 (put legerement dans la monnaie)

Lecture. Le put 105 est légèrement dans la monnaie au sens du forward (K/F > 1) : il a une valeur intrinsèque prospective non nulle et son prix sera sensible aux trois moteurs - un choix qui discriminate mieux qu’une option à la monnaie parfaite, où tous les prix se resserrent. Le forward, pas le spot, est la bonne référence de monnaie (le dividende 1,5 % tire le forward vers le bas).

Moteur 1 - Black-Scholes : la formule fermée

Le premier moteur ne simule rien : il résout. Sous les hypothèses du modèle (GBM, taux et volatilité constants, pas de frictions), le prix d’une option européenne admet une forme fermée - deux cumulatives normales et c’est fini. C’est la référence à laquelle les deux autres moteurs convergent, et le seul moteur qui donne aussi les dérivées du prix (les grecques) en forme fermée.

def bs_price(ct, kind):
    # formule fermee : deux normales cumulees, aucune simulation
    d1 = (math.log(ct.S0 / ct.K) + (ct.r - ct.q + 0.5 * ct.sigma**2) * ct.T) \
         / (ct.sigma * math.sqrt(ct.T))
    d2 = d1 - ct.sigma * math.sqrt(ct.T)
    if kind == "call":
        return ct.S0 * math.exp(-ct.q * ct.T) * norm.cdf(d1) \
               - ct.K * math.exp(-ct.r * ct.T) * norm.cdf(d2)
    return ct.K * math.exp(-ct.r * ct.T) * norm.cdf(-d2) \
           - ct.S0 * math.exp(-ct.q * ct.T) * norm.cdf(-d1)


def bs_greeques(ct, kind):
    d1 = (math.log(ct.S0 / ct.K) + (ct.r - ct.q + 0.5 * ct.sigma**2) * ct.T) \
         / (ct.sigma * math.sqrt(ct.T))
    pdf = norm.pdf(d1)
    delta = -math.exp(-ct.q * ct.T) * norm.cdf(-d1) if kind == "put" \
        else math.exp(-ct.q * ct.T) * norm.cdf(d1)
    gamma = math.exp(-ct.q * ct.T) * pdf / (ct.S0 * ct.sigma * math.sqrt(ct.T))
    vega = ct.S0 * math.exp(-ct.q * ct.T) * pdf * math.sqrt(ct.T)
    return delta, gamma, vega


REF_PUT = bs_price(CT, "put")
REF_CALL = bs_price(CT, "call")
print(f"BS put  : {REF_PUT:.6f}")
print(f"BS call : {REF_CALL:.6f}")
d, g, v = bs_greeques(CT, "put")
print(f"BS grecques put : delta={d:.6f}  gamma={g:.6f}  vega={v:.6f}")

# parite put-call : C - P = S0*exp(-qT) - K*exp(-rT), une identite sans modele
theo = CT.S0 * math.exp(-CT.q * CT.T) - CT.K * math.exp(-CT.r * CT.T)
ecart_parite = (REF_CALL - REF_PUT) - theo
print(f"ecart de parite put-call : {ecart_parite:.2e} (identite exacte attendue)")
BS put  : 9.102864
BS call : 5.434808
BS grecques put : delta=-0.542447  gamma=0.022246  vega=27.808043
ecart de parite put-call : -7.11e-15 (identite exacte attendue)

Lecture. Deux chiffres à retenir pour toute la suite : le put de référence (six décimales - la précision que seule la forme fermée donne) et l’écart de parité à ~1e-16 : la relation C - P = S0·e^(-qT) - K·e^(-rT) ne dépend d’aucun modèle de dynamique - c’est une conséquence de l’absence d’arbitrage statique, et tout moteur de pricing (arbre, MC) doit la satisfaie à sa précision près. C’est le premier contrôle de cohérence de tout pricer.

Moteur 2 - L’arbre binomial CRR : discrétiser, puis reculer

Le deuxième moteur remplace le continu par une grille : le spot ne suit plus une diffusion mais un arbre à n pas, où il monte d’un facteur u ou descend d’un facteur d (choisis pour matcher la variance du GBM). Le prix se calcule en reculant depuis l’échéance : valeur terminale connue (payoff), puis à chaque noeud, valeur = actualisation de l’espérance neutre au risque - et, si l’option est américaine, max avec la valeur d’exercice immédiat. C’est ce max qui rend l’arbre irremplaçable.

def crr_price(ct, kind, n=1000, americain=False):
    # arbre CRR vectorise : induction arriere sur les n+1 noeuds terminaux
    dt = ct.T / n
    u = math.exp(ct.sigma * math.sqrt(dt))
    d = 1.0 / u
    p = (math.exp((ct.r - ct.q) * dt) - d) / (u - d)
    disc = math.exp(-ct.r * dt)
    j = np.arange(n + 1)
    ST = ct.S0 * u ** (2 * j - n)          # valeurs terminales (n hausse a 0 baisse)
    if kind == "call":
        val = np.maximum(ST - ct.K, 0.0)
        intrinseque_si = lambda S: np.maximum(S - ct.K, 0.0)
    else:
        val = np.maximum(ct.K - ST, 0.0)
        intrinseque_si = lambda S: np.maximum(ct.K - S, 0.0)
    for step in range(n - 1, -1, -1):
        val = disc * (p * val[1:] + (1 - p) * val[:-1])
        if americain:
            j2 = np.arange(step + 1)
            S = ct.S0 * u ** (2 * j2 - step)
            val = np.maximum(val, intrinseque_si(S))
    return float(val[0])


for kind in ("put", "call"):
    euro = crr_price(CT, kind, n=2000)
    ref = REF_PUT if kind == "put" else REF_CALL
    print(f"CRR {kind} europeen n=2000 : {euro:.6f}  (BS {ref:.6f}, "
          f"ecart {euro - ref:+.2e})")
am = crr_price(CT, "put", n=2000, americain=True)
print(f"CRR put AMERICAIN n=2000   : {am:.6f}  (prime d'exercice anticipe "
      f"{am - REF_PUT:+.6f})")
CRR put europeen n=2000 : 9.102941  (BS 9.102864, ecart +7.69e-05)
CRR call europeen n=2000 : 5.434885  (BS 5.434808, ecart +7.69e-05)
CRR put AMERICAIN n=2000   : 9.291420  (prime d'exercice anticipe +0.188557)

Lecture. (1) À n = 2000 pas, l’arbre européen recolle au put Black-Scholes à quelques centimes de centime près - la discrétisation converge vers le continu. (2) Le put américain vaut strictement plus que l’européen : la prime d’exercice anticipé est mesurée, pas affirmée. Aucune formule fermée ne donne ce chiffre pour un put américain sur actif versant des dividendes - c’est le premier trade-off réel entre les moteurs : la forme fermée est exacte mais aveugle à l’exercice anticipé ; l’arbre le voit.

Moteur 3 - Monte-Carlo : échantillonner l’avenir

Le troisième moteur ne discrétise ni ne résout : il tire. Sous la mesure neutre au risque, le terminal S_T est log-normal ; on en tire n réalisations, on moyenne le payoff actualisé. L’estimateur porte sa propre erreur standard (l’écart-type de la moyenne), qui décroît en 1/√n - dix fois moins d’erreur = cent fois plus de trajectoires. La variante antithétique (chaque tirage gaussien est réutilisé avec son signe opposé) réduit la variance gratuitement en anti-corrélant les paires.

def mc_price(ct, kind, n=200_000, antithetique=True, seed=42):
    # simulation du terminal log-normal sous la mesure neutre au risque
    g = np.random.default_rng(seed)
    z = g.standard_normal(n // 2 if antithetique else n)
    if antithetique:
        z = np.concatenate([z, -z])
    ST = ct.S0 * np.exp((ct.r - ct.q - 0.5 * ct.sigma**2) * ct.T
                        + ct.sigma * math.sqrt(ct.T) * z)
    payoff = np.maximum(ST - ct.K, 0.0) if kind == "call" \
        else np.maximum(ct.K - ST, 0.0)
    disc_payoff = math.exp(-ct.r * ct.T) * payoff
    prix = float(disc_payoff.mean())
    se = float(disc_payoff.std(ddof=1) / math.sqrt(len(disc_payoff)))
    return prix, se


prix_mc, se_mc = mc_price(CT, "put")
print(f"MC put n=2e5 antithetique : {prix_mc:.6f} +/- {se_mc:.6f} (1 SE)")
print(f"BS reference               : {REF_PUT:.6f}")
print(f"ecart normalise            : {(prix_mc - REF_PUT) / se_mc:+.2f} SE "
      f"(attendu : |.| < ~2)")
MC put n=2e5 antithetique : 9.114584 +/- 0.023550 (1 SE)
BS reference               : 9.102864
ecart normalise            : +0.50 SE (attendu : |.| < ~2)

Lecture. Le prix Monte-Carlo est dans le tunnel : son écart à la référence vaut moins de deux erreurs standard. C’est la seule notion d’erreur qui ait un sens pour un estimateur stochastique - et elle est dominé par le budget de simulation, pas par la malchance. Retenir l’asymétrie fondamentale : l’arbre divise son erreur par ~4 quand n double (en régime régulier), Monte-Carlo la divise par √2. La suite mesure ces vitesses.

Convergence : deux lois, un seul budget de calcul

Chaque moteur a sa loi d’erreur. L’arbre converge vers BS avec une signature oscillante (l’erreur change de signe selon la parité et la position des noeuds terminaux par rapport au strike) ; Monte-Carlo converge en √n avec une erreur gaussienne. La question pédagogique n’est pas « lequel est le plus précis ? » mais : combien coûte un chiffre de précision supplémentaire, en secondes, sur chaque moteur ?

print("=== arbre : erreur vs nombre de pas (put europeen, ref BS) ===")
for n in (10, 50, 100, 500, 1000, 2000, 5000):
    p = crr_price(CT, "put", n=n)
    print(f"  n={n:5d}  prix={p:.6f}  erreur={p - REF_PUT:+.6f}")

print("=== Monte-Carlo : erreur vs nombre de trajectoires (meme contrat) ===")
for n in (1_000, 10_000, 100_000, 1_000_000):
    p, se = mc_price(CT, "put", n=n)
    print(f"  n={n:8d}  prix={p:.6f}  SE={se:.6f}  "
          f"ecart={p - REF_PUT:+.6f} ({(p - REF_PUT) / se:+.1f} SE)")
=== arbre : erreur vs nombre de pas (put europeen, ref BS) ===
  n=   10  prix=9.263438  erreur=+0.160575
  n=   50  prix=9.073228  erreur=-0.029636
  n=  100  prix=9.117355  erreur=+0.014491
  n=  500  prix=9.101397  erreur=-0.001467
  n= 1000  prix=9.104261  erreur=+0.001397
  n= 2000  prix=9.102941  erreur=+0.000077
  n= 5000  prix=9.103011  erreur=+0.000147
=== Monte-Carlo : erreur vs nombre de trajectoires (meme contrat) ===
  n=    1000  prix=8.842993  SE=0.323069  ecart=-0.259871 (-0.8 SE)
  n=   10000  prix=9.068527  SE=0.105024  ecart=-0.034336 (-0.3 SE)
  n=  100000  prix=9.110449  SE=0.033282  ecart=+0.007585 (+0.2 SE)
  n= 1000000  prix=9.105347  SE=0.010512  ecart=+0.002483 (+0.2 SE)

Lecture. Deux signatures lisibles dans les chiffres : (1) l’erreur de l’arbre oscille autour de zéro (signes alternés aux petits n) puis se resserre - elle est déterministe, reproductible à l’identique, et s’améliore par paliers quand n double ; (2) l’erreur Monte-Carlo est bruitée mais bornée par son SE affiché, et son SE décroît exactement en 1/√n : cent fois plus de trajectoires pour un chiffre décimal de plus. Le coût d’une précision donnée se lit donc différemment : pour l’arbre, doubler n ; pour MC, multiplier par 100. Sur ce contrat européen, l’arbre est le meilleur compromis - mais le prochain section renverse ce verdict.

Grecques : le prix n’est rien, sa sensibilité est tout

Une stratégie de couverture ne consomme pas le prix mais ses dérivées : delta (sensibilité au spot), gamma (convexité), vega (sensibilité à la volatilité). La forme fermée les donne exactement ; les deux autres moteurs les estiment par différences finies - re-pricer le contrat avec le paramètre décalé. Le nuage de bruit Monte-Carlo traverse ici : deux pricings bruités différenciés = un grecque très bruité.

def fd_grecques(moteur, ct, kind, h_rel=1e-3):
    # differences finies centrees sur le spot (delta, gamma) et sigma (vega)
    hS = ct.S0 * h_rel
    hv = ct.sigma * h_rel
    up = moteur(Contrat(ct.S0 + hS, ct.K, ct.r, ct.q, ct.sigma, ct.T), kind)
    dn = moteur(Contrat(ct.S0 - hS, ct.K, ct.r, ct.q, ct.sigma, ct.T), kind)
    mid = moteur(ct, kind)
    delta = (up - dn) / (2 * hS)
    gamma = (up - 2 * mid + dn) / hS**2
    vp = moteur(Contrat(ct.S0, ct.K, ct.r, ct.q, ct.sigma + hv, ct.T), kind)
    vega = (vp - mid) / hv
    return delta, gamma, vega


arbre = lambda ct, kind: crr_price(ct, kind, n=2000)
mc = lambda ct, kind: mc_price(ct, kind, n=200_000)[0]
d0, g0, v0 = bs_greeques(CT, "put")
print(f"{'moteur':<22}{'delta':>12}{'gamma':>12}{'vega':>12}")
print(f"{'BS ferme':<22}{d0:>12.6f}{g0:>12.6f}{v0:>12.6f}")
for nom, mote in (("arbre n=2000", arbre), ("MC n=2e5 (seed commun)", mc)):
    d, g, v = fd_grecques(mote, CT, "put")
    print(f"{nom:<22}{d:>12.6f}{g:>12.6f}{v:>12.6f}")
moteur                       delta       gamma        vega
BS ferme                 -0.542447    0.022246   27.808043
arbre n=2000             -0.548207    0.000000   27.695402
MC n=2e5 (seed commun)   -0.543308    0.020689   27.829763

Lecture - la mesure renverse l’intuition. Le tableau affiche deux surprises mesurées, pas affirmées : (1) Monte-Carlo à seed commun est le plus fidèle sur les trois grecques (delta à 3 décimales, gamma à 7 %, vega à 0,1 %) — la discipline du tirage partagé fait que le bruit des deux pricings décalés se corrige par différence ; (2) l’arbre par différences finies est le piège : son delta FD est biaisé (pente locale d’un segment, pas la dérivée moyenne) et son gamma FD sort exactement nul. La raison est structurelle : à n fixé, le prix CRR est linéaire par morceaux en le spot — des segments droits, séparés par des kinks espacés d’environ S0·(u²−1) ≈ 0,8 $ ici — et notre pas de différences finies (h = 0,10 $) tombe entre deux kinks. La convexité de l’arbre n’existe qu’en moyenne sur ses kinks : un gamma d’arbre exige un schéma dédié (lissage du payoff, ou différences sur n et n+1). Leçon de praticien : la convergence de l’arbre porte sur le prix, pas sur ses dérivées locales.

print("=== prime d'exercice anticipe du put americain (n=2000) ===")
print(f"{'T':>6}{'europeen':>12}{'americain':>12}{'prime':>10}")
for T in (0.25, 0.5, 1.0, 2.0):
    ct = Contrat(CT.S0, CT.K, CT.r, CT.q, CT.sigma, T)
    pe = crr_price(ct, "put", n=2000)
    pa = crr_price(ct, "put", n=2000, americain=True)
    print(f"{T:>6.2f}{pe:>12.6f}{pa:>12.6f}{pa - pe:>10.6f}")

# la parite n'existe plus pour l'americain : controlons que C_am >= C_eu
ca = crr_price(Contrat(CT.S0, CT.K, CT.r, CT.q, CT.sigma, 2.0), "call",
               n=2000, americain=True)
ce = crr_price(Contrat(CT.S0, CT.K, CT.r, CT.q, CT.sigma, 2.0), "call",
               n=2000)
print(f"\ncall americain 2 ans {ca:.6f} vs europeen {ce:.6f} "
      f"(dividendes 1.5% : la prime call est minuscule mais positive)")
=== prime d'exercice anticipe du put americain (n=2000) ===
     T    europeen   americain     prime
  0.25    7.528363    7.624942  0.096579
  0.50    9.102941    9.291420  0.188480
  1.00   11.148986   11.539960  0.390974
  2.00   13.549390   14.400668  0.851278

call americain 2 ans 13.666885 vs europeen 13.666727 (dividendes 1.5% : la prime call est minuscule mais positive)

Lecture. La prime d’exercice anticipé du put croît avec la maturité et avec le taux sans risque (la valeur temporelle de l’argent rend attendre plus cher) : sur 2 ans, elle vaut plusieurs dizaines de centimes - une erreur de modèle, pas un arrondi, si on pricait l’américain avec la formule européenne. Le call américain, lui, ne gagne presque rien à l’exercice anticipé tant que les dividendes restent modérés (exercer le call tôt, c’est jeter la valeur temporelle pour toucher un dividende de 1,5 %) : la sortie le chiffre — 13.666885 pour l’américain contre 13.666727 pour l’européen, soit 0.000158 $ de prime — et l’exercice 2 fait chercher la frontière exacte. Tableau de flexibilité : fermée = européen seulement ; arbre = américain, dividendes discrets, payoff dépendant du chemin terminal ; MC = tout payoff path-dependent, mais européen (ou frontière libre à grand coût).

Liquidité : le prix théorique rencontre le carnet d’ordres

Toute la comparaison précédente vit dans un monde sans friction. La dernière section mesure ce que le bid-ask ajoute : répliquer un put par un portefeuille delta-hedgé (short put couvert par delta actions et du cash) oblige à rebalancer, et chaque rebalancement paie un demi-spread sur les actions échangées. Le scénario est falsifiable : pour chaque largeur de spread, le coût total médian de la réplication sur la vie de l’option est mesuré - et il existe un spread crossover où ce coût dépasse la prime encaissée. Ce n’est pas le risque de marché (déjà dans la volatilité) : c’est un coût de transaction, déterministe au sens où il croît mécaniquement avec le spread.

def cout_replication(ct, spread_usd, n_paths=2000, rebalances=26, seed=7):
    # replique short-put : dettenir delta actions, rebalancer chaque semaine.
    # cout = demi-spread x volume echange a chaque rebalancement.
    g = np.random.default_rng(seed)
    dt = ct.T / rebalances
    couts = []
    for _ in range(n_paths):
        S = ct.S0
        d_old = bs_greeques(ct, "put")[0]
        cout = 0.0
        for _ in range(rebalances):
            z = g.standard_normal()
            S *= math.exp((ct.r - ct.q - 0.5 * ct.sigma**2) * dt
                          + ct.sigma * math.sqrt(dt) * z)
            t_restant = ct.T * (1 - (_ + 1) / rebalances)
            ctt = Contrat(S, ct.K, ct.r, ct.q, ct.sigma, max(t_restant, 1e-6))
            d_new = bs_greeques(ctt, "put")[0]
            cout += abs(d_new - d_old) * S * spread_usd / 2.0
            d_old = d_new
        couts.append(cout)
    return float(np.median(couts)), REF_PUT


print("=== cout median de la replication hebdomadaire vs prime du put ===")
print(f"{'spread/act.':>13}{'cout median':>13}{'prime BS':>11}{'cout/prime':>11}")
for spread in (0.01, 0.05, 0.10, 0.20):
    c, p = cout_replication(CT, spread)
    print(f"{spread:>13.2f}{c:>13.4f}{p:>11.4f}{c / p:>11.1%}")

# croisement : spread ou le cout median egale la prime (dichotomie grossiere)
lo, hi = 0.01, 1.00
for _ in range(20):
    mid = (lo + hi) / 2
    if cout_replication(CT, mid)[0] < REF_PUT:
        lo = mid
    else:
        hi = mid
print(f"\nspread crossover (cout = prime) : ~{(lo + hi) / 2:.3f} usd "
      f"sur une action a {CT.S0:.0f} usd "
      f"({(lo + hi) / 2 / CT.S0:.2%} du spot)")
=== cout median de la replication hebdomadaire vs prime du put ===
  spread/act.  cout median   prime BS cout/prime
         0.01       0.8030     9.1029       8.8%
         0.05       4.0152     9.1029      44.1%
         0.10       8.0304     9.1029      88.2%
         0.20      16.0608     9.1029     176.4%

spread crossover (cout = prime) : ~0.113 usd sur une action a 100 usd (0.11% du spot)

Lecture - le verdict falsifiable. Sur une action à 100 $ : un spread d’un cent (univers très liquide type SPY) rend la réplication quasi gratuite ; à 20 cents, le coût médian devient une fraction visible de la prime ; au-delà du spread crossover mesuré (~0.113 $ mesurés ici, soit 0.11 % du spot), répliquer l’option coûte plus cher que l’acheter - la couverture statique ou l’achat direct domine. Ce verdict est falsifiable au sens propre : changez la fréquence de rebalancement ou la volatilité, le crossover bouge dans le sens prédit (moins de rebalancements = crossover plus large). C’est le critère RNCP visé : le risque de liquidité, mesuré par un scénario avec point de bascule, distinct du risque de marché.

Récapitulatif : le tableau des trois moteurs

Le tableau suivant est construit par les mesures du notebook lui-même (précision atteinte sur ce contrat, temps de calcul median, flexibilité) - il se relit comme la fiche de choix d’un praticien : quel moteur pour quel contrat, à quel budget.

def chronometre(f, reps=5):
    ts = []
    for _ in range(reps):
        t0 = time.perf_counter()
        f()
        ts.append(time.perf_counter() - t0)
    return float(np.median(ts))


t_bs = chronometre(lambda: bs_price(CT, "put"))
t_crr = chronometre(lambda: crr_price(CT, "put", n=2000))
t_mc = chronometre(lambda: mc_price(CT, "put", n=200_000))
p_crr = crr_price(CT, "put", n=2000)
p_mc, se_mc = mc_price(CT, "put", n=200_000)

print(f"{'moteur':<24}{'prix put':>11}{'erreur':>11}{'temps':>12}{'flexibilite':>34}")
print(f"{'BS ferme':<24}{REF_PUT:>11.6f}{'ref':>11}{t_bs * 1e6:>9.1f} us"
      f"{'europeen seulement':>34}")
print(f"{'arbre CRR n=2000':<24}{p_crr:>11.6f}{p_crr - REF_PUT:>11.2e}"
      f"{t_crr * 1e3:>9.1f} ms"
      f"{'americain, dividendes discrets':>34}")
print(f"{'MC antithetique 2e5':<24}{p_mc:>11.6f}{p_mc - REF_PUT:>11.2e}"
      f"{t_mc * 1e3:>9.1f} ms"
      f"{'payoff quelconque (path-dep.)':>34}")
print(f"\nerreur MC bornee par +/-{se_mc:.6f} (1 SE) : la precision MC "
      f"s'achete en 1/sqrt(n), celle de l'arbre par paliers de n")
moteur                     prix put     erreur       temps                       flexibilite
BS ferme                   9.102864        ref    155.4 us                europeen seulement
arbre CRR n=2000           9.102941   7.69e-05     13.3 ms    americain, dividendes discrets
MC antithetique 2e5        9.114584   1.17e-02      7.6 ms     payoff quelconque (path-dep.)

erreur MC bornee par +/-0.023550 (1 SE) : la precision MC s'achete en 1/sqrt(n), celle de l'arbre par paliers de n

Lecture du tableau. Aucun moteur ne gagne sur tous les axes - c’est le résultat central du livrable. La forme fermée est exacte, instantanée (155.4 µs au tableau, contre 13.3 ms pour l’arbre et 7.6 ms pour Monte-Carlo, soit 85,6 et 48,9 fois moins), et aveugle dès que le contrat sort du européen-vanille. L’arbre paie quelques millisecondes pour voir l’américain et les dividendes discrets, avec une erreur déterministe. Monte-Carlo est le seul à ne jamais fermer la porte sur un payoff (asiatique, barrière, pire-des-cas sur panier) mais paie chaque décimale en √n et exige la discipline du seed commun pour les grecques. La décision de pricer est un choix d’ingénierie, pas de goût.

Exercice 1 - Parité put-call et arbitrage

La parité est une identité sans modèle : si un marché cote le call et le put avec un écart à la valeur théorique, un portefeuille statique (long call, short put, short action, prêt au taux r) encaisse la différence sans risque.

def arbitrage_parite(call_cote, put_cote, ct):
    # TODO etudiant
    # Etape 1 : valeur theorique de C - P (aucun modele : juste taux et spot)
    # Etape 2 : ecart = (call_cote - put_cote) - valeur_theorique
    # Etape 3 : si |ecart| > frais, construire le portefeuille et retourner
    #           le pnl certain, sinon retourner 0.0 (pas d'arbitrage)
    pass

Attendu : sur le contrat du notebook avec des cotations décalées de ±0,50 $, l’écart mesuré est exactement le décalage injecté ; le seuil de frais (disons 0,05 $) tranche des cotations arbitrales des non-arbitrables.

def arbitrage_parite(call_cote, put_cote, ct):
    # TODO etudiant
    # Etape 1 : valeur theorique de C - P = S0*exp(-qT) - K*exp(-rT)
    # Etape 2 : ecart = (call_cote - put_cote) - valeur_theorique
    # Etape 3 : si |ecart| > frais, retourner le pnl certain, sinon 0.0
    pass


print("Exercice 1 à compléter : parité put-call et seuil d'arbitrage.")
Exercice 1 à compléter : parité put-call et seuil d'arbitrage.

Exercice 2 - La frontière d’exercice du call américain

Le call américain sur action à dividendes peut payer à exercer tôt : juste avant un détachement, si le dividende dépasse la valeur temporelle restante. Sur notre contrat (dividende continu 1,5 %), la prime est quasi nulle - montez-la pour la voir naître.

def prime_call_americain(q, ct_base):
    # TODO etudiant
    # Etape 1 : pricer le call europeen ET americain a dividend yield q (arbre)
    # Etape 2 : retourner la difference (prime d'exercice anticipe)
    # Etape 3 : balayer q sur [0, 0.05] et repeter la mesure
    pass

Attendu : la prime reste ~0 jusqu’à un q critique (de l’ordre de r·K/S0), puis croît - la frontière d’exercice apparaît dans les chiffres, pas dans la prose.

def prime_call_americain(q, ct_base):
    # TODO etudiant
    # Etape 1 : call europeen et americain a dividend yield q (arbre n=2000)
    # Etape 2 : retourner la difference
    pass


print("Exercice 2 à compléter : frontière d'exercice du call américain (balayage q).")
Exercice 2 à compléter : frontière d'exercice du call américain (balayage q).

Exercice 3 - Réduction de variance : la variable de contrôle

Monte-Carlo peut emprunter la précision de la formule fermée : le put européen a un prix BS exact - donc MC naïf - BS est un estimateur sans biais de l’erreur du moteur sur CE contrat. Sur un contrat voisin (strike décalé), l’erreur du MC est corrélée : le principe de la variable de contrôle est de soustraire cette erreur estimée.

def mc_variable_controle(ct, n=200_000):
    # TODO etudiant
    # Etape 1 : tirer les terminaux UNE fois (seed fixe)
    # Etape 2 : payoff du contrat cible ET du contrat de controle (K = 105)
    # Etape 3 : beta = covariance(cible, controle) / variance(controle)
    #           estimateur = mean(cible) - beta * (mean(controle) - BS_K105)
    pass

Attendu : l’erreur standard de l’estimateur contrôlé est plus petite que celle du naïf - le facteur de réduction mesuré (typiquement > 2 sur un strike voisin) est le livrable de l’exercice.

def mc_variable_controle(ct, n=200_000):
    # TODO etudiant
    # Etape 1 : memes terminaux pour cible et controle (seed fixe)
    # Etape 2 : beta = cov(cible, controle) / var(controle)
    # Etape 3 : estimateur = mean(cible) - beta * (mean(controle) - BS_controle)
    pass


print("Exercice 3 à compléter : variable de contrôle (facteur de réduction de variance).")
Exercice 3 à compléter : variable de contrôle (facteur de réduction de variance).

Conclusion - ce que sait chaque moteur, et ce que coûte la liquidité

Trois moteurs, un contrat, quatre axes mesurés : précision (la fermée impose la référence ; l’arbre converge par paliers ; MC converge en √n avec son erreur standard), flexibilité (l’américain et les dividendes discrets appartiennent à l’arbre ; les payoffs path-dependent à MC ; la fermée au vanille européen), coût (des microsecondes aux dizaines de millisecondes - négligeable sur un contrat, central sur un carnet de 10 000 options), liquidité (le spread crossover mesuré sépare les contrats réplicables des contrats à dédaigner).

Pour le projet collectif : un pricer n’est jamais « le meilleur » - il est adapté au contrat et au budget. Les notebooks stratégies QC qui suivent consomment ces prix (couverture en delta, sélection volatilité) ; ce notebook est la boîte à outils qui les rend lisibles.

Livrable 2 de l’epic #16239 (RNCP37437BC01C1.5, 7 h) : comparaison pédagogique compacte des trois méthodes sur le même instrument, avec précision, convergence, flexibilité, coût - et un scénario de liquidité falsifiable.

Retour au sommet