← Back to Home
An Algorithmic Exploration of Rough Path Momentum

An Algorithmic Exploration of Rough Path Momentum

Beyond Price Points: The Geometry of Financial Paths

Traditional quantitative finance often treats price data as a discrete series of points in time. A candle represents a static snapshot of open, high, low, and close. Momentum is frequently measured by simple percentage changes or the slope of a moving average. But what if there’s a richer, more profound structure embedded within the continuous evolution of price? What if the way price moves from one point to another – its twists, turns, and interactions – holds more information than just its start and end values?

This is the fascinating realm of Rough Path Theory. Originating from advanced mathematics, Rough Path Theory provides a robust framework for understanding and analyzing highly irregular, non-differentiable paths (like financial time series). Instead of viewing price as a sequence of discrete observations, it treats price as a continuous “path” and offers tools to extract universal information about its geometric properties.

The central concept in Rough Path Theory for financial applications is the path signature. The signature is a sequence of iterated integrals that uniquely “encodes” the shape of a path. It doesn’t just tell us where a path ended, but how it got there. For instance, it can distinguish between two paths that start and end at the same points but took vastly different trajectories.

This article embarks on an algorithmic exploration of a Rough Path Momentum Strategy. We aim to investigate whether this sophisticated mathematical lens can reveal novel insights into market momentum, potentially identifying persistent trends or regimes that are less apparent with conventional indicators. Can the geometry of price paths offer a new dimension for signal generation?


The Theory: Signatures, Momentum, and Stability

The strategy’s theoretical underpinnings are rooted in the concepts of path signatures and their properties:

  1. Path Signatures:
    • Imagine a path as a continuous journey. The path signature is an infinite sequence of iterated integrals over the path’s dimensions. Each term in the sequence captures a higher-order interaction or “meander” of the path.
    • Universal Property: A remarkable property of signatures is that they uniquely determine the path itself (up to reparameterization). This means the signature is a comprehensive, non-lossy summary of the path’s shape.
    • Levels (Truncation Depth): In practice, the infinite sequence is truncated to a certain depth.
      • Level 1 (\\int dX_t): This is simply the net displacement or total change of the path (e.g., the cumulative return). It captures the overall direction.
      • Level 2 (\\int \\int dX_s \\otimes dX_t): These are iterated integrals. They capture information about the order of movements and the “cross-variation” between dimensions if the path is multi-dimensional. For a single-dimensional path (like price), the 2nd level captures aspects of its “meander” or how it folds back on itself. It hints at persistence or mean-reversion.
      • Level 3 and higher: These capture increasingly subtle and complex interactions and shapes within the path.
    • Hypothesis: By examining these signature terms, especially higher-order ones, we might uncover richer patterns of momentum that simple net changes cannot reveal.
  2. Signature Momentum:
    • The strategy hypothesizes that a specific combination of these signature levels can quantify a unique “momentum signature.” This isn’t just about the current price change, but about the persistent geometric shape of the price movement over a signature_window.
    • Hypothesis: A strong positive “momentum signature” might indicate a robust upward path geometry, suggesting a bullish trend, while a strong negative one suggests a bearish trend.
  3. Signature Invariance / Stability:
    • Rough path theory also deals with the concept of rough path “invariance” or stability, implying consistency in the path’s geometric structure.
    • Hypothesis: If the “momentum signature” extracted from sub-sections of a path is similar, it suggests the path’s shape is stable and potentially more predictable. Trading signals might be more reliable when the underlying path structure is stable.

The overarching strategy idea is to investigate whether quantifying the rich geometric information embedded in price paths using signatures can provide novel and robust momentum signals, potentially leading to more effective trading decisions than traditional methods.


Algorithmic Implementation: A backtrader Strategy

The following backtrader code provides a concrete implementation for exploring a Rough Path Momentum strategy. Each snippet will be presented and analyzed to understand how the theoretical ideas are translated into executable code.

Step 1: Initial Setup and Data Loading

Every quantitative exploration begins with data. This section prepares the environment and fetches historical price data.

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline 
plt.rcParams['figure.figsize'] = (10, 6)

# Download data and run backtest
data = yf.download('BTC-USD', '2021-01-01', '2024-01-01')
data.columns = data.columns.droplevel(1)
data_feed = bt.feeds.PandasData(dataname=data)

Analysis of this Snippet:

Step 2: Defining the Rough Path Momentum Strategy: RoughPathMomentumStrategy Initialization

This section outlines the RoughPathMomentumStrategy class, including its parameters and the initialization of variables for tracking price movements and signature components.

