Trois personnalités des Gémeaux débattent d’une stratégie de rupture de la volatilité vouée à l’échec
Un backtest d’une stratégie de rupture de volatilité sur des actions à fort beta, ajustée par un quant, un gestionnaire de risques et une personne chargée de l’exécution, montrant ce qui s’est amélioré et ce qui reste en retard par rapport à SPY.
Les ruptures de volatilité tentent presque tous les analystes quantitatifs qui disposent d’un script Python et d’un outil pour traiter les données de marché. Les actions à fort coefficient beta suivent une tendance positive jusqu’à ce qu’elles inversent brusquement, et une seule après-midi peut anéantir un compte. Les outils d’optimisation, axés sur un seul indicateur, généralement le ratio Sharpe, ajustent les paramètres jusqu’à ce que le bruit historique ressemble à de l’alpha. Les marchés en temps réel se moquent d’une recherche par grille surajustée.
Le réglage conventionnel traite également le risque comme une considération secondaire, en ajoutant simplement un stop statique ou une taille de position fixe à la fin. Cela échoue car générer de l’alpha et survivre exigent des approches opposées. Un analyste quantitatif souhaite une exposition forte au mouvement de marché, tandis qu’un gestionnaire de risques expérimenté veut prendre des mesures de protection avant que la volatilité ne monte en flèche.
Cet experiment vise à relier ces deux approches grâce à un débat entre plusieurs agents représentant trois personnalités Gemini : un analyste quantitatif, un gestionnaire de risques et un stratège d’exécution. Ils critiquent, modifient et valident une stratégie de sortie en cas de volatilité élevée pour un portefeuille composé de quinze actions à fort beta ainsi que de SPY en tant que référence. Ils ne seront pas toujours d’accord, et cette friction est intentionnelle. La moyenne des suggestions de paramètres émises par les personnalités ayant des priorités différentes permet d’assurer la stabilité du résultat.
C’est ici que l’exercice s’est terminé après trois tours de débat structuré :
{
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": -0.6822300207528283,
"consensus_rationale": "Iteration 1 consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement.",
"max_drawdown_delta": 0.00687485272186061,
"total_iterations_run": 3
}
Le point de référence a débuté avec un ratio Sharpe effrayant de -2,68, ainsi qu’un rendement cumulé négatif. Le débat a permis à cette stratégie de sortir de cette situation difficile et a également offert une comparaison édifiante avec le marché plus large. La mise en place de cette stratégie nécessitait une gestion minutieuse des données et un outil de backtesting modulaire ; c’est pourquoi l’exposé commence par l’univers du marché et les données historiques.
Données et univers du marché
L’univers à fort beta
Sélectionner des actions technologiques individuelles en se basant uniquement sur l’enthousiasme des réseaux sociaux n’est pas une méthode fiable ; un univers structuré constitue plutôt le point de départ. Le test utilise quinze actions à fort beta en complément de SPY. Ces actions à fort beta fournissent la dispersion des prix nécessaire aux règles de rupture de tendance, tandis que SPY sert de référence pour distinguer l’alpha réel du bruit dû au beta.
Avant même que toute logique de détection ne soit mise en place, les importations et la configuration globale doivent être définies de manière claire. Les importations concernent pandas et numpy pour les calculs mathématiques, yfinance pour les prix, ainsi que le SDK d’IA générative de Google, qui gère les profils utilisateurs. En conservant les paramètres en un seul endroit, il devient facile de reproduire les expériences ultérieures.
import os
import json
import logging
from datetime import datetime, timezone
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
from google import genai
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
Données manquantes et alignement MultiIndex
Les données financières sont complexes. Yahoo Finance peut retourner des colonnes MultiIndex ou des valeurs NaN dispersées dues à des interruptions de cotation, à des dates d’introduction en bourse différentes ou à des actions sociétales. Lorsque des DataFrames non alignés sont utilisés dans un backtesteur, les calculs de positions s’écartent, ce qui transforme les rendements du portefeuille en données fictives.
La classe MarketDataLoader gère ce problème en encapsulant le téléchargement des données. Elle impose auto_adjust=True afin que les prix soient corrigés en fonction des scissions d’actions et des dividendes, applique un remplissage progressif, et élimine de manière sûre les lignes NaN en tête causées par des dates d’introduction en bourse différentes, ce qui permet de maintenir une structure uniforme.
class MarketDataLoader:
def __init__(self, tickers: list[str], benchmark: str):
self.tickers = tickers
self.all_symbols = tickers + [benchmark]
self.benchmark = benchmark
def fetch_data(self) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d')
start_date = (datetime.now(timezone.utc) -
pd.Timedelta(days=365 * 3)).strftime('%Y-%m-%d')
logging.info(
f"Fetching historical data from {start_date} to {end_date} for {len(self.all_symbols)} symbols.")
data = yf.download(self.all_symbols, start=start_date,
end=end_date, auto_adjust=True, progress=False)
if data.empty:
logging.error(
f"Failed to download data for symbols: {self.all_symbols}")
raise ValueError(
f"Failed to download data for symbols: {self.all_symbols}")
if isinstance(data.columns, pd.MultiIndex):
close = data['Close'] if 'Close' in data.columns.levels[0] else data.xs(
'Close', level=1, axis=1)
high = data['High'] if 'High' in data.columns.levels[0] else data.xs(
'High', level=1, axis=1)
low = data['Low'] if 'Low' in data.columns.levels[0] else data.xs(
'Low', level=1, axis=1)
volume = data['Volume'] if 'Volume' in data.columns.levels[0] else data.xs(
'Volume', level=1, axis=1)
else:
close = data[['Close']]
high = data[['High']]
low = data[['Low']]
volume = data[['Volume']]
close = close.ffill().dropna(how='all')
high = high.ffill().dropna(how='all')
low = low.ffill().dropna(how='all')
volume = volume.ffill().fillna(0.0)
if close.empty:
logging.error(
"Cleaned price data DataFrame is empty after processing.")
raise ValueError(
"Cleaned price data DataFrame is empty after processing.")
logging.info(
f"Data checkpoint - Shape: {close.shape}, Column sample: {list(close.columns[:3])}, NaN count: {close.isna().sum().sum()}, Date range: {close.index[0]} to {close.index[-1]}")
return close, high, low, volume
Lorsque le pipeline s’exécute, le chargeur récupère trois ans de données quotidiennes pour les seize symboles et vérifie leur alignement avant que toute opération mathématique ne soit appliquée :
INFO - Fetching historical data from 2023-08-10 to 2026-08-09 for 16 symbols.
INFO - Data checkpoint - Shape: (751, 16), Column sample: ['AMD', 'COIN', 'DKNG'], NaN count: 0, Date range: 2023-08-10 00:00:00 to 2026-08-07 00:00:00
Le journal indique 751 jours de négociation pour 16 symboles, de août 2023 à août 2026, sans aucune valeur NaN. Avec le panneau synchronisé et vérifié, l’attention peut se porter de l’ingénierie des données à la stratégie.
Le moteur de ruptures
Momentum et volatilité ensemble
Les ruptures attirent les traders particuliers car elles promettent un momentum immédiat. En pratique, de nombreux signaux de rupture sur des titres à fort beta sont des mouvements faux qui piègent la liquidité avant un retournement. Un franchissement de seuil par le prix ne signifie pas grand-chose sans une augmentation du volume et une escalade de la volatilité pour le confirmer.
Un backtester fiable doit donc examiner la manière dont le prix interagit avec le volume et les bandes de volatilité en évolution. Lorsque le prix dépasse un sommet en progression tandis que le volume dépasse son moyenne mobile, le signal a une réelle valeur. Sinon, il s’agit de bruit.
Simulation des rendements du portefeuille
La classe VolatilityStrategyBacktester contient cette logique. Elle prend les ensembles de données propres concernant les prix et les volumes fournis par le chargeur, calcule les indicateurs et simule les rendements quotidiens du portefeuille à partir de paramètres configurables tels que les périodes d’analyse et les seuils de volatilité. La mise en œuvre présentée ci-dessous effectue des simulations sur un portefeuille composé de plusieurs symboles sans que d’informations ne s’échappent entre les différentes exécutions.
class VolatilityStrategyBacktester:
def __init__(self, close_df: pd.DataFrame, high_df: pd.DataFrame, low_df: pd.DataFrame, volume_df: pd.DataFrame, benchmark: str):
self.close_df = close_df
self.high_df = high_df
self.low_df = low_df
self.volume_df = volume_df
self.benchmark = benchmark
self.asset_columns = [c for c in close_df.columns if c != benchmark]
def backtest(self, params: dict) -> tuple[pd.Series, pd.Series]:
lookback = int(params.get("lookback", 20))
atr_mult = float(params.get("atr_multiplier", 2.0))
vol_thresh = float(params.get("volume_threshold", 1.5))
asset_returns_dict = {}
for ticker in self.asset_columns:
if ticker not in self.close_df.columns:
continue
c = self.close_df[ticker]
h = self.high_df[ticker]
l = self.low_df[ticker]
v = self.volume_df[ticker]
tr1 = h - l
tr2 = (h - c.shift(1)).abs()
tr3 = (l - c.shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = tr.rolling(window=lookback).mean()
rolling_high = c.rolling(window=lookback).max()
avg_vol = v.rolling(window=lookback).mean()
breakout = (c > rolling_high.shift(1) + atr_mult *
atr) & (v > vol_thresh * avg_vol)
asset_ret = c.pct_change()
strat_ret = asset_ret * breakout.shift(1).fillna(False)
trades = breakout & (~breakout.shift(1).fillna(False))
strat_ret = strat_ret - (trades.astype(float) * 0.001)
asset_returns_dict[ticker] = strat_ret
strategy_df = pd.DataFrame(asset_returns_dict)
portfolio_returns = strategy_df.mean(axis=1).fillna(0.0)
benchmark_returns = self.close_df[self.benchmark].pct_change().fillna(
0.0)
return portfolio_returns, benchmark_returns
L’exécution de cette opération sur les quinze actions et SPY avant toute intervention d’un agent, en utilisant des paramètres non ajustés, donne un résultat sévère :
INFO - Baseline Metrics: Sharpe=-2.68, Return=-2.79%
Un Sharpe négatif de 2,68 et un rendement cumulé négatif de 2,79 % montrent à quel point les paramètres statiques d’un panier à fort beta peuvent détruire un compte rapidement. C’est cette défaillance que l’architecture de débat est conçue pour corriger, après qu’une recherche grille naïve ait aggravé la situation.
Conception de la personnalité et boucle de débat
Utiliser le modèle comme outil de raisonnement, et non comme oracle
De nombreuses tentatives d’intégration de modèles LLM dans la finance quantitative échouent parce que le modèle est traité comme un oracle chargé de prédire le prix du lendemain, ce qui constitue une façon rapide de perdre des capitaux. Ici, le modèle fonctionne comme un outil de raisonnement linguistique au sein d’une boucle d’optimisation structurée. Il évalue le degré de sensibilité des paramètres sans tenter de prédire les prix.
Une classe de configuration contient les hyperparamètres de base ainsi que les identifiants du modèle nécessaires pour accéder au Google Gen AI SDK :
class Config:
TICKERS = [
"TSLA", "NVDA", "AMD", "PLTR", "COIN",
"MARA", "RIOT", "MSTR", "HOOD", "ROKU",
"UBER", "SHOP", "SNAP", "PTON", "DKNG"
]
BENCHMARK = "SPY"
MAX_ITERATIONS = 3
INITIAL_PARAMS = {
"lookback": 20,
"atr_multiplier": 2.0,
"volume_threshold": 1.5,
"stop_loss_pct": 0.05
}
Afin de rendre le débat productif, trois profils spécialisés sont créés. L’analyste quantitatif surveille la désintégration alpha et la sensibilité au seuil. Le gestionnaire de risques s’intéresse aux limites de baisse de valeur et aux indicateurs de volatilité. Le stratège d’exécution examine le taux de rotation et les écarts de prix.
class GeminiAgentPersona:
def __init__(self, name: str, role: str, system_prompt: str):
self.name = name
self.role = role
self.system_prompt = system_prompt
self.client = genai.Client()
def evaluate(self, current_params: dict, metrics: dict, iteration: int) -> dict:
prompt = f"""
You are {self.name}, a specialized {self.role} in a multi-agent quantitative trading system.
Current Iteration: {iteration}
Current Strategy Parameters: {json.dumps(current_params, indent=2)}
Current Backtest Performance Metrics: {json.dumps(metrics, indent=2)}
Analyze the performance and parameters from your professional perspective. Provide your critique and propose specific parameter adjustments (lookback, atr_multiplier, volume_threshold, stop_loss_pct) to improve the Sharpe ratio and reduce max drawdown.
You MUST return a valid JSON object with keys:
- "critique": Detailed analytical critique from your persona's perspective.
- "proposed_adjustment": Dictionary of recommended parameter changes (e.g., ).
- "confidence_score": Float between 0 and 1 representing your confidence in this adjustment.
"""
response = self.client.models.generate_content(
model="gemini-3.5-flash-lite",
contents=prompt
)
text = response.text
clean_text = text.strip()
if clean_text.startswith("```json"):
clean_text = clean_text[7:]
if clean_text.endswith("```"):
clean_text = clean_text[:-3]
try:
parsed = json.loads(clean_text.strip())
except json.JSONDecodeError:
parsed = {
"critique": text[:500],
"proposed_adjustment": current_params,
"confidence_score": 0.6
}
return parsed
Le coordinateur du débat
Lorsque les personas se rencontrent, elles ne se contentent pas d’échanger des opinions. Le PersonaDebateCoordinator met en œuvre un cycle de critique structuré. Il prend les résultats des tests rétrospectifs de l’itération précédente, transmet les métriques à chaque persona et collecte des réponses au format JSON structuré contenant des critiques ainsi que des modifications de paramètres proposées.
Afin d’empêcher le modèle d’inventer des tailles de position excessives ou des fenêtres de rétroaction infinies, le coordinateur applique une limitation stricte des paramètres. Il calcule également la moyenne des recommandations des personas, ce qui facilite la convergence entre les itérations.
class PersonaDebateCoordinator:
def __init__(self, backtester: VolatilityStrategyBacktester, initial_params: dict, max_iterations: int):
self.backtester = backtester
self.current_params = initial_params.copy()
self.max_iterations = max_iterations
self.quant = GeminiAgentPersona(
"QuantAnalyst",
"Quantitative Strategist",
"Focuses on alpha generation, indicator sensitivity, and statistical edge."
)
self.risk = GeminiAgentPersona(
"RiskManager",
"Risk Officer",
"Focuses on drawdowns, tail risk, position sizing, and volatility caps."
)
self.execution = GeminiAgentPersona(
"ExecutionStrategist",
"Execution Trading Specialist",
"Focuses on liquidity, volume confirmation, slippage, and practical execution friction."
)
self.audit_log = []
self.parameter_history = []
def run_iterative_loop(self) -> dict:
best_sharpe = -999.0
consensus_rationale = "Initial baseline strategy evaluation."
p_ret, b_ret = self.backtester.backtest(self.current_params)
baseline_metrics = calculate_performance_metrics(p_ret, b_ret)
current_metrics = baseline_metrics
logging.info(
f"Baseline Metrics: Sharpe={baseline_metrics['sharpe_ratio']:.2f}, Return={baseline_metrics['cumulative_return']*100:.2f}%")
for i in range(1, self.max_iterations + 1):
logging.debug(
f"--- Starting Debate Iteration {i}/{self.max_iterations} ---")
q_eval = self.quant.evaluate(
self.current_params, current_metrics, i)
r_eval = self.risk.evaluate(
self.current_params, current_metrics, i)
e_eval = self.execution.evaluate(
self.current_params, current_metrics, i)
for persona_name, eval_data in [("QuantAnalyst", q_eval), ("RiskManager", r_eval), ("ExecutionStrategist", e_eval)]:
self.audit_log.append({
"iteration_number": i,
"persona_name": persona_name,
"critique_text": eval_data.get("critique", ""),
"proposed_adjustment": eval_data.get("proposed_adjustment", {}),
"confidence_score": eval_data.get("confidence_score", 0.8)
})
q_adj = q_eval.get("proposed_adjustment", {})
r_adj = r_eval.get("proposed_adjustment", {})
e_adj = e_eval.get("proposed_adjustment", {})
new_lookback = int(np.mean([
float(q_adj.get("lookback",
self.current_params["lookback"])),
float(r_adj.get("lookback",
self.current_params["lookback"])),
float(e_adj.get("lookback",
self.current_params["lookback"]))
]))
new_atr = float(np.mean([
float(q_adj.get("atr_multiplier",
self.current_params["atr_multiplier"])),
float(r_adj.get("atr_multiplier",
self.current_params["atr_multiplier"])),
float(e_adj.get("atr_multiplier",
self.current_params["atr_multiplier"]))
]))
new_vol_thresh = float(np.mean([
float(q_adj.get("volume_threshold",
self.current_params["volume_threshold"])),
float(r_adj.get("volume_threshold",
self.current_params["volume_threshold"])),
float(e_adj.get("volume_threshold",
self.current_params["volume_threshold"]))
]))
new_sl = float(np.mean([
float(q_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"])),
float(r_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"])),
float(e_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"]))
]))
self.current_params = {
"lookback": max(5, min(60, new_lookback)),
"atr_multiplier": max(1.0, min(4.0, new_atr)),
"volume_threshold": max(1.0, min(3.0, new_vol_thresh)),
"stop_loss_pct": max(0.01, min(0.15, new_sl))
}
p_ret_new, b_ret_new = self.backtester.backtest(
self.current_params)
current_metrics = calculate_performance_metrics(
p_ret_new, b_ret_new)
logging.debug(
f"Iteration {i} Results: Sharpe={current_metrics['sharpe_ratio']:.2f}, Return={current_metrics['cumulative_return']*100:.2f}%, Params={self.current_params}")
self.parameter_history.append({
"iteration": i,
"params": self.current_params.copy(),
"metrics": current_metrics.copy()
})
if current_metrics["sharpe_ratio"] > best_sharpe:
best_sharpe = current_metrics["sharpe_ratio"]
consensus_rationale = f"Iteration {i} consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement."
summary_result = {
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": float(best_sharpe),
"consensus_rationale": consensus_rationale,
"max_drawdown_delta": float(current_metrics["max_drawdown"] - baseline_metrics["max_drawdown"]),
"total_iterations_run": self.max_iterations,
"parameter_history": self.parameter_history
}
return summary_result
Pendant que le coordinateur passe en revue les débats et rédige des journaux d’audit ainsi que des résumés de performance, l’exécution d’optimisation se termine :
INFO - Successfully generated output JSON files.
Résultats
Itération par itération
Le backtesting sans métriques fiables n’est qu’une forme de narration ; c’est pourquoi l’évolution au fil des itérations est mesurée. Une fonction de métriques de performance standardise l’annualisation des rendements ainsi que l’ajustement en fonction du risque.
def calculate_performance_metrics(portfolio_returns: pd.Series, benchmark_returns: pd.Series) -> dict:
valid_idx = portfolio_returns.index.intersection(benchmark_returns.index)
p_ret = portfolio_returns.loc[valid_idx]
b_ret = benchmark_returns.loc[valid_idx]
trading_days = 252
cum_return = (1 + p_ret).prod() - 1
annualized_return = (1 + cum_return) ** (trading_days /
len(p_ret)) - 1 if len(p_ret) > 0 else 0.0
annualized_vol = p_ret.std() * np.sqrt(trading_days)
risk_free_rate = 0.03
sharpe_ratio = (annualized_return - risk_free_rate) / \
annualized_vol if annualized_vol > 0 else 0.0
cum_wealth = (1 + p_ret).cumprod()
peak = cum_wealth.cummax()
drawdown = (cum_wealth - peak) / peak
max_drawdown = drawdown.min()
b_cum_return = (1 + b_ret).prod() - 1
outperformance = cum_return - b_cum_return
return {
"cumulative_return": float(cum_return),
"annualized_return": float(annualized_return),
"annualized_volatility": float(annualized_vol),
"sharpe_ratio": float(sharpe_ratio),
"max_drawdown": float(max_drawdown),
"benchmark_cumulative_return": float(b_cum_return),
"outperformance": float(outperformance)
}
Pendant l’essai, l’itération 1 a atteint le point idéal. L’accord entre les trois profils utilisateurs a permis d’obtenir un rendement cumulatif positif ainsi qu’un ratio Sharpe bien supérieur, permettant d’échapper au piège négatif de la référence de base. Les résultats du débat et l’historique des paramètres se présentent comme suit :
{
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": -0.6822300207528283,
"consensus_rationale": "Iteration 1 consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement.",
"parameter_history": [
{
"iteration": 1,
"params": {
"lookback": 14,
"atr_multiplier": 1.5,
"volume_threshold": 1.4666,
"stop_loss_pct": 0.025
},
"metrics": {
"cumulative_return": 0.0399,
"annualized_return": 0.0132,
"sharpe_ratio": -0.6822,
"max_drawdown": -0.0344
}
}
]
}
Par rapport au référentiel
Les rendements de la stratégie doivent toujours être comparés à un référentiel simple de type « acheter et conserver ». L’optimisation multi-agents a réussi à inverser les rendements négatifs du référentiel de base, mais elle a eu beaucoup de mal à concurrencer SPY dans ce régime de marché. Les paniers technologiques à fort beta ont connu des périodes instables où les règles de rupture de tendance ont subi à plusieurs reprises des erreurs de jugement. Un graphique compare la croissance de la stratégie au référentiel au fil des itérations.
Tout le processus intègre le chargement des données, le backtesting et la coordination des profils en un seul point d’entrée :
def main():
logging.info(
"Starting Multi-Agent Volatility Breakout Optimization Pipeline.")
loader = MarketDataLoader(Config.TICKERS, Config.BENCHMARK)
close_df, high_df, low_df, volume_df = loader.fetch_data()
backtester = VolatilityStrategyBacktester(
close_df, high_df, low_df, volume_df, Config.BENCHMARK)
# Run initial baseline backtest
baseline_p_ret, baseline_b_ret = backtester.backtest(Config.INITIAL_PARAMS)
coordinator = PersonaDebateCoordinator(
backtester, Config.INITIAL_PARAMS, Config.MAX_ITERATIONS)
summary_results = coordinator.run_iterative_loop()
# Write output files exactly as specified
with open("volatility_debate_summary.json", "w") as f:
json.dump(summary_results, f, indent=4)
with open("iteration_debate_audit_log.json", "w") as f:
json.dump(coordinator.audit_log, f, indent=4)
logging.info("Successfully generated output JSON files.")
# Generate annotated plot
fig, ax = plt.subplots(figsize=(10, 6))
optimized_p_ret, _ = backtester.backtest(coordinator.current_params)
wealth_baseline = (1 + baseline_p_ret).cumprod()
wealth_optimized = (1 + optimized_p_ret).cumprod()
wealth_benchmark = (1 + baseline_b_ret).cumprod()
ax.plot(wealth_baseline.index, wealth_baseline,
label="Baseline Strategy", color="gray", linestyle="--")
ax.plot(wealth_optimized.index, wealth_optimized,
label="Optimized Multi-Agent Strategy", color="blue", linewidth=2)
ax.plot(wealth_benchmark.index, wealth_benchmark,
label="SPY Benchmark", color="orange", alpha=0.7)
# Annotate final peak / convergence
max_idx = wealth_optimized.idxmax()
max_val = wealth_optimized.max()
ax.annotate(f"Optimized Peak: {max_val:.2f}x",
xy=(max_idx, max_val),
xytext=(max_idx - pd.Timedelta(days=90), max_val * 0.9),
arrowprops=dict(facecolor='blue', shrink=0.05, width=1, headwidth=6))
ax.set_title("Multi-Agent Volatility Breakout Portfolio vs. SPY Benchmark",
fontsize=12, fontweight='bold')
ax.set_xlabel("Date", fontsize=10)
ax.set_ylabel("Growth of 1.00", fontsize=10)
ax.legend(loc="upper left")
ax.grid(True, linestyle=":", alpha=0.6)
plt.tight_layout()
plt.show()
logging.info("Successfully generated strategy performance plot.")
if __name__ == "__main__":
main()
Lorsque le script se termine, il enregistre les journaux d’audit ainsi que la visualisation des performances :
INFO - Successfully generated strategy performance plot.
Ces résultats rappellent que les systèmes LLM multi-agents sont performants en matière de navigation des paramètres et d’évaluation des risques, mais ils ne peuvent pas identifier les tendances macroéconomiques dans un marché stagnant. Pour transférer cette recherche vers un environnement de trading réel, il faudrait également prêter attention au délai d’exécution et aux limites de routage des ordres.
Ce que montre l’expérience
Un système de trading quantitatif ne se résume pas à fournir des données historiques à un optimiseur en espérant de bons résultats. La structure du débat a fonctionné comme une salle de réunion quantitative, avec des personnages Gemini spécialisés chargés d’évaluer la génération d’alpha, les limites de baisse des valeurs et les frictions liées à l’exécution.
Le cycle de critique a effectivement corrigé la valeur de référence. Au fil des itérations, Sharpe est passé de -2,68 à -0,68, ce qui reste une valeur négative, tandis que les rendements cumulatifs sont passés d’un niveau négatif de 2,79 % à un niveau positif. La moyennisation des paramètres associée à une limitation stricte a empêché les profils de dériver vers des fantaisies mathématiques non contrôlées, de sorte que chaque itération est restée liée aux performances réelles des tests en conditions réelles.
Ces résultats ont également apporté une leçon d’humilité. Même avec trois personnalités compétentes chargées d’affiner les seuils de sortie, la stratégie à haute volatilité a nettement accusé un retard par rapport au référentiel d’achat et de détention de SPY dans ce contexte. Les règles basées sur la dynamique perdent leur efficacité dans de tels cas dès qu’une tendance s’inverse et que les prix commencent à revenir vers leurs moyennes. Aucune réflexion nocturne de la part d’un modèle ne peut créer un avantage macroéconomique que le marché lui-même ne fournit pas. Ce que les débats améliorent, c’est la cohérence interne de la stratégie, tandis que le contexte externe reste hors de portée.
Un plan d’action concret pour le trading en temps réel commencerait par diviser le travail en trois étapes d’amélioration. Il consisterait à remplacer les tableaux historiques de pandas par des flux WebSocket en temps réel provenant des API des courtiers, à ajouter des modèles dynamiques de slippage et de commissions au backtester, ainsi qu’à introduire une quatrième entité, un agent de sentiment macroéconomique, capable de changer complètement de stratégie lorsque la volatilité exige de conserver de l’argent liquide.
Les marchés ne paient pas pour du code élégant ou des instructions ingénieuses. Ils paient pour survivre à une incertitude incessante.