Chargement de SwissQuant / Loading SwissQuant

Portefeuille Markowitz Covariance Dynamique

Allocation mensuelle entre cinq ETF, optimisée à partir des rendements passés et d'une covariance Ledoit-Wolf sur 252 séances ; chaque poids est compris entre 0 et 50 %.

Catégorie : Allocation de portefeuille · Univers : SPY, EFA, IEF, VNQ, GLD

Famille : Allocation de portefeuille

Verdict en bref

Sur 2011–2026, le portefeuille Markowitz a rapporté moins que le 60/40 — 8,45 % contre 9,45 % par an — avec davantage de volatilité et un Sharpe inférieur. Sa perte maximale a été nettement plus profonde : −31,04 % contre −20,06 %. La répartition égale des mêmes ETF a rapporté légèrement moins, mais avec une perte maximale plus limitée et un Sharpe légèrement supérieur (0,84 contre 0,82). Ces résultats portent sur une seule période et ne démontrent pas un avantage durable de l'optimisation.

Réplication calculée · audit en attente. Contenu éducatif ; résultats simulés passés, sans garantie de rendement ni protection. Ceci n'est pas un conseil en placement.

Le principe

L'optimisation moyenne-variance recherche un compromis entre rendements estimés et risque estimé. Elle utilise ici les rendements passés, qui ne sont pas des prévisions fiables. Une solution mathématique optimale sur ces estimations peut décevoir ensuite.

Cinq règles pour comprendre le mécanisme

  1. Univers : SPY, EFA, IEF, VNQ, GLD. Cours ajustés Yahoo archivés, en USD ; aucune observation manquante comblée.
  2. Estimation : moyenne arithmétique des 252 rendements quotidiens × 252 ; covariance Ledoit-Wolf sur la même fenêtre, annualisée × 252.
  3. Allocation : maximisation du Sharpe estimé par SLSQP, avec un paramètre de 2 % dans l'objectif ; poids entre 0 et 50 %, total de 100 %, sans levier.
  4. Exécution : rééquilibrage à la clôture ajustée de la dernière séance commune de chaque mois, avec les signaux fixés la veille.
  5. Évaluation : coûts de 0,09 % par dollar acheté ou vendu, achats initiaux inclus ; comparateur 60 % SPY / 40 % AGG rééquilibré mensuellement aux mêmes coûts. Sharpe affiché à 0 % ; perte maximale sur NAV mensuelle.

Résultats recalculés et variantes

Période réellement évaluée : du 2011-01-31 au 2026-08-31 ; 187 rendements mensuels complets, premier mois complet au 2011-02-28. Frais et glissement : 0,09 % par côté ; Sharpe à 0 %. Perte maximale calculée sur la NAV mensuelle.

PortefeuilleCAGRVolatilitéSharpePerte maximaleNAV finale · base 100
Markowitz officiel8,45 %10,67 %0,82−31,04 %354,05
60/40 SPY/AGG9,45 %9,15 %1,04−20,06 %408,23
Répartition égale · cinq parts de 20 %8,17 %10,02 %0,84−21,13 %339,89

Le paramètre de 2 % est conservé dans l'objectif d'optimisation ; tous les Sharpes de performance affichés sont calculés à 0 %.

6 derniers mois · positions après rééquilibrage

NAV sur base 100 ; poids cibles du modèle officiel après rééquilibrage.

MoisNAV #010RendementNAV 60/40Poids
2026-03337,80−7,66 %368,93SPY 18,06 % · EFA 10,76 % · IEF 24,42 % · VNQ 0,00 % · GLD 46,76 %
2026-04343,351,64 %392,42SPY 50,00 % · EFA 0,00 % · IEF 27,11 % · VNQ 5,85 % · GLD 17,04 %
2026-05351,312,32 %405,26SPY 50,00 % · EFA 0,00 % · IEF 28,58 % · VNQ 6,57 % · GLD 14,85 %
2026-06343,86−2,12 %403,17SPY 50,00 % · EFA 3,90 % · IEF 3,94 % · VNQ 32,54 % · GLD 9,62 %
2026-07346,990,91 %401,16SPY 36,66 % · EFA 29,31 % · IEF 0,00 % · VNQ 22,53 % · GLD 11,50 %
2026-08354,052,04 %408,23SPY 50,00 % · EFA 13,42 % · IEF 0,00 % · VNQ 17,48 % · GLD 19,11 %