class RoughPathMomentumStrategy(bt.Strategy):
    params = (
        ('signature_window', 30),    # Window for path signature calculation (number of increments)
        ('signature_depth', 3),      # Signature truncation level (up to Level 3)
        ('momentum_threshold', 0.1), # Momentum signature threshold for trade signals
        ('trailing_stop_pct', 0.05), # Trailing stop percentage
    )
    
    def __init__(self):
        self.close = self.data.close
        self.returns = bt.indicators.PctChange(self.close, period=1) # Calculate daily returns (increments)
        
        # Path data storage: stores historical returns (increments)
        self.path_increments = []
        
        # Signature components storage for the current bar
        self.level1_signature = 0
        self.level2_signature = 0
        self.level3_signature = 0
        self.momentum_signature = 0
        
        # Trailing stop tracking variables
        self.entry_price = 0
        self.trailing_stop_price = 0
        self.highest_price_since_entry = 0
        self.lowest_price_since_entry = 0
        
        # backtrader's order tracking variables
        self.order = None
        self.stop_order = None

Analysis of the __init__ Method:

Step 3: Calculating Path Signatures and Extracting Momentum

This section contains the core mathematical functions for computing simplified path signatures and combining them into a “momentum signature.”

    def calculate_path_signature(self, increments):
        """Calculate rough path signature up to specified depth"""
        # Needs at least 2 increments to form a path segment
        if len(increments) < 2:
            return 0, 0, 0
        
        increments = np.array(increments)
        n = len(increments)
        
        # Level 1 signature: ∫ dX (cumulative sum of increments)
        # This represents the net displacement of the path.
        level1 = np.sum(increments)
        
        # Level 2 signature: ∫∫ dX ⊗ dX (iterated integrals)
        # This captures the interaction between consecutive increments, hinting at persistence or meander.
        level2 = 0
        for i in range(n-1):
            for j in range(i+1, n):
                level2 += increments[i] * increments[j]
        
        # Level 3 signature: ∫∫∫ dX ⊗ dX ⊗ dX (higher-order iterated integrals)
        # This captures more complex interactions and shape characteristics.
        level3 = 0
        for i in range(n-2):
            for j in range(i+1, n-1):
                for k in range(j+1, n):
                    level3 += increments[i] * increments[j] * increments[k]
        
        return level1, level2, level3

    def extract_momentum_signature(self, increments):
        """Extract momentum characteristics from path signature"""
        # Ensure enough increments for signature calculation window
        if len(increments) < self.params.signature_window:
            return 0
        
        # Calculate the raw signature components
        level1, level2, level3 = self.calculate_path_signature(increments)
        
        # Store components for potential future analysis or visualization (not directly used for trading here)
        self.level1_signature = level1
        self.level2_signature = level2
        self.level3_signature = level3
        
        # Combine signature levels into a composite momentum signature
        # Hypothesis: Level 1 provides overall direction, Level 2 captures correlation/persistence,
        # Level 3 captures higher-order patterns. Normalization by path length scales contributions.
        path_length = len(increments)
        
        momentum_sig = (level1 * 0.5 +                     # Direct momentum (weighted 0.5)
                        level2 * 0.3 / path_length +       # Correlation momentum (weighted 0.3, normalized by length)
                        level3 * 0.2 / (path_length**2))   # Pattern momentum (weighted 0.2, normalized by length squared)
        
        return momentum_sig

Analysis of Path Signatures and Momentum Extraction:

Step 4: Assessing Path Stability (Invariance) and Managing Risk

This section includes a function to assess the “stability” or “invariance” of the path’s signature, along with the standard notify_order and update_trailing_stop methods for risk management.

    def is_signature_invariant(self, increments):
        """Check signature stability (invariance property) by comparing signatures of sub-paths."""
        # Needs enough increments to split into meaningful sub-paths
        if len(increments) < 10:
            return False
        
        # Split the path into two halves
        mid = len(increments) // 2
        first_half = increments[:mid]
        second_half = increments[mid:]
        
        # Calculate momentum signatures for each half
        sig1 = self.extract_momentum_signature(first_half)
        sig2 = self.extract_momentum_signature(second_half)
        
        # Hypothesis: If signatures of sub-paths are very similar, the overall path structure is stable.
        # This is checked by comparing the absolute difference to a fraction of the momentum_threshold.
        return abs(sig1 - sig2) < self.params.momentum_threshold * 0.5

    def notify_order(self, order):
        if order.status in [order.Completed]:
            if order.isbuy() and self.position.size > 0:
                self.entry_price = order.executed.price
                self.highest_price_since_entry = order.executed.price
                self.trailing_stop_price = order.executed.price * (1 - self.params.trailing_stop_pct)
                self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trailing_stop_price)
                
            elif order.issell() and self.position.size < 0:
                self.entry_price = order.executed.price
                self.lowest_price_since_entry = order.executed.price
                self.trailing_stop_price = order.executed.price * (1 + self.params.trailing_stop_pct)
                self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trailing_stop_price)
        
        if order.status in [order.Completed, order.Canceled, order.Rejected]:
            if self.stop_order is None or order != self.stop_order:
                self.order = None
            
            if self.stop_order is not None and order == self.stop_order:
                self.stop_order = None
                self.entry_price = 0
                self.trailing_stop_price = 0
                self.highest_price_since_entry = 0
                self.lowest_price_since_entry = 0

    def update_trailing_stop(self):
        if not self.position or self.stop_order is None:
            return
        
        current_price = self.close[0]
        
        if self.position.size > 0: # Long position
            if current_price > self.highest_price_since_entry:
                self.highest_price_since_entry = current_price
                new_stop_price = self.highest_price_since_entry * (1 - self.params.trailing_stop_pct)
                
                if new_stop_price > self.trailing_stop_price:
                    self.trailing_stop_price = new_stop_price
                    self.cancel(self.stop_order)
                    self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trailing_stop_price)
        
        elif self.position.size < 0: # Short position
            if current_price < self.lowest_price_since_entry:
                self.lowest_price_since_entry = current_price
                new_stop_price = self.lowest_price_since_entry * (1 + self.params.trailing_stop_pct)
                
                if new_stop_price < self.trailing_stop_price:
                    self.trailing_stop_price = new_stop_price
                    self.cancel(self.stop_order)
                    self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trailing_stop_price)

