AccueilÉcritsVaR : la forme du calcul passe avant l'optimisation

VaR : la forme du calcul passe avant l'optimisation

Sur un moteur de risque, le volume n'est pas ce qu'on lit, c'est ce qu'on engendre. Optimiser la lecture en premier est la semaine perdue classique.

Chafiq Madkour14 mai 202610 min de lectureFinance de marché

Un moteur de calcul d'indicateurs de risque de marché ne ressemble pas à une chaîne d'ingestion. Les données d'entrée sont modestes — quelques millions de positions, quelques centaines de scénarios. Ce qui coûte, c'est ce que le calcul produit à partir d'elles. Tant qu'on n'a pas vu ça, on optimise au mauvais endroit.

Ce que calcule réellement une VaR historique

Le principe est simple à énoncer. On prend le portefeuille d'aujourd'hui, on lui applique les variations de marché observées sur une fenêtre historique — un ou deux ans de scénarios quotidiens — et on obtient une distribution de profits et pertes. La VaR à 99 % est un quantile de cette distribution ; la CVaR — l'expected shortfall — est la moyenne de la queue au-delà.

En pratique, on ne revalorise pas complètement chaque position sous chaque scénario : c'est trop coûteux. On utilise une approximation par sensibilités — le produit des sensibilités du portefeuille par les chocs du scénario, plus éventuellement un terme d'ordre deux.

pnl(position, scenario) = Σ_facteur  sensi(position, facteur) × choc(scenario, facteur)

Cette formule est la clé de tout ce qui suit. Elle dit que le calcul est un produit matriciel entre une matrice de sensibilités et une matrice de chocs — et un produit matriciel a une forme d'exécution optimale qui n'est pas celle d'une chaîne ETL.

Le produit croisé est le vrai volume

Mettons des ordres de grandeur. Un million de positions, deux cent cinquante scénarios, cinquante facteurs de risque. Les entrées tiennent en quelques gigaoctets. Le produit croisé position × scénario, lui, fait deux cent cinquante millions de lignes avant agrégation.

Le volume n'est pas ce qu'on lit, c'est ce qu'on engendre. Toute optimisation de la lecture porte sur quelques gigaoctets ; le coût réel est dans un objet cent fois plus gros qui n'existe qu'en mémoire.

C'est pour ça que la première intuition — compresser mieux, lire moins de colonnes, ajouter des partitions en entrée — donne des gains décevants. Elle s'applique à la petite moitié du problème.

Trois formes possibles, trois coûts très différents

La même formule s'exprime de trois façons en Spark, et l'écart de coût entre elles est d'un ordre de grandeur.

Forme naïve : jointure sur le facteur de risque.

val pnl = sensibilites                        // (position, facteur, sensi)
  .join(chocs, Seq("facteur"))                // (scenario, facteur, choc)
  .withColumn("contrib", col("sensi") * col("choc"))
  .groupBy("position", "scenario")
  .agg(sum("contrib").as("pnl"))

C'est correct et c'est le pire cas. La jointure sur facteur a une cardinalité faible — quelques dizaines de valeurs distinctes — donc le shuffle concentre les données sur très peu de partitions. On obtient un déséquilibre massif, et un groupBy derrière qui redistribue tout une seconde fois.

Forme améliorée : diffuser les chocs. La matrice de chocs est petite — quelques centaines de scénarios par quelques dizaines de facteurs, soit quelques mégaoctets. Elle a vocation à être diffusée, pas jointe.

val chocsBc = spark.sparkContext.broadcast(
  chocs.collect().groupBy(_.getAs[String]("facteur"))
)

val pnl = sensibilites.mapPartitions { it =>
  val table = chocsBc.value                    // lu une fois par partition
  it.flatMap { ligne =>
    table(ligne.facteur).map { c =>
      (ligne.position, c.scenario, ligne.sensi * c.choc)
    }
  }
}.toDF("position", "scenario", "contrib")

Plus aucun shuffle pour croiser : chaque partition de sensibilités produit ses contributions localement. Il reste l'agrégation, mais elle porte sur une clé à forte cardinalité, donc elle se répartit correctement.

Forme dense : un vecteur par position. Quand le nombre de scénarios est fixe et modéré, la représentation la plus efficace agrège les contributions dans un tableau de taille connue, en une seule passe et sans jamais matérialiser le produit croisé.

val n = chocsBc.value.nbScenarios

val pnlParPosition = sensibilites.groupByKey(_.position).mapGroups { (pos, lignes) =>
  val vecteur = new Array[Double](n)          // un seul tableau par position
  lignes.foreach { l =>
    val chocsFacteur = chocsBc.value.pour(l.facteur)
    var s = 0
    while (s < n) { vecteur(s) += l.sensi * chocsFacteur(s); s += 1 }
  }
  (pos, vecteur)
}

C'est cette forme-là qui change l'échelle du calcul : le produit croisé n'est jamais matérialisé, la mémoire est bornée et prévisible, et le tri final pour le quantile porte sur un tableau par position au lieu de deux cent cinquante millions de lignes.

Calculer les sensibilités une fois

L'autre gain majeur est ailleurs et il est plus banal : ne pas recalculer ce qui ne dépend pas du scénario. Les sensibilités sont une propriété du portefeuille et de la date de valorisation, pas du scénario. Un moteur qui les recalcule à l'intérieur de la boucle sur les scénarios fait deux cent cinquante fois le même travail.

C'est une erreur fréquente et difficile à voir, parce qu'elle est structurelle : le code est organisé en une boucle « pour chaque scénario, valoriser le portefeuille », ce qui est la formulation naturelle du métier. La formulation efficace inverse l'ordre : « valoriser une fois, puis appliquer tous les scénarios ».

// le calcul lourd sort de la boucle et se met en cache
val sensibilites = valoriser(portefeuille, dateValeur).persist(MEMORY_AND_DISK)
sensibilites.count()                        // matérialisation explicite

// P&L, VaR et CVaR partagent la même base
val pnl  = appliquerChocs(sensibilites, chocsBc)
val var99  = quantile(pnl, 0.99)
val cvar99 = moyenneQueue(pnl, 0.99)

Le count() après le persist n'est pas décoratif : sans action, le cache n'est pas rempli, et les trois usages suivants recalculent chacun la base.

Le partitionnement stable de bout en bout

Dernier point, celui qui donne les gains les plus durables. Si les sensibilités, les contributions et les agrégats sont partitionnés sur la même clé, Spark n'a pas besoin de redistribuer entre les étapes.

val sensiPart = sensibilites.repartition(400, col("position"))
                            .sortWithinPartitions("position")
                            .persist()

Le choix de la clé mérite réflexion. Partitionner par position répartit uniformément mais casse les agrégats par portefeuille. Partitionner par portefeuille garde les agrégats locaux mais déséquilibre — les portefeuilles n'ont pas la même taille. Sur les moteurs que j'ai optimisés, la réponse était le partitionnement par position avec une agrégation en deux étapes : partielle et locale, puis finale sur un volume déjà réduit.

La méthode générale, s'il faut la résumer : écrire d'abord la forme du calcul, mesurer le volume engendré, et seulement ensuite régler le moteur. Un moteur de risque bien formulé sur une configuration moyenne bat un moteur mal formulé sur une grosse configuration, et il coûte moins cher à faire tourner tous les jours.

Chafiq Madkour est Tech Lead et Senior Data Engineer. Dix ans sur des plateformes data critiques en banque, en assurance et en finance de marché — déclaratifs réglementaires, détection de fraude, moteurs de calcul de risque.