Extrait essentiel · replicate_010.py

Extrait abrégé, non autonome ; contrôles et départs multiples dans les détails techniques.

# Essential fragments; full checks and multi-start search in technical details.
# Input: 252 daily returns ending before execution.
estimator = LedoitWolf().fit(matrix)
daily_cov = np.asarray(estimator.covariance_, dtype=float)
annual_cov = daily_cov * 252.0
annual_mean = matrix.mean(axis=0) * 252.0
def objective(weights: np.ndarray) -> float:
    variance = float(weights @ annual_cov @ weights)
    if not math.isfinite(variance) or variance <= 0:
        return 1e100
    value = (float(weights @ annual_mean) - ANNUAL_RISK_FREE) / math.sqrt(variance)
    return -value if math.isfinite(value) else 1e100
constraints = ({"type": "eq", "fun": lambda w: float(np.sum(w) - 1.0)},)
# For each deterministic feasible start:
result = minimize(
    objective, start, method="SLSQP", bounds=[(0.0, 0.5)] * n,
    constraints=constraints,
    options={"ftol": 1e-12, "maxiter": 1000, "disp": False},
)
Détails techniques · code complet de la règle d'allocation · replicate_010.py
# Excerpt: official allocation rule. Trading costs are applied separately.
import math
from typing import Any
import numpy as np
from scipy.optimize import minimize
from sklearn.covariance import LedoitWolf
TICKERS = ("SPY", "EFA", "IEF", "VNQ", "GLD")
ANNUAL_RISK_FREE = 0.02  # optimization parameter only
TRANSACTION_COST_RATE = 0.0009

class ReplicationError(ValueError):
    pass

def optimize_weights(window_returns: np.ndarray, tickers: tuple[str, ...] = TICKERS) -> tuple[np.ndarray, dict[str, Any]]:
    """Optimize annualized plug-in Sharpe using deterministic feasible SLSQP starts."""
    matrix = np.asarray(window_returns, dtype=float)
    if matrix.shape != (252, len(tickers)) or not np.isfinite(matrix).all():
        raise ReplicationError("Optimization requires exactly 252 finite daily return rows")
    try:
        estimator = LedoitWolf().fit(matrix)
        daily_cov = np.asarray(estimator.covariance_, dtype=float)
        annual_cov = daily_cov * 252.0
        annual_mean = matrix.mean(axis=0) * 252.0
    except Exception as exc:
        raise ReplicationError(f"Ledoit-Wolf covariance estimation failed: {exc}") from exc
    if not np.isfinite(annual_mean).all() or not np.isfinite(annual_cov).all():
        raise ReplicationError("Non-finite expected return/covariance estimate")
    if not np.allclose(annual_cov, annual_cov.T, atol=1e-12):
        raise ReplicationError("Estimated covariance is not symmetric")
    eigenvalues = np.linalg.eigvalsh(annual_cov)
    if not np.isfinite(eigenvalues).all() or eigenvalues.min() <= 0:
        raise ReplicationError("Estimated covariance is not positive definite")

    def objective(weights: np.ndarray) -> float:
        variance = float(weights @ annual_cov @ weights)
        if not math.isfinite(variance) or variance <= 0:
            return 1e100
        value = (float(weights @ annual_mean) - ANNUAL_RISK_FREE) / math.sqrt(variance)
        return -value if math.isfinite(value) else 1e100

    n = len(tickers)
    starts = [np.full(n, 1.0 / n)]
    # Deterministic bounded vertices and two-asset mixes provide repeatable
    # multi-start coverage of the capped simplex without stochastic seeds.
    for high in range(n):
        candidate = np.full(n, 0.125)
        candidate[high] = 0.5
        starts.append(candidate)
    for left in range(n):
        for right in range(left + 1, n):
            candidate = np.zeros(n)
            candidate[left] = candidate[right] = 0.5
            starts.append(candidate)
    constraints = ({"type": "eq", "fun": lambda w: float(np.sum(w) - 1.0)},)
    feasible: list[tuple[float, np.ndarray, int]] = []
    solver_errors = []
    for start in starts:
        try:
            result = minimize(
                objective, start, method="SLSQP", bounds=[(0.0, 0.5)] * n,
                constraints=constraints,
                options={"ftol": 1e-12, "maxiter": 1000, "disp": False},
            )
        except Exception as exc:
            solver_errors.append(str(exc))
            continue
        weights = np.asarray(result.x, dtype=float)
        is_feasible = (
            weights.shape == (n,) and np.isfinite(weights).all()
            and abs(float(weights.sum()) - 1.0) <= 1e-7
            and (weights >= -1e-7).all() and (weights <= 0.5 + 1e-7).all()
        )
        if result.success and is_feasible and math.isfinite(objective(weights)):
            weights = np.clip(weights, 0.0, 0.5)
            weights /= weights.sum()
            feasible.append((objective(weights), weights, int(result.nit)))
        elif not result.success:
            solver_errors.append(str(result.message))
    if not feasible:
        raise ReplicationError("All deterministic SLSQP starts failed: " + "; ".join(solver_errors[:5]))
    feasible.sort(key=lambda item: (item[0], tuple(float(x) for x in item[1])))
    score, weights, iterations = feasible[0]
    # Numerical solver tolerances can leave a bound off by a few ulps. Project
    # onto the capped simplex without a normalize-after-clipping cap violation.
    low_shift, high_shift = -1.0, 1.0
    for _ in range(100):
        shift = (low_shift + high_shift) / 2
        projected = np.clip(weights + shift, 0.0, 0.5)
        if projected.sum() < 1.0:
            low_shift = shift
        else:
            high_shift = shift
    weights = np.clip(weights + (low_shift + high_shift) / 2, 0.0, 0.5)
    if abs(float(weights.sum()) - 1.0) > 1e-10:
        raise ReplicationError("Could not project SLSQP result onto the capped simplex")
    return weights, {
        "daily_return_observations": 252,
        "annual_expected_returns_arithmetic_mean": dict(zip(tickers, annual_mean.tolist())),
        "annual_covariance_estimator": "sklearn.covariance.LedoitWolf.fit(Tx5 daily simple returns) * 252",
        "optimized_annualized_sharpe": -score,
        "solver": "scipy.optimize.minimize(method=SLSQP)",
        "solver_multistarts": len(starts),
        "selected_solver_iterations": iterations,
    }

Trois limites à retenir

  1. Poids instables : les allocations peuvent changer fortement d'un mois à l'autre, et la limite de 50 % est souvent atteinte. Cela entraîne des coûts et une concentration temporaire.
  2. Sensibilité aux rendements estimés : de petites erreurs sur les moyennes historiques peuvent modifier fortement les poids. Ledoit-Wolf stabilise la covariance, pas les prévisions de rendement.
  3. Une seule période : le choix des ETF, de la fenêtre et du plafond n'est pas validé hors échantillon. La perte maximale observée n'est pas une borne des pertes futures.

Guide et calcul officiel

Le guide cite TLT, mais la réplication officielle utilise IEF. Le code d'exemple utilisait une covariance empirique ; le calcul présenté ici utilise Ledoit-Wolf. Le rendement attendu est la moyenne arithmétique des 252 rendements quotidiens, multipliée par 252.

Les anciennes données sont conservées séparément ; les anciens rapports PDF ne sont pas proposés depuis cette fiche corrigée.

Références méthodologiques

Ces publications expliquent les méthodes ; elles ne valident pas les résultats SwissQuant.