Analysis of Path Stability and Risk Management:

Step 5: The Trading Logic: Orchestrating Rough Path Signals

This is the main loop that executes for each new bar of data, collecting path increments, calculating signatures, checking for stability, and generating trading decisions.

    def next(self):
        self.update_trailing_stop() # Update trailing stop first for immediate risk management
        
        if self.order is not None:
            return # Skip if a trade order is pending
        
        # Store path increments (returns) for the current bar
        if not np.isnan(self.returns[0]):
            self.path_increments.append(self.returns[0])
        
        # Keep only the necessary window of recent increments for signature calculation
        if len(self.path_increments) > self.params.signature_window * 2: # Keep a buffer
            self.path_increments = self.path_increments[-self.params.signature_window * 2:]
        
        # Skip if not enough data for the signature window
        if len(self.path_increments) < self.params.signature_window:
            return
        
        # Calculate the momentum signature for the recent price path
        recent_path = self.path_increments[-self.params.signature_window:]
        momentum_sig = self.extract_momentum_signature(recent_path)
        self.momentum_signature = momentum_sig # Store for potential visualization or analysis
        
        # Check if the path's structure is stable/invariant
        is_stable = self.is_signature_invariant(recent_path)
        
        # --- Trading signals based on momentum signatures and stability ---
        # Hypothesis: Trade when there's strong momentum AND the path structure is stable
        if abs(momentum_sig) > self.params.momentum_threshold and is_stable:
            
            # Strong positive momentum signature: Go long or close short
            if momentum_sig > self.params.momentum_threshold:
                if self.position.size < 0: # If currently short, close the position
                    if self.stop_order is not None: self.cancel(self.stop_order)
                    self.order = self.close()
                elif not self.position: # If no position, open a long position
                    self.order = self.buy()
            
            # Strong negative momentum signature: Go short or close long
            elif momentum_sig < -self.params.momentum_threshold:
                if self.position.size > 0: # If currently long, close the position
                    if self.stop_order is not None: self.cancel(self.stop_order)
                    self.order = self.close()
                elif not self.position: # If no position, open a short position
                    self.order = self.sell()

Analysis of next() (The Trade Orchestrator):


Step 6: Executing the Rough Path Momentum Experiment: The Backtest Execution

This section sets up backtrader’s core engine, adds the strategy and data, configures the broker, and executes the simulation.

cerebro = bt.Cerebro()
cerebro.addstrategy(RoughPathMomentumStrategy)
cerebro.adddata(data_feed)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)

print(f'Start: ${cerebro.broker.getvalue():,.2f}')
results = cerebro.run()
print(f'End: ${cerebro.broker.getvalue():,.2f}')
print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')

# Fix matplotlib plotting issues
plt.rcParams['figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000

try:
    cerebro.plot(iplot=False, style='candlestick', volume=False)
    plt.show()
except Exception as e:
    print(f"Plotting error: {e}")
    print("Strategy completed successfully - plotting skipped")

Analysis of the Backtest Execution:


Reflections on Our Rough Path Momentum Expedition

This backtrader strategy represents a highly ambitious and mathematically intriguing attempt to apply concepts from Rough Path Theory to financial momentum trading. It pushes the boundaries beyond traditional indicators by seeking to quantify the intricate “shape” of price paths.

This strategy provides a rich and complex ground for further research into higher-order properties of financial time series. The journey of translating abstract mathematical theories into testable trading algorithms is a continuous and profoundly challenging endeavor in quantitative finance.