Ne jamais combiner deux bilans : une pile d’analyse boursière utilisant un LLM à capacités limitées
Une pile d’évaluation des actions qui ne calcule jamais la moyenne de deux bilans : données de Yahoo, de la SEC et de FMP fusionnées selon leur priorité, un filtre monétaire et un LLM local maintenus dans leur domaine respectif.
Sources: Never Average Two Balance Sheets
Cette étude de cas porte sur un outil d’analyse boursière qui relie un agent local basé sur Ollama à un moteur de valorisation déterministe que le modèle ne peut pas recalculer. Cet outil vise une seule réponse : la valeur d’une entreprise par rapport à son coût actuel. Il exécute six modèles de valorisation et cinq scores diagnostiques, avant de rendre un jugement unique sur une échelle de cinq niveaux, couvrant les entreprises en Inde, en Europe et aux États-Unis.
La version initiale était un seul processus Python de 1 700 lignes. Il fonctionnait bien, mais même après des mois d’utilisation, il ne parvenait pas à répondre à la question essentielle pour un outil de valorisation : quel était le jugement concernant une entreprise donnée en mars dernier, et qu’est-ce qui a changé depuis ? Il n’y avait aucune historique, un seul fournisseur de données et un cache qui disparaissait chaque fois que le processus redémarrait. Le sujet de cet article est la division de ce processus en cinq services, ainsi que l’évaluation de l’avantage apporté par chaque séparation.
Définir les limites
Ces cinq services ont des responsabilités strictement séparées. Seul marketdata a accès à Internet. Seul valuation génère un chiffre. Seul gateway est exposé au trafic extérieur.
Rayon d’impact, pas d’échelle
Les microservices utilisés pour des projets amateurs suscitent souvent un sourire moqueur. La motivation ici était pratique : différents types de pannes se mélangeaient et chacun coûtait du temps. Le volume de trafic n’est pas un facteur, car une seule personne l’utilise. L’objectif est de limiter les dommages. Un champ renommé chez Yahoo affecte un seul pod, qui enregistre des avertissements. Un modèle bloqué n’empêche pas une analyse de revenir. Tester un autre taux de remise signifie redéployer environ 600 lignes de fonctions purement sans opérations d’entrée/sortie.
Le code commun se trouve dans une bibliothèque partagée : des configurations, des modèles ORM, un cache Redis qui recourt à la mémoire du processus en cas de problème, un client HTTP qui effectue des tentatives répétées, ainsi qu’une usine à gaz qui équipe chaque service des mêmes routes /health/live et /health/ready. Les cinq services communiquent tous leur état de santé de la même manière identique.
Une requête du début à la fin
Une requête vers GET /api/analyze atteint le gateway, qui la distribue aux autres services et assemble leurs réponses. Celui-ci ne calcule rien lui-même. Trois appels en amont sont effectués afin que le navigateur n’ait besoin que d’un seul appel :
@app.get("/api/analyze")
def analyze(q: str, market: str = "US", refresh: bool = False):
snap = _up("marketdata", "/snapshot",
params={"q": q, "market": market, "refresh": refresh})
rates = _up("marketdata", f"/rates/{market}")
result = _up("valuation", "/analyze", method="POST", json={
"symbol": snap["symbol"], "market": market,
"snapshot": snap["data"], "rates": rates, "persist": True,
})
# Provenance travels with the numbers, so the UI can show who said what.
result["sources"] = snap.get("sources", [])
result["disagreements"] = snap.get("disagreements", [])
return result
La dernière étape de ce processus, où l’origine des données est indiquée en même temps que les chiffres, s’est avérée être le résultat le plus précieux de toute cette réécriture.
Trois sources, et rien n’est moyenné
La priorité l’emporte sur le consensus
Le système monolithique ne faisait confiance qu’à Yahoo Finance. Les données fondamentales de Yahoo sont en retard et présentent des lacunes, et elles sont les plus faibles justement là où c’est le plus important : les entreprises indiennes et européennes de plus petite taille.
Deux autres sources ont été ajoutées. L’API XBRL de SEC EDGAR couvre les entreprises américaines sans clé API, et ses chiffres correspondent exactement à ceux déposés, ce qui permet de trancher en cas d’égalité. Financial Modeling Prep offre une couverture mondiale et est le plus fiable là où Yahoo est le plus faible.
Avec trois sources, il y a trois réponses, et moyenniser celles-ci est le raccourci tentant. Résistez-y. Mélanger deux bilans donne un document que personne n’a jamais déposé.
L’ordre de priorité est clair :
# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")
La source ayant la plus haute priorité et disposant réellement d’un champ l’emporte définitivement. Tous les autres valeurs sont conservées pour être affichées accompagnées de l’écart relatif :
def _dispersion(values: list[float]) -> float | None:
"""Relative spread across sources. 0.0 means they agree exactly."""
clean = [v for v in values if v is not None]
if len(clean) < 2:
return None
scale = abs(statistics.median(clean))
if scale < 1e-9:
return None if max(map(abs, clean)) < 1e-9 else 1.0
return (max(clean) - min(clean)) / scale
Tout chiffre dépassant 5 % est marqué comme un désaccord et affiché à côté du nombre. Si la SEC indique un bénéfice net de 130 et que Yahoo affiche 100, un chiffre silencieux de 115 cacherait ce conflit. Le meilleur résultat est de voir les deux valeurs afin de savoir laquelle a été utilisée par le modèle.
Le problème de la monnaie
Un piège plus complexe concerne les monnaies. Infosys est cotée à la NSE et son cours est exprimé en roupies, mais ses comptes sont en dollars. Les cours de Bourse à Londres sont indiqués en pence tandis que la capitalisation boursière est exprimée en livres sterling. Si une source qui rapporte en dollars est autorisée à fournir un champ de valeur pour une action dont le cours est en roupies, le ratio P/E devient approximativement 80 fois trop élevé tout en semblant crédible.
Ainsi, une vérification est effectuée avant même que la priorité ne soit prise en compte :
for name, data in sources.items():
rc = (data.get("reporting_currency")
or data.get("currency") or base_currency or "").upper()
allowed_statements[name] = (not base_currency) or rc == base_currency.upper()
Une source dont la monnaie de reporting diffère de la monnaie de transaction n’a aucun droit de vote sur les champs des états financiers. La règle est simple et juste, car privilégier une opinion unique vaut mieux que de moyener deux monnaies différentes.
Une conséquence en découle : chaque valeur multiple doit être recalculée une fois que les gagnants ont été désignés. Si l’on mélange le résultat net d’EDGAR avec le ratio P/E de Yahoo, la page devient contradictoire sans que personne ne s’en aperçoive pendant des semaines. Par conséquent, le ratio P/E, P/B, P/S, EV/EBITDA, ROE ainsi que les marges sont tous recalculés localement à partir des données sélectionnées.
Cesser de coder de manière fixe le taux sans risque
Deux hypothèses sous chaque modèle DCF
Un flux de trésorerie actualisé dépend d’un taux de croissance et d’un taux d’actualisation. Dans cet outil, les paramètres d’actualisation étaient initialement définis sous forme de valeurs littérales dans un dictionnaire, indiquant un taux sans risque, une prime de risque pour les actions et un taux d’imposition par marché :
"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},
Entrés une seule fois et jamais revus, de tels chiffres deviennent définitivement obsolètes. Le remplacement utilise une série FRED par marché : DGS10 pour les États-Unis, INDIRLTLT01STM pour l’Inde, ainsi qu’une série de l’OCDE (IRLTLT01..M156N) pour l’Europe. Les résultats sont mémorisés pendant un jour, enregistrés avec leur date d’observation et soumis à des limites raisonnables :
def risk_free(market: str, fallback: float) -> dict:
"""{rate, source, observed_on, series}. Never raises."""
series_id = SERIES.get(market)
static = {"rate": fallback, "source": "static",
"observed_on": None, "series": None}
if not series_id or not enabled():
return static
...
Prêtez attention à la forme de cette fonction. Chaque intégration dans le système renvoie une réponse dégradée au lieu d’une valeur augmentée. L’absence de clé FRED signifie des taux statiques. L’absence de clé FMP signifie deux sources au lieu de trois. L’absence de Redis implique l’utilisation d’un dictionnaire en mémoire. Le système indique le chemin qu’il a suivi via GET /api/providers plutôt que de planter en cas d’absence d’un élément utile.
Stocker le taux pour chaque date d’observation est plus important qu’il n’y paraît. Six mois plus tard, lorsque le verdict a changé, on peut déterminer si c’est l’entreprise qui a évolué ou bien le taux de remise.
Les positions sont dérivées, jamais stockées
Le service de portefeuille ne conserve que les transactions et rien d’autre, et le grand livre constitue la seule vérité. Les positions sont recomputées à partir de celui-ci à chaque demande : quantité, coût de base, gains réalisés et non réalisés, dividendes, pondérations, ainsi que le XIRR pour chaque position et pour le compte dans son ensemble.
Cela nécessite plus de travail par demande et c’est évidemment correct. Si vous saisissez une valeur incorrectement en mars et que vous la corrigez en septembre, l’historique s’ajuste automatiquement. En revanche, si vous enregistrez simplement la position, vous corrigerez indéfiniment un chiffre dérivé.
XIRR est le seul indicateur de rendement fiable pour un portefeuille qui continue de recevoir des dépôts, car une simple augmentation en pourcentage vous crédite pour un timing que vous n’avez jamais choisi. Le solveur essaie d’abord la méthode de Newton, recourt à la bisection lorsque cette méthode échoue, et renvoie None lorsque les flux de trésorerie ne permettent pas de déterminer un taux.
Un analyste qui ne peut pas contredire l’analyse
Déterministe des deux côtés
Le dernier composant est un agent LangGraph soutenu par un modèle local Ollama. Le but n’a jamais été de le rendre intelligent. Le problème de conception le plus difficile était d’éviter les erreurs dues à une trop grande confiance, et trois restrictions y parviennent.
Il ne touche jamais d’outil. Chaque récupération et calcul a lieu dans gather, avant que le modèle ne soit invoqué. Le modèle ne peut pas demander de données, il ne peut donc pas boucler, errer ou inventer une source.
Il lit une fiche factuelle, pas du JSON. La fiche contient une quarantaine de nombres étiquetés en moins de 4 KB :
VALUATION
blended fair value: 1402.11 INR (confidence medium, model spread 0.43)
per model: dcf=1610.22, epv=1104.50, graham=1288.31, gordon=1377.90
margin of safety: -8.4% - Trading above fair value
growth assumed: 11.0% (basis: revenue YoY 11.0%) discount rate: 11.2%
SOURCE DISAGREEMENTS (treat these figures as uncertain):
net_income: fmp=261.0B, yahoo=248.5B (spread 4.9%, using fmp)
Un modèle doté de 8 milliards de paramètres gère bien ce fichier compact, mais mal un fichier de 40 KB. Le deuxième point mérite d’être noté : les désaccords détectés lors de la réconciliation sont inclus dans l’instruction, ce qui permet au modèle de savoir quels chiffres sont incertains.
Il obtient au maximum un degré de liberté. Le jugement du moteur sert de référence :
def _clamp_to_engine(model_verdict, engine_verdict):
gap = SCALE.index(model_verdict) - SCALE.index(engine_verdict)
if abs(gap) <= 1:
return model_verdict, None
capped = SCALE[SCALE.index(engine_verdict) + (1 if gap > 0 else -1)]
return capped, (f"model said '{model_verdict}', more than one rung from "
f"the engine's '{engine_verdict}' - capped at '{capped}'")
Le modèle peut argumenter, mais il ne peut pas annuler un modèle quantitatif qu’il ne peut pas recalculer, et chaque tentative est enregistrée, ce qui constitue en soi un signal digne d’être analysé.
Lors d’une deuxième passe, le modèle examine son propre brouillon à la recherche d’affirmations non étayées, et entre en boucle si la critique risque de modifier le verdict. Si la critique reste valable une fois le budget de boucles épuisé, la conviction indiquée est multipliée par 0,7.
Lorsque Ollama est inaccessible, le graphe passe directement à l’étape finalise et renvoie le verdict du moteur avec status: engine_only. L’endpoint renvoie toujours 200. Un modèle manquant produit une réponse de qualité inférieure, mais pas une panne.
Ce que codent les manifestes
Tout se trouve dans un même espace de noms, stockanalysis, sur un cluster k3s local.
Les sondes ignorent délibérément le modèle :
startupProbe: { httpGet: { path: /health/live, port: http },
failureThreshold: 30, periodSeconds: 5 }
readinessProbe: { httpGet: { path: /health/ready, port: http }, periodSeconds: 15 }
livenessProbe: { httpGet: { path: /health/live, port: http }, periodSeconds: 30 }
Si la vérification de disponibilité était conçue pour tester Ollama, un modèle qui n’aurait pas encore été chargé serait considéré comme défaillant, et kubelet continuerait à redémarrer le pod indéfiniment. La santé du modèle est vérifiée intentionnellement via /api/agent/model.
L’agent utilise délibérément une seule réplique. Un processus Ollama sur l’hôte gère un seul modèle ; par conséquent, des pods supplémentaires ne font que s’ajouter en file d’attente sur la même carte graphique, ce qui double les besoins en mémoire. L’automatisation de l’échelle gaspillerait des ressources. Ce qui doit être échelonné, c’est la valuation : il s’agit d’un traitement pur, sans état et sans opérations d’entrée/sortie.
Le trafic externe ne parvient qu’au gateway. Le reste des services est accessible via des adresses ClusterIP derrière une politique d’entrée par défaut qui refuse tout accès. L’agent est accessible via /api/agent/* et ne possède aucune interface publique, ce qui fait de ce proxy l’endroit idéal pour l’authentification ultérieure. Le nombre total de requêtes adressées à l’ensemble du système atteint environ 1,1 Gi.
Quatre éléments qui ont défailli, aucun n’étant d’ordre architectural
- containerd versus dockerd. Rancher Desktop installe une interface en ligne de commande
dockersous les deux environnements d’exécution, de sorte qu’une page d’aide affichée ne résout rien. Avec containerd, les images doivent être importées dans l’espace de nomsk8s.io. Si elles ont été créées ailleurs, cela fait que chaque pod reste bloqué dans l’étatImagePullBackOff, en signalant un registre auquel on n’a jamais tenté de se connecter. - CoreDNS ne parvient pas à résoudre l’hôte. L’URL
http://host.rancher-desktop.internal:11434utilisée pourOLLAMA_BASE_URLéchoue car ce nom d’hôte est défini dans le fichier hosts de la distribution WSL, et non dans le DNS du cluster. Chaque analyse aurait renvoyéengine_onlysans aucun erreur, ce qui signifierait un fonctionnement mais une exécution incomplète, ce qui constitue le pire type de bug. La solution consiste à résoudre l’adresse du routeur par défaut de WSL lors du déploiement. - Insuffisance de mémoire. Avec deux réplicas par service, les requêtes atteignaient 2,24 Gi avant même de prendre en compte les pods propres à k3s. En utilisant un seul réplica par service, cette valeur est passée à 1,05 Gi. Un autre problème : une mise à jour progressive avec
maxSurge: 1, maxUnavailable: 0bloque un déploiement avec un seul réplica sur un nœud, car le nouveau pod a besoin de mémoire que le vieux pod n’a pas encore libérée. - PowerShell 5.1 et encodages. Il lit les fichiers
.ps1en mode ANSI à moins qu’une balise BOM ne soit présente. Les traits en tiret dans les commentaires se transforment en mojibake, ce qui fait planter le analyseur.
Tous ces quatre aspects demandent plus de temps que l’architecture elle-même.
Dette connue
- La tolérance de concordance est fixée à 5 % au niveau global. Elle devrait varier selon le champ, car un écart de 5 % sur les revenus est inquiétant, tandis que c’est insignifiant pour les dépenses d’intérêts.
- La fiche descriptive de l’agent est compilée manuellement à l’aide de formattage de chaînes. Sa structure est correcte, mais elle devrait être générée à partir du schéma publié par le service de valorisation, afin que les deux ne divergent pas.
- Il n’y a pas d’authentification. Cela est acceptable pour un outil à usage unique lié à localhost, mais ce n’est plus le cas dès qu’il est déplacé ailleurs.
Le schéma sous-jacent
Chaque composant doit se dégrader plutôt que de tomber en panne, et doit indiquer ce qu’il a fait. Trois sources de données se réduisent à deux sans aucun problème. FRED devient un tableau statique. Redis devient un dictionnaire. Ollama se tait, mais le moteur fournit néanmoins sa réponse.
À aucun moment le système ne surestime ce qu’il sait, et pour un outil dont les résultats peuvent influencer des décisions impliquant de l’argent réel, cela prime sur tous les autres choix de conception.