← Retour aux articles
August 12, 2026
5 min de lecture

Koopman Operators and DMD: Do Market Modes Survive Out-of-Sample?

Koopman Operators and DMD: Do Market Modes Survive Out-of-Sample?
#mathematics
#Koopman
#dynamical-systems
#spectral
#prediction

La décomposition en mode dynamique vous offre un spectre : une poignée de valeurs propres complexes, chacune avec un taux de croissance et une fréquence, chacune attachée à un mode spatial sur la section transversale de votre actif. Cela ressemble à une structure. Toute la question est de savoir s'il s'agit d'une structure ou s'il s'agit d'un opérateur linéaire mémorisant consciencieusement une fenêtre de bruit.

Cette question comporte deux moitiés testables, et cet article est construit autour d'elles :

  1. Persistance du mode. Ajuster DMD sur la fenêtre tt et sur la fenêtre t+1t+1. Les modes dominants s'étendent-ils sur le même sous-espace, ou sont-ils remaniés à chaque réaménagement ? S'ils remanient, DMD est une décomposition dans l'échantillon et rien de plus - et dire cela est clairement plus utile qu'un autre didacticiel.
  2. Le rayon spectral comme indicateur avancé. Le plus grand module de valeur propre ρ=maxjλj\rho = \max_j |\lambda_j| est un scalaire unique résumant à quel point la dynamique ajustée est explosive. Est-ce qu'il avance la volatilité réalisée, la retarde-t-il ou simplement la reformule-t-il ? L’une ou l’autre réponse est publiable ; seul le premier est négociable.

Le fait que les marchés soient des systèmes non linéaires non stationnaires est supposé ici, pas argumenté - le blog l'a déjà soutenu, avec la géométrie de l'espace de phase dans attractors in algotrading et avec les statistiques BTC mesurées par régime dans détection de régime avec HMM. Ce qui suit est la question plus étroite de savoir ce que la non-stationnarité fait à un opérateur Koopman ajusté et comment la mesurer.

1. L'idée de base : linéariser la dynamique non linéaire

Le mouvement non linéaire du marché devient un flux latent linéaire

Considérons un système dynamique à temps discret sur un espace d'états MRn\mathcal{M} \subseteq \mathbb{R}^n:

xk+1=F(xk)x_{k+1} = F(x_k)

F:MMF: \mathcal{M} \to \mathcal{M} est une carte éventuellement non linéaire. Pour les marchés, xkx_k est un vecteur de rendements d'actifs, de volatilités ou de déséquilibres du carnet d'ordres à un pas de temps kk.

L'opérateur Koopman K\mathcal{K} n'agit pas sur l'État xx directement, mais sur des fonctions observables à valeur scalaire g:MCg: \mathcal{M} \to \mathbb{C}:

[Kg](x)=g(F(x))[\mathcal{K} g](x) = g(F(x))

La propriété clé : K\mathcal{K} est linéaire, même lorsque FF ne l'est pas. Le prix est la dimensionnalité — K\mathcal{K} agit sur un espace fonctionnel de dimension infinie. Une bonne approximation de dimension finie vous offre l'expressivité de la dynamique non linéaire avec la maniabilité de l'algèbre linéaire : la prédiction devient une exponentiation matricielle, et chaque mode est inspectable plutôt que enterré dans des poids de réseau.

2. Décomposition spectrale de l'opérateur Koopman

Modes spectraux cohérents de la dynamique du marché

Si K\mathcal{K} a des valeurs propres λj\lambda_j et fonctions propres φj\varphi_j, alors Kφj=λjφj\mathcal{K} \varphi_j = \lambda_j \varphi_j, et tout observable gg dans l'ensemble de ces fonctions propres se décompose comme suit :

g(xk)=j=1φj(x0)λjkvjg(x_k) = \sum_{j=1}^{\infty} \varphi_j(x_0) \, \lambda_j^{k} \, v_j

vjv_j sont les modes Koopman — des coefficients à valeur vectorielle décrivant comment chaque fonction propre contribue au vecteur observable complet.

