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 strategy’s theoretical underpinnings are rooted in the concepts of path signatures and their properties:
depth
.
signature_window
.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.
backtrader
StrategyThe 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.
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
'figure.figsize'] = (10, 6)
plt.rcParams[
# Download data and run backtest
= yf.download('BTC-USD', '2021-01-01', '2024-01-01')
data = data.columns.droplevel(1)
data.columns = bt.feeds.PandasData(dataname=data) data_feed
Analysis of this Snippet:
matplotlib.pyplot
is configured with %matplotlib inline
, indicating that
plots will be displayed directly within the output area.
plt.rcParams['figure.figsize']
sets the default size for
generated plots.yfinance.download('BTC-USD', ...)
fetches historical data
for Bitcoin. The data.columns.droplevel(1)
call ensures
column headers (e.g., ‘Close’, ‘Volume’) are in a single-level format,
which backtrader
expects.bt.feeds.PandasData(dataname=data)
converts the prepared
pandas
DataFrame into a data feed for
backtrader
, enabling the backtesting engine to process it
bar by bar.RoughPathMomentumStrategy
InitializationThis 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:
params
: These parameters are
adjustable controls for the strategy. signature_window
defines the number of past price increments (returns) used to calculate
the path signature. signature_depth
specifies the maximum
level of iterated integrals to compute. momentum_threshold
sets the magnitude of the momentum signature required to trigger a
trade. trailing_stop_pct
defines the risk management for
the trailing stop.self.close
references the closing price, and
self.returns
calculates daily percentage changes, which
serve as the “increments” for building the path.self.path_increments
is a list that will store a rolling
window of these daily returns, forming the “path” for signature
calculation.momentum_signature
for the current bar.backtrader
variables (order
, stop_order
) are initialized
for managing trade orders.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
= np.array(increments)
increments = len(increments)
n
# Level 1 signature: ∫ dX (cumulative sum of increments)
# This represents the net displacement of the path.
= np.sum(increments)
level1
# Level 2 signature: ∫∫ dX ⊗ dX (iterated integrals)
# This captures the interaction between consecutive increments, hinting at persistence or meander.
= 0
level2 for i in range(n-1):
for j in range(i+1, n):
+= increments[i] * increments[j]
level2
# Level 3 signature: ∫∫∫ dX ⊗ dX ⊗ dX (higher-order iterated integrals)
# This captures more complex interactions and shape characteristics.
= 0
level3 for i in range(n-2):
for j in range(i+1, n-1):
for k in range(j+1, n):
+= increments[i] * increments[j] * increments[k]
level3
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
= self.calculate_path_signature(increments)
level1, level2, level3
# 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.
= len(increments)
path_length
= (level1 * 0.5 + # Direct momentum (weighted 0.5)
momentum_sig * 0.3 / path_length + # Correlation momentum (weighted 0.3, normalized by length)
level2 * 0.2 / (path_length**2)) # Pattern momentum (weighted 0.2, normalized by length squared)
level3
return momentum_sig
Analysis of Path Signatures and Momentum Extraction:
calculate_path_signature(increments)
:
This function implements a simplified calculation of path
signatures.
increments
(returns). This is equivalent to the net displacement of the path.increments
from different time points.
It is crucial to note that a mathematically rigorous path
signature involves iterated integrals, which are more complex than
simple sums of products. This implementation provides an
approximation for exploration.extract_momentum_signature(increments)
:
This function attempts to create a single “momentum signature” by
combining the calculated signature levels. The weights (0.5, 0.3, 0.2)
and normalization by path_length
are arbitrary choices. The
hypothesis here is that this combination yields a robust measure of
momentum that captures directional bias, correlation structure, and
higher-order patterns.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
= len(increments) // 2
mid = increments[:mid]
first_half = increments[mid:]
second_half
# Calculate momentum signatures for each half
= self.extract_momentum_signature(first_half)
sig1 = self.extract_momentum_signature(second_half)
sig2
# 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
= self.close[0]
current_price
if self.position.size > 0: # Long position
if current_price > self.highest_price_since_entry:
self.highest_price_since_entry = current_price
= self.highest_price_since_entry * (1 - self.params.trailing_stop_pct)
new_stop_price
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
= self.lowest_price_since_entry * (1 + self.params.trailing_stop_pct)
new_stop_price
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:
is_signature_invariant(increments)
:
This function attempts to assess the “stability” or “invariance” of the
path’s structure. It does so by splitting the path into two halves,
calculating a momentum signature for each half, and then checking if
these two signatures are “similar enough” (i.e., their absolute
difference is below a threshold derived from
momentum_threshold
). The hypothesis is that a stable path
structure is more likely to continue or be predictable.notify_order()
: This
backtrader
method manages order status updates. Upon a
successful trade (order.Completed
), it places an initial
trailing stop-loss order (bt.Order.Stop
).
This order type dynamically moves the stop price to protect profits. The
logic also handles resetting internal tracking variables
(entry_price
, trailing_stop_price
,
highest/lowest_price_since_entry
) once a position is
closed.update_trailing_stop()
: This function
manually updates the trailing stop price for open positions. It
continuously monitors the highest_price_since_entry
(for
longs) or lowest_price_since_entry
(for shorts) and, if a
new extreme is reached, recalculates a new, more favorable stop price.
If this new stop is indeed better, the old stop order is cancelled and a
new one is placed. This provides fine-grained control over the trailing
stop.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
= self.path_increments[-self.params.signature_window:]
recent_path = self.extract_momentum_signature(recent_path)
momentum_sig self.momentum_signature = momentum_sig # Store for potential visualization or analysis
# Check if the path's structure is stable/invariant
= self.is_signature_invariant(recent_path)
is_stable
# --- 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):
self.update_trailing_stop()
is called at the very beginning
of next()
, ensuring that any open position’s stop-loss is
updated immediately with the latest price information.if self.order is not None: return
prevents the strategy
from placing new main trade orders if one is already pending
completion.self.path_increments
is populated with the current bar’s
returns. The list is then trimmed to maintain a rolling window of
increments for signature calculation.extract_momentum_signature()
calculates the composite
momentum signal for the current signature_window
.
is_stable = self.is_signature_invariant()
then assesses the
consistency of the path’s structure.momentum_sig
must exceed the
momentum_threshold
in magnitude (indicating strong
directional momentum).is_stable
flag must be True
(indicating that the path’s structure is consistent and potentially
reliable). If both conditions are met, the strategy then decides to buy
(for positive momentum) or sell (for negative momentum), or to close an
opposing position if one is open.self.buy()
,
self.sell()
, self.close()
are
backtrader
commands for executing trades.
self.cancel(self.stop_order)
is used to cancel any existing
stop-loss order before closing a position.This section sets up backtrader
’s core engine, adds the
strategy and data, configures the broker, and executes the
simulation.
= bt.Cerebro()
cerebro
cerebro.addstrategy(RoughPathMomentumStrategy)
cerebro.adddata(data_feed)=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission
print(f'Start: ${cerebro.broker.getvalue():,.2f}')
= cerebro.run()
results print(f'End: ${cerebro.broker.getvalue():,.2f}')
print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')
# Fix matplotlib plotting issues
'figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000
plt.rcParams[
try:
=False, style='candlestick', volume=False)
cerebro.plot(iplot
plt.show()except Exception as e:
print(f"Plotting error: {e}")
print("Strategy completed successfully - plotting skipped")
Analysis of the Backtest Execution:
cerebro = bt.Cerebro()
: This line
initializes the central backtrader
engine responsible for
running the simulation.cerebro.addstrategy(...)
registers the defined Rough Path
Momentum strategy, and cerebro.adddata(...)
feeds it the
historical data for the simulation.cerebro.addsizer(...)
,
cerebro.broker.setcash(...)
, and
cerebro.broker.setcommission(...)
configure the initial
capital, the percentage of capital to use per trade, and simulate
trading commissions, contributing to a more realistic backtest
environment.cerebro.run()
: This command initiates
the entire backtest simulation, allowing the strategy to execute its
logic sequentially through the historical data bars. The
results
variable stores the outcome of the backtest.print
statements provide a straightforward summary of the simulation,
displaying the starting and ending portfolio values, along with the
overall percentage return achieved by the strategy over the backtest
period.plt.rcParams
lines configure matplotlib
for plotting, potentially
preventing warnings with large datasets. The cerebro.plot()
call generates a visual representation of the backtest. It is configured
to use a candlestick
style. The volume=False
setting for the plot means the raw volume bars will not be
displayed.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.
calculate_path_signature
function provides a simplified
approximation of true iterated integrals. A rigorous application of
Rough Path Theory typically involves dedicated libraries (e.g.,
esig
in Python) that handle the complex mathematical
nuances more accurately. The extent to which this simplification
captures the theoretical power of signatures is a critical area for
research.signature_window
values, impacting backtesting speed.signature_window
,
momentum_threshold
, and the arbitrary weights in
extract_momentum_signature
, are non-trivial. These are
deeply empirical questions that require extensive experimentation and
may be highly dependent on the asset and timeframe.is_signature_invariant
function’s method of assessing
stability (comparing sub-path signatures) is a conceptual exploration.
How well does this simple comparison truly capture the mathematical
notion of “invariance” or a stable market regime for trading?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.