Chaque valeur propre λj=λjeiωj\lambda_j = |\lambda_j| e^{i\omega_j} code un taux de croissance ou de décroissance (λj|\lambda_j|) et une fréquence d'oscillation (ωj\omega_j) :

Composant Propriété aux valeurs propres Interprétation financière
Tendance λ1\lambda \approx 1, ω0\omega \approx 0 Dérive lente, élan
Cycles λ1\lvert\lambda\rvert \approx 1, ω0\omega \neq 0 Oscillations, saisonnalité
Transitoires λ<1\lvert\lambda\rvert < 1 Chocs décroissants, mouvements de courte durée
Modes instables λ>1\lvert\lambda\rvert > 1 Dynamique croissante et explosive

Cette table est la promesse. La section 4 est l'endroit où la promesse est vérifiée par rapport aux données.

3. Décomposition en mode dynamique (DMD)

Modes dynamiques décomposés et réassemblés

DMD est l'algorithme de référence pour approximer K\mathcal{K} à partir de données. Étant donné des instantanés disposés en matrices :

X=[x0x1xm1],X=[x1x2xm]X = \begin{bmatrix} x_0 & x_1 & \cdots & x_{m-1} \end{bmatrix}, \quad X' = \begin{bmatrix} x_1 & x_2 & \cdots & x_m \end{bmatrix}

DMD recherche l'opérateur linéaire le mieux adapté AA avec XAXX' \approx AX:

  1. Calculez le SVD : X=UΣVX = U \Sigma V^*
  2. Projet : A~=UXVΣ1\tilde{A} = U^* X' V \Sigma^{-1}
  3. Décomposition propre : A~W=WΛ\tilde{A} W = W \Lambda
  4. Récupérez les modes plein espace : Φ=XVΣ1W\Phi = X' V \Sigma^{-1} W

Les colonnes de Φ\Phi sont les modes DMD ; la diagonale de Λ\Lambda contient les valeurs propres DMD.

import numpy as np
from numpy.linalg import svd, eig, lstsq

def dmd(X: np.ndarray, rank: int | None = None) -> tuple:
    """
    Dynamic Mode Decomposition.

    Parameters
    ----------
    X : np.ndarray, shape (n_features, n_snapshots)
        Data matrix where each column is a state snapshot.
    rank : int or None
        Truncation rank for the SVD. None = no truncation.

    Returns
    -------
    eigenvalues : np.ndarray, shape (r,)
        DMD eigenvalues (approximating Koopman eigenvalues).
    modes : np.ndarray, shape (n_features, r)
        DMD modes (columns), L2-normalised.
    amplitudes : np.ndarray, shape (r,)
        Mode amplitudes fitted to the FINAL snapshot, so that a one-step
        forecast is simply modes @ (eigenvalues * amplitudes).
    """
    X0 = X[:, :-1]
    X1 = X[:, 1:]

    U, S, Vh = svd(X0, full_matrices=False)

    if rank is not None:
        U = U[:, :rank]
        S = S[:rank]
        Vh = Vh[:rank, :]

    S_inv = np.diag(1.0 / S)

    A_tilde = U.conj().T @ X1 @ Vh.conj().T @ S_inv
    eigenvalues, W = eig(A_tilde)

    modes = X1 @ Vh.conj().T @ S_inv @ W

    norms = np.linalg.norm(modes, axis=0)
    norms[norms == 0] = 1.0
    modes = modes / norms

    amplitudes = lstsq(modes, X[:, -1].astype(complex), rcond=None)[0]

    return eigenvalues, modes, amplitudes

Deux choix délibérés ici. Les modes sont normalisés en L2, car la section 4.2 compare les sous-espaces de mode à travers les fenêtres et des amplitudes non normalisées submergeraient la comparaison. Les amplitudes sont ajustées au dernier instantané plutôt qu'au premier, ce qui signifie qu'une prévision n'élève jamais une valeur propre à une grande puissance - la source de l'explosion numérique qui fait que les signaux DMD naïfs ressemblent à des dynamiques lorsqu'ils sont en virgule flottante.

4. La mesure

Observations transformées en structure d'espace d'état

C'est la partie de l'article qui n'est pas un manuel. Tout ce qui précède est une procédure appropriée ; Vous trouverez ci-dessous le protocole permettant de savoir si l'ajustement signifie quelque chose.

Données. Utilisez les propres données d'échange du projet : un échantillon représentatif de BTC, d'ETH et d'alts liquides sur une grille de minutes ou de ticks cohérente, et non un téléchargement quotidien d'ETF d'actions. Le blog est axé sur la cryptographie et l'argument de la microstructure n'est pas transféré. Construire XX avec des actifs sur les lignes et du temps sur les colonnes, sur les retours de journaux, diminués par actif dans chaque fenêtre.

4.1 Le spectre des valeurs propres

Rapport, pour une fenêtre représentative : combien de rr les valeurs propres se situent dans une tolérance du cercle unité, à quelle période d'oscillation chacune correspond en heures (période =2π/ωj= 2\pi / |\omega_j| barres, converties), et quelle fraction de la variance de rendement est la plus élevée rr modes de reconstruction.

def spectrum_report(eigenvalues: np.ndarray, bar_minutes: float,
                    tol: float = 0.05) -> list[dict]:
    """
    Turn a DMD spectrum into human-readable rows: modulus, period in hours,
    and whether the eigenvalue sits on the unit circle within `tol`.
    """
    rows = []
    for lam in eigenvalues:
        modulus = float(np.abs(lam))
        omega = float(np.angle(lam))
        period_hours = (2 * np.pi / abs(omega)) * bar_minutes / 60 if omega else np.inf
        rows.append({
            "modulus": modulus,
            "period_hours": period_hours,
            "on_unit_circle": abs(modulus - 1.0) < tol,
            "regime": "unstable" if modulus > 1 + tol
                      else "persistent" if abs(modulus - 1.0) <= tol
                      else "decaying",
        })
    return sorted(rows, key=lambda r: -r["modulus"])


def reconstruction_r2(X: np.ndarray, modes: np.ndarray,
                      eigenvalues: np.ndarray, amplitudes: np.ndarray) -> float:
    """Fraction of in-window return variance captured by the truncated modes."""
    n_steps = X.shape[1]
    powers = eigenvalues[:, None] ** np.arange(-(n_steps - 1), 1)
    X_hat = (modes @ (amplitudes[:, None] * powers)).real
    resid = np.var(X - X_hat)
    return 1.0 - resid / np.var(X)

Un rapport honnête sur le spectre vaut déjà l’article. Si rien ne se situe à proximité du cercle unitaire, il n'y a pas de cycles persistants à négocier et l'histoire de la « saisonnalité annuelle » de la littérature sur les actions ne se répercute tout simplement pas sur la cryptographie 24h/24 et 7j/7.

4.2 Stabilité du mode sur les fenêtres adjacentes

L'épreuve décisive. Installer DMD sur la fenêtre tt, puis sur la fenêtre t+1t+1, et mesurez la quantité de sous-espace du mode dominant qui survit. La bonne statistique n'est pas une corrélation naïve de vecteurs de mode — l'ordre des modes et la phase complexe sont arbitraires — mais les angles principaux entre les deux sous-espaces.

def subspace_stability(modes_a: np.ndarray, modes_b: np.ndarray,
                       k: int = 3) -> float:
    """
    Overlap between the leading-k DMD mode subspaces of two adjacent windows.

    Returns the mean cosine of the principal angles: 1.0 = identical subspace,
    0.0 = orthogonal. Immune to mode reordering and complex phase, both of
    which are arbitrary in a DMD fit.
    """
    Qa, _ = np.linalg.qr(modes_a[:, :k])
    Qb, _ = np.linalg.qr(modes_b[:, :k])
    sing = np.linalg.svd(Qa.conj().T @ Qb, compute_uv=False)
    return float(np.mean(np.clip(sing, 0.0, 1.0)))


def stability_curve(returns: np.ndarray, window: int, step: int,
                    rank: int, k: int = 3) -> np.ndarray:
    """Subspace overlap between every pair of adjacent windows."""
    fits = []
    for t_end in range(window, returns.shape[1], step):
        evals, modes, _ = dmd(returns[:, t_end - window:t_end], rank=rank)
        order = np.argsort(-np.abs(evals))
        fits.append(modes[:, order])
    return np.array([subspace_stability(fits[i], fits[i + 1], k=k)
                     for i in range(len(fits) - 1)])

Rapportez la distribution de ce chevauchement et rapportez-la par rapport à une valeur nulle : la même statistique calculée sur des substituts randomisés en phase des mêmes rendements. Un chevauchement élevé mais pas supérieur au nul de substitution signifie que les modes suivent la structure de covariance, pas la dynamique.

4.3 Rayon spectral roulant par rapport à la volatilité réalisée

La prétention de tester : ρt=maxjλj\rho_t = \max_j |\lambda_j|, recalculé sur des fenêtres glissantes, évolue avant la volatilité réalisée plutôt qu'avec elle. Le scalaire est mécaniquement nouveau – le blog a déjà surveillé des scalaires géométriques en temps réel, notamment la courbure de Kobayashi dans variétés complexes pour le trading algorithmique – mais un module de valeur propre de Koopman est une quantité différente avec un mode de défaillance différent, et il mérite son propre test d'avance-retard plutôt qu'un pas hérité.

def rolling_spectral_radius(returns: np.ndarray, window: int = 1440,
                            step: int = 60, rank: int = 5) -> dict:
    """
    Rolling DMD spectrum for regime monitoring.

    returns : np.ndarray, shape (n_assets, n_timesteps)
    window  : rolling window length in bars
    step    : bars between refits
    """
    idx, radii, dom_freq = [], [], []

    for t_end in range(window, returns.shape[1], step):
        X_win = returns[:, t_end - window:t_end]
        try:
            evals, _, _ = dmd(X_win, rank=rank)
        except np.linalg.LinAlgError:
            continue

        idx.append(t_end)
        radii.append(float(np.max(np.abs(evals))))

        on_circle = np.abs(np.abs(evals) - 1.0) < 0.1
        if on_circle.any():
            sel = evals[on_circle]
            dom_freq.append(float(np.abs(np.angle(sel[np.argmax(np.abs(sel))])) / (2 * np.pi)))
        else:
            dom_freq.append(0.0)

    return {"index": np.array(idx),
            "spectral_radius": np.array(radii),
            "dominant_frequency": np.array(dom_freq)}


def lead_lag(signal: np.ndarray, target: np.ndarray, max_lag: int = 24) -> dict:
    """
    Cross-correlation of `signal` against `target` over +/- max_lag steps.
    A peak at negative lag means the signal LEADS the target.
    """
    s = (signal - signal.mean()) / (signal.std() + 1e-12)
    y = (target - target.mean()) / (target.std() + 1e-12)
    lags = np.arange(-max_lag, max_lag + 1)
    corrs = []
    for L in lags:
        if L < 0:
            corrs.append(float(np.corrcoef(s[:L], y[-L:])[0, 1]))
        elif L > 0:
            corrs.append(float(np.corrcoef(s[L:], y[:-L])[0, 1]))
        else:
            corrs.append(float(np.corrcoef(s, y)[0, 1]))
    corrs = np.array(corrs)
    return {"lags": lags, "corr": corrs, "peak_lag": int(lags[np.argmax(np.abs(corrs))])}

Aligner spectral_radius à la volatilité réalisée calculée sur la même grille et lue peak_lag. Un pic au décalage 0 signifie ρt\rho_t est un retraitement de la volatilité avec des étapes supplémentaires. Un pic à décalage négatif, stable dans l’ensemble de l’échantillon et dans tous les rangs, est la seule version de cet article contenant une affirmation négociable.

Une note sur ce qui se passe après un résultat positif

Si ρt\rho_t est en tête, la prochaine étape évidente est la construction transversale : classer les actifs en fonction du rendement de la prochaine étape prévu par le DMD, être long sur les gagnants prédits et vendre à découvert sur les perdants prédits. Cette construction n'est pas nouvelle ici - il s'agit du commerce de facteurs-résiduels déjà couvert dans arbitrage statistique et trading de paires en crypto et la section 4 de arbitrage complexe avec vecteurs et matrices ; la seule particularité véritablement spécifique à Koopman est que les portefeuilles propres comportent des valeurs propres variables dans le temps plutôt que des chargements PCA statiques.

La section stratégie est volontairement absente de cet article car elle n'a pas été backtestée ici avec les frais et les slippages. Quand c'est le cas, il doit franchir la barre du blog : le test de signification dans le ratio de Sharpe dégonflé et les tests multiples, contre le contre-exemple permanent du négatif honnête. Un tracé du spectre DMD n’est pas un résultat.

Pour la prévision en une étape elle-même, l'implémentation correcte prédit à partir du dernier instantané plutôt que de propager une valeur propre à la puissance de la longueur de la fenêtre :

def dmd_one_step(returns: np.ndarray, rank: int = 4) -> np.ndarray:
    """
    One-step-ahead prediction from the final snapshot of the window.

    Never raise eigenvalues to the window length: any |lambda| != 1 then
    overflows or underflows and the "signal" becomes numerical garbage.
    """
    evals, modes, amplitudes = dmd(returns, rank=rank)
    return (modes @ (evals * amplitudes)).real

5. DMD étendu (EDMD) : observables non linéaires

Champ de fonctionnalités non linéaires étendant les modes dynamiques

Le DMD standard fonctionne sur le vecteur d’état brut. EDMD extrait d'abord les données via un dictionnaire de fonctions de base non linéaires.

Étant donné un dictionnaire D={d1,,dp}\mathbf{D} = \{d_1, \ldots, d_p\} de fonctions scalaires di:RnRd_i: \mathbb{R}^n \to \mathbb{R}, définissez l'état levé :

zk=[d1(xk)d2(xk)dp(xk)]Rpz_k = \begin{bmatrix} d_1(x_k) \\ d_2(x_k) \\ \vdots \\ d_p(x_k) \end{bmatrix} \in \mathbb{R}^p

EDMD cherche KRp×pK \in \mathbb{R}^{p \times p} avec zk+1Kzkz_{k+1} \approx K z_k. Dans la convention utilisée par le code ci-dessous — indique sous forme de colonnes, KK agissant à gauche :

K=AG1,G=k=0m1zkzkT,A=k=0m1zk+1zkTK = A G^{-1}, \quad G = \sum_{k=0}^{m-1} z_k z_k^{T}, \quad A = \sum_{k=0}^{m-1} z_{k+1} z_k^{T}

Type de dictionnaire Fonctions Captures
Polynôme xi, xixj, xi2,x_i,\ x_i x_j,\ x_i^2, \ldots Interactions non linéaires entre actifs
Base radiale (RBF) exp(γxck2)\exp(-\gamma \lVert x - c_k \rVert^2) Similitude locale, regroupement de régimes
Intégration temporisée xk, xk1,, xkτx_k,\ x_{k-1}, \ldots,\ x_{k-\tau} Mémoire / structure autorégressive
Fourier sin(2πfjt), cos(2πfjt)\sin(2\pi f_j t),\ \cos(2\pi f_j t) Périodicités connues (intrajournalières, hebdomadaires)
Caractéristiques de volatilité rt, rt2\lvert r_t \rvert,\ r_t^2 Hétéroscédasticité, vol clustering

Le dictionnaire est l'endroit où la connaissance du domaine entre en jeu, et la ligne de retard est la raison spécifique à Koopman de se soucier des coordonnées de retard : il ne s'agit pas d'une technique distincte intégrée, elles constituent un bloc supplémentaire de la carte de levage. L'intégration elle-même - choix du délai τ\tau, intégration de la dimension dd, et le théorème de reconstruction qui le sous-tend — est déjà introduit et codé dans variétés complexes pour le trading algorithmique ; prenez les vecteurs de retard à partir de là et introduisez-les directement dans build_financial_dictionary comme lignes supplémentaires.

import numpy as np
from itertools import combinations_with_replacement

def build_financial_dictionary(X: np.ndarray, max_poly_degree: int = 2,
                               include_volatility: bool = True,
                               delay_steps: int = 0) -> np.ndarray:
    """
    Build a dictionary of nonlinear observables for EDMD.

    X : np.ndarray, shape (n_features, n_snapshots)
    Returns Z of shape (n_dict, n_snapshots - delay_steps).
    """
    n_features, n_snapshots = X.shape
    offset = max(delay_steps, 0)
    X_eff = X[:, offset:]
    n_eff = X_eff.shape[1]

    lifted = [X_eff]  # degree-1 terms (identity)

    if max_poly_degree >= 2:
        for deg in range(2, max_poly_degree + 1):
            for combo in combinations_with_replacement(range(n_features), deg):
                term = np.ones(n_eff)
                for idx in combo:
                    term *= X_eff[idx]
                lifted.append(term.reshape(1, -1))

    if include_volatility:
        lifted.append(np.abs(X_eff))   # absolute returns
        lifted.append(X_eff ** 2)      # squared returns

    for d in range(1, delay_steps + 1):
        lifted.append(X[:, offset - d : n_snapshots - d])

    return np.vstack(lifted)


def edmd(X: np.ndarray, dictionary_fn=None, reg: float = 1e-8,
         **dict_kwargs) -> tuple:
    """
    Extended Dynamic Mode Decomposition.

    Solves Z1 ~= K @ Z0 in the least-squares sense. Uses a least-squares
    solve rather than an explicit Gram inverse: `inv` on a near-singular
    dictionary Gram matrix is how EDMD spectra get silently corrupted.
    """
    if dictionary_fn is None:
        dictionary_fn = lambda x: build_financial_dictionary(x, **dict_kwargs)

    Z = dictionary_fn(X)
    Z0, Z1 = Z[:, :-1], Z[:, 1:]

    p = Z0.shape[0]
    G = Z0 @ Z0.T + reg * np.eye(p)   # regularised Gram matrix
    A = Z1 @ Z0.T

    K = np.linalg.solve(G, A.T).T

    eigenvalues, eigenvectors = np.linalg.eig(K)
    return K, eigenvalues, eigenvectors

6. Réseaux Deep Koopman

Dynamique linéaire latente d'apprentissage en réseau profond

Le dictionnaire EDMD est élaboré à la main, ce qui constitue une réelle limitation lorsque le sous-espace invariant de Koopman est inconnu. Les réseaux Deep Koopman apprennent conjointement le levage et l'opérateur.

L'architecture est un auto-encodeur - encodeur, code latent, décodeur, perte de reconstruction, le tout comme introduit dans détection d'anomalies dans algotrading - avec un ajout qui est tout l'intérêt de la section : une perte de linéarité forçant la dynamique latente à travers une seule matrice apprise KK.

x_k  -->  [Encoder φ]  -->  z_k  -->  [Linear K]  -->  z_{k+1}  -->  [Decoder ψ]  -->  x̂_{k+1}
                              |                            |
                              +--- Linearity loss: ‖z_{k+1} - K z_k‖ ---+

L=xk+1ψ(Kφ(xk))2prediction+αφ(xk+1)Kφ(xk)2linearity+βxkψ(φ(xk))2reconstruction\mathcal{L} = \underbrace{\lVert x_{k+1} - \psi(K \varphi(x_k)) \rVert^2}_{\text{prediction}} + \alpha \underbrace{\lVert \varphi(x_{k+1}) - K \varphi(x_k) \rVert^2}_{\text{linearity}} + \beta \underbrace{\lVert x_k - \psi(\varphi(x_k)) \rVert^2}_{\text{reconstruction}}

Sans le α\alpha terme, vous avez un auto-encodeur ordinaire dont l'espace latent est suivi d'une multiplication matricielle. Avec lui, le réseau est pénalisé pour toute représentation latente dont l'évolution n'est pas linéaire — ce qui rend l'apprenant KK une approximation de Koopman et ses valeurs propres comparables au spectre DMD de la section 4.

import torch
import torch.nn as nn

class DeepKoopman(nn.Module):
    """Deep Koopman autoencoder: learned lifting + linear latent dynamics."""

    def __init__(self, input_dim: int, latent_dim: int, hidden_dim: int = 128):
        super().__init__()

        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, latent_dim),
        )
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, input_dim),
        )
        self.K = nn.Linear(latent_dim, latent_dim, bias=False)

    def encode(self, x: torch.Tensor) -> torch.Tensor:
        return self.encoder(x)

    def decode(self, z: torch.Tensor) -> torch.Tensor:
        return self.decoder(z)

    def forward(self, x_k: torch.Tensor) -> dict:
        z_k = self.encode(x_k)
        z_k1_pred = self.K(z_k)
        return {
            "z_k": z_k,
            "z_k1_pred": z_k1_pred,
            "x_k1_pred": self.decode(z_k1_pred),
            "x_k_recon": self.decode(z_k),
        }

    def multi_step_predict(self, x_0: torch.Tensor, n_steps: int) -> torch.Tensor:
        """Roll out by repeated application of the linear operator."""
        z = self.encode(x_0)
        preds = []
        for _ in range(n_steps):
            z = self.K(z)
            preds.append(self.decode(z))
        return torch.stack(preds, dim=1)

    def latent_spectrum(self) -> np.ndarray:
        """Eigenvalues of the learned K — directly comparable to DMD's."""
        return np.linalg.eigvals(self.K.weight.detach().cpu().numpy())


def koopman_loss(model: DeepKoopman, x_k: torch.Tensor, x_k1: torch.Tensor,
                 alpha: float = 1.0, beta: float = 0.5) -> torch.Tensor:
    out = model(x_k)
    z_k1_true = model.encode(x_k1)

    prediction = nn.functional.mse_loss(out["x_k1_pred"], x_k1)
    linearity = nn.functional.mse_loss(out["z_k1_pred"], z_k1_true)
    reconstruction = nn.functional.mse_loss(out["x_k_recon"], x_k)

    return prediction + alpha * linearity + beta * reconstruction

latent_spectrum C'est ce qui fait que cela vaut la peine d'être formé plutôt que de rechercher un modèle de séquence : l'opérateur appris est toujours une matrice, donc le test de stabilité de la section 4.2 et le test d'avance-retard de la section 4.3 s'appliquent inchangés à un modèle profond.

7. Considérations pratiques et pièges

Navigation de la dérive du modèle et des observations éparses

Sélection du rang. Le rang de troncature rr est un cadran de biais-variance - trop faible manque la dynamique, trop élevé correspond au bruit. Ne regardez pas un coude à valeur singulière ; le blog répond déjà correctement "combien de composants avant d'ajuster le bruit", avec le lien Marchenko-Pastur de la théorie des matrices aléatoires dans [arbitrage complexe avec vecteurs et matrices] (/en/blog/post/complex-arbitrage-vector-matrix). Conservez les composants dont les valeurs singulières dépassent le bord de Marchenko-Pastur pour la forme de votre fenêtre et indiquez le résultat. rr explicitement à côté de chaque spectre que vous publiez.

Longueur de la fenêtre. La théorie de Koopman suppose une valeur fixe FF; les marchés n’en fournissent pas. Les radoubs continus sont obligatoires, et la courbe de stabilité de la section 4.2 est précisément le diagnostic permettant de savoir si la fenêtre choisie est suffisamment longue pour effectuer une estimation et suffisamment courte pour rester dans un régime.

Sensibilité au bruit. Les données financières ont un faible rapport signal/bruit et le DMD standard est biaisé par le bruit dans XX. Remèdes à essayer avant de conclure que les modes sont instables :

  • Total DMD (TDMD) — traite les deux XX et XX' aussi bruyant via les moindres carrés totaux.
  • DMD optimisé — optimise directement la décomposition en mode valeur propre par rapport à la norme de Frobenius résiduelle.
  • Kernel EDMD — fonctionne implicitement dans un espace de fonctionnalités de grande dimension sans créer de dictionnaire.

Si la stabilité du mode augmente sensiblement sous TDMD, l'instabilité était du bruit de mesure. Si ce n’est pas le cas, c’est le marché.

8. Où se trouve DMD

Décomposition en mode dynamique parmi les approches de modélisation

Méthode Linéarité Interprétable Prévisions en plusieurs étapes
DMD Linéaire dans l'espace d'état Oui (modes + valeurs propres) Stable (puissance matricielle)
EDMD Linéaire dans un espace surélevé Oui, étant donné le dictionnaire Stable (puissance matricielle)
Koopman profond Linéaire dans l'espace appris Modéré (inspecter K latent) Stable (puissance matricielle)

Pour les prévisions spécifiques à la volatilité, le point de comparaison est la famille GARCH — voir Prévision de la volatilité GARCH pour la crypto. Pour les modèles de séquence entièrement non linéaires et les compromis d'interprétabilité et d'accumulation d'erreurs qu'ils comportent, voir le Temporal Fusion Transformer in trading.

Le créneau qu'occupe DMD est étroit mais réel : des prévisions en plusieurs étapes générées par une seule puissance matricielle plutôt que par un déploiement autorégressif, avec chaque mode inspectable. La question de savoir si cette niche contient de l'alpha est la question de la section 4, pas celle de ce tableau.

Conclusion

Trajectoires turbulentes se résolvant en modes stables

La théorie de Koopman est une manière véritablement élégante d’examiner la dynamique du marché, et l’élégance est exactement la raison pour laquelle elle nécessite le test le plus sévère disponible. Les plats à emporter :

  1. Ajustez DMD, puis testez immédiatement l'ajustement. Le chevauchement du sous-espace entre les fenêtres adjacentes, mesuré par rapport à un zéro randomisé en phase, vous indique en un après-midi si vous avez trouvé une structure ou mémorisé une fenêtre.
  2. Le rayon spectral de roulement est le seul scalaire qui mérite d'être surveillé, et sa valeur dépend entièrement du résultat avance-retard. Au décalage 0, c'est un proxy de volatilité ; en cas de décalage négatif, c'est un avertissement de régime.
  3. Ancrez les amplitudes au dernier instantané et n'augmentez jamais les valeurs propres à la longueur de la fenêtre. Une grande partie des « signaux » DMD dans la nature sont des artefacts à virgule flottante.
  4. Choisissez le classement par Marchenko-Pastur, pas en regardant du coude, et publiez le classement pour chaque spectre.
  5. Un tracé de spectre n'est pas un résultat. Toute stratégie construite sur cette base doit survivre aux frais, aux dérapages et à un test de Sharpe dégonflé avant de compter.

Pour les implémentations au-delà de celles-ci, la bibliothèque PyDMD couvre de manière exhaustive les variantes DMD, et le code de référence de Mallen et al. couvre le côté profond de Koopman.

Les marchés resteront désordonnés, non stationnaires et partiellement observés. La théorie de Koopman propose une optique de principe pour extraire la structure de ce désordre – à condition de vérifier que la structure est toujours là la semaine prochaine.


Références et lectures complémentaires :- B. O. Koopman, « Systèmes hamiltoniens et transformation dans l'espace Hilbert », Actes de l'Académie nationale des sciences, 1931.

  • J. H. Tu et al., "Sur la décomposition en mode dynamique : théorie et applications", Journal of Computational Dynamics, 2014.
  • M. O. Williams, I. G. Kevrekidis, C. W. Rowley, "Une approximation basée sur les données de l'opérateur Koopman : extension de la décomposition en mode dynamique", Journal of Nonlinear Science, 2015.
  • B. Lusch, J. N. Kutz, S. L. Brunton, "Apprentissage profond pour les intégrations linéaires universelles de dynamique non linéaire", Nature Communications, 2018.
  • J. Mann et J. N. Kutz, « Décomposition en mode dynamique pour les stratégies de trading financier », Quantitative Finance, 2016.
  • A. Mallen et al., « Koopman Neural Forecaster for Time Series with Temporal Distribution Shifts », ICML, 2023.
  • E. Gonzalez et M. Generelo, "Analyse de modèles économiques chaotiques à travers les opérateurs de Koopman, EDMD, théorème de Takens et Machine Learning", Data Science in Finance and Economics, 2022.
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.