Approccio della Distanza nel Pairs Trading: Implementazione e Analisi con Rust
L'Approccio della Distanza nel pairs trading ha acquisito notevole popolarità grazie alla sua elegante semplicità ed efficacia. Questa tecnica identifica coppie di asset tramite misure statistiche e opera basandosi sulla divergenza e convergenza delle loro relazioni di prezzo. Questo articolo fornisce un'analisi completa delle metodologie di base e avanzate dell'Approccio della Distanza, con implementazioni pratiche in Rust pensate per trader ad alta frequenza, sviluppatori algoritmici, matematici e programmatori in cerca di soluzioni robuste.
Visualizzazione dell'Approccio della Distanza: gli asset A e B si inseguono, con segnali di trading generati sulla base della divergenza dello spread (Long/Short)
Fondamenti Teorici dell'Approccio della Distanza
L'Approccio della Distanza stabilisce un framework per il pairs trading basato sui movimenti di prezzo normalizzati tra asset. Nella sua essenza, il metodo utilizza misurazioni della distanza euclidea al quadrato per identificare asset che storicamente si muovono insieme e genera segnali di trading quando la loro divergenza di prezzo normalizzata supera soglie statisticamente significative[2].
Questo approccio si compone di due fasi principali:
- Formazione delle coppie - identificazione di coppie di asset statisticamente correlate
- Generazione dei segnali di trading - creazione di regole di ingresso e uscita basate sulla divergenza
Base Matematica
L'implementazione di base utilizza la distanza euclidea tra serie di prezzi normalizzate. Per due asset con serie temporali di prezzi normalizzati X e Y, calcoliamo:
fn euclidean_squared_distance(x: &[f64], y: &[f64]) -> f64 {
assert_eq!(x.len(), y.len(), "Time series must have equal length");
x.iter()
.zip(y.iter())
.map(|(xi, yi)| (xi - yi).powi(2))
.sum()
}
Questa metrica di distanza aiuta a identificare asset che storicamente si muovono insieme, fornendo la base per le opportunità di arbitraggio statistico[2].
Implementazione di Base dell'Approccio della Distanza
Normalizzazione dei Dati
Prima di calcolare le distanze, è necessario normalizzare i dati di prezzo per stabilire scale comparabili. La normalizzazione min-max è comunemente applicata:
fn min_max_normalize(prices: &[f64]) -> Vec<f64> {
if prices.is_empty() {
return Vec::new();
}
let min_price = prices.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max_price = prices.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let range = max_price - min_price;
if range.abs() < f64::EPSILON {
return vec![0.5; prices.len()];
}
prices.iter()
.map(|&price| (price - min_price) / range)
.collect()
}
Ricerca delle Coppie più Vicine
Identifichiamo le coppie potenziali calcolando la distanza euclidea tra tutte le combinazioni di asset e selezionando quelle con le distanze minori:
#[derive(Debug, Clone)]
struct StockPair {
stock1_idx: usize,
stock2_idx: usize,
distance: f64,
}
impl PartialEq for StockPair {
fn eq(&self, other: &Self) -> bool {
self.distance.eq(&other.distance)
}
}
impl Eq for StockPair {}
impl PartialOrd for StockPair {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.distance.partial_cmp(&other.distance)
}
}
impl Ord for StockPair {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.partial_cmp(other).unwrap_or(std::cmp::Ordering::Equal)
}
}
fn find_closest_pairs(normalized_prices: &[Vec<f64>], top_n: usize) -> Vec<StockPair> {
let stock_count = normalized_prices.len();
let mut pairs = BinaryHeap::new();
for i in 0..stock_count {
for j in (i+1)..stock_count {
let distance = euclidean_squared_distance(&normalized_prices[i], &normalized_prices[j]);
pairs.push(Reverse(StockPair {
stock1_idx: i,
stock2_idx: j,
distance,
}));
// Keep only top N pairs
if pairs.len() > top_n {
pairs.pop();
}
}
}
// Convert from heap to vector and reverse to get ascending order
pairs.into_iter().map(|Reverse(pair)| pair).collect()
}
Calcolo della Volatilità Storica
Il calcolo della volatilità storica è fondamentale per impostare soglie di trading appropriate:
fn calculate_spread_volatility(normalized_price1: &[f64], normalized_price2: &[f64]) -> f64 {
assert_eq!(normalized_price1.len(), normalized_price2.len());
// Calculate price spread
let spread: Vec<f64> = normalized_price1.iter()
.zip(normalized_price2.iter())
.map(|(p1, p2)| p1 - p2)
.collect();
// Calculate mean of spread
let mean = spread.iter().sum::<f64>() / spread.len() as f64;
// Calculate standard deviation
let variance = spread.iter()
.map(|&x| (x - mean).powi(2))
.sum::<f64>() / spread.len() as f64;
variance.sqrt()
}
Metodi di Selezione Avanzati
Filtraggio per Gruppo Industriale
Limitare la selezione delle coppie allo stesso settore industriale può migliorare le performance selezionando asset economicamente correlati:
fn find_industry_pairs(
normalized_prices: &[Vec<f64>],
industry_codes: &[usize],
top_n_per_industry: usize
) -> Vec<StockPair> {
// Group stocks by industry
let mut industry_groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
for (idx, &code) in industry_codes.iter().enumerate() {
industry_groups.entry(code).or_default().push(idx);
}
// Find closest pairs within each industry
let mut all_pairs = Vec::new();
for (_industry_code, stock_indices) in industry_groups {
let mut industry_pairs = Vec::new();
for i in 0..stock_indices.len() {
for j in (i+1)..stock_indices.len() {
let stock1_idx = stock_indices[i];
let stock2_idx = stock_indices[j];
let distance = euclidean_squared_distance(
&normalized_prices[stock1_idx],
&normalized_prices[stock2_idx]
);
industry_pairs.push(StockPair {
stock1_idx,
stock2_idx,
distance,
});
}
}
// Sort pairs by distance
industry_pairs.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
// Take top N from each industry
let top_pairs: Vec<StockPair> = industry_pairs.into_iter()
.take(top_n_per_industry)
.collect();
all_pairs.extend(top_pairs);
}
all_pairs
}
L'approccio degli zero-crossings identifica coppie con convergenza e divergenza frequenti, indicando potenzialmente opportunità di trading più redditizie:
Concetto di Zero-Crossings: identificazione di coppie che tornano frequentemente alla media, indicato dallo spread che incrocia la linea dello zero
fn count_zero_crossings(spread: &[f64]) -> usize {
if spread.len() < 2 {
return 0;
}
let mut count = 0;
for i in 1..spread.len() {
if (spread[i-1] < 0.0 && spread[i] >= 0.0) ||
(spread[i-1] >= 0.0 && spread[i] < 0.0) {
count += 1;
}
}
count
}
fn find_zero_crossing_pairs(
normalized_prices: &[Vec<f64>],
top_distance_threshold: f64,
min_crossings: usize
) -> Vec<StockPair> {
let stock_count = normalized_prices.len();
let mut qualifying_pairs = Vec::new();
for i in 0..stock_count {
for j in (i+1)..stock_count {
let distance = euclidean_squared_distance(&normalized_prices[i], &normalized_prices[j]);
// Only consider pairs with distance below threshold
if distance < top_distance_threshold {
// Calculate spread
let spread: Vec<f64> = normalized_prices[i].iter()
.zip(normalized_prices[j].iter())
.map(|(p1, p2)| p1 - p2)
.collect();
let crossings = count_zero_crossings(&spread);
if crossings >= min_crossings {
qualifying_pairs.push(StockPair {
stock1_idx: i,
stock2_idx: j,
distance,
});
}
}
}
}
// Sort by number of crossings (could extend StockPair to include this)
qualifying_pairs.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
qualifying_pairs
}
Considerazione della Deviazione Standard Storica
Questo metodo affronta una limitazione dell'approccio di base privilegiando coppie con maggiore volatilità dello spread, il che può aumentare il potenziale di profitto:
fn find_highsd_pairs(
normalized_prices: &[Vec<f64>],
top_distance_count: usize,
min_volatility: f64
) -> Vec<StockPair> {
let stock_count = normalized_prices.len();
let mut all_pairs = Vec::new();
for i in 0..stock_count {
for j in (i+1)..stock_count {
let distance = euclidean_squared_distance(&normalized_prices[i], &normalized_prices[j]);
// Calculate spread volatility
let spread: Vec<f64> = normalized_prices[i].iter()
.zip(normalized_prices[j].iter())
.map(|(p1, p2)| p1 - p2)
.collect();
let volatility = calculate_spread_volatility(&normalized_prices[i], &normalized_prices[j]);
if volatility >= min_volatility {
all_pairs.push(StockPair {
stock1_idx: i,
stock2_idx: j,
distance,
});
}
}
}
// Sort by distance
all_pairs.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
// Take top N pairs with highest volatility that meet distance criteria
all_pairs.into_iter().take(top_distance_count).collect()
}
Approccio Avanzato: Metodo della Correlazione di Pearson
L'approccio della Correlazione di Pearson offre diversi vantaggi rispetto all'Approccio della Distanza di base, concentrandosi sulle correlazioni dei rendimenti piuttosto che sulle distanze dei prezzi[1].
Implementazione in Rust
fn pearson_correlation(x: &[f64], y: &[f64]) -> f64 {
assert_eq!(x.len(), y.len(), "Arrays must have the same length");
let n = x.len() as f64;
let sum_x: f64 = x.iter().sum();
let sum_y: f64 = y.iter().sum();
let sum_xx: f64 = x.iter().map(|&val| val * val).sum();
let sum_yy: f64 = y.iter().map(|&val| val * val).sum();
let sum_xy: f64 = x.iter().zip(y.iter()).map(|(&xi, &yi)| xi * yi).sum();
let numerator = n * sum_xy - sum_x * sum_y;
let denominator = ((n * sum_xx - sum_x * sum_x) * (n * sum_yy - sum_y * sum_y)).sqrt();
if denominator.abs() < f64::EPSILON {
return 0.0;
}
numerator / denominator
}
struct PearsonPair {
stock_idx: usize,
comover_indices: Vec<usize>,
correlations: Vec<f64>,
}
fn find_pearson_pairs(returns: &[Vec<f64>], top_n_comovers: usize) -> Vec<PearsonPair> {
let stock_count = returns.len();
let mut all_pairs = Vec::new();
for i in 0..stock_count {
let mut correlations = Vec::with_capacity(stock_count - 1);
for j in 0..stock_count {
if i == j {
continue;
}
let correlation = pearson_correlation(&returns[i], &returns[j]).abs();
correlations.push((j, correlation));
}
// Sort by correlation (highest first)
correlations.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Take top N comovers
let top_comovers: Vec<(usize, f64)> = correlations.into_iter()
.take(top_n_comovers)
.collect();
let (comover_indices, correlation_values): (Vec<usize>, Vec<f64>) =
top_comovers.into_iter().unzip();
all_pairs.push(PearsonPair {
stock_idx: i,
comover_indices,
correlations: correlation_values,
});
}
all_pairs
}
Formazione del Portafoglio e Calcolo del Beta
L'approccio di Pearson crea portafogli di comover per ogni titolo, quindi calcola i coefficienti di regressione:
fn calculate_beta(stock_returns: &[f64], portfolio_returns: &[f64]) -> f64 {
let cov_xy = covariance(stock_returns, portfolio_returns);
let var_x = variance(portfolio_returns);
if var_x.abs() < f64::EPSILON {
return 0.0;
}
cov_xy / var_x
}
fn covariance(x: &[f64], y: &[f64]) -> f64 {
assert_eq!(x.len(), y.len());
let n = x.len() as f64;
let mean_x: f64 = x.iter().sum::<f64>() / n;
let mean_y: f64 = y.iter().sum::<f64>() / n;
let sum_cov: f64 = x.iter()
.zip(y.iter())
.map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y))
.sum();
sum_cov / n
}
fn variance(x: &[f64]) -> f64 {
let n = x.len() as f64;
let mean: f64 = x.iter().sum::<f64>() / n;
let sum_var: f64 = x.iter()
.map(|&xi| (xi - mean).powi(2))
.sum();
sum_var / n
}
Generazione dei Segnali di Trading
Il passaggio finale in entrambi gli approcci consiste nel generare segnali di trading basati sulle soglie di divergenza:
enum TradingSignal {
Long,
Short,
Neutral
}
struct TradePosition {
stock1_idx: usize,
stock2_idx: usize,
signal: TradingSignal,
entry_spread: f64,
timestamp: usize,
}
fn generate_trading_signals(
normalized_prices: &[Vec<f64>],
pairs: &[StockPair],
threshold_multiplier: f64,
volatilities: &[f64],
current_time: usize
) -> Vec<TradePosition> {
let mut positions = Vec::new();
for (pair_idx, pair) in pairs.iter().enumerate() {
let stock1_idx = pair.stock1_idx;
let stock2_idx = pair.stock2_idx;
// Calculate current spread
let current_spread = normalized_prices[stock1_idx][current_time] -
normalized_prices[stock2_idx][current_time];
let threshold = threshold_multiplier * volatilities[pair_idx];
let signal = if current_spread > threshold {
// Stock1 is overvalued relative to Stock2
TradingSignal::Short
} else if current_spread < -threshold {
// Stock1 is undervalued relative to Stock2
TradingSignal::Long
} else {
TradingSignal::Neutral
};
if signal != TradingSignal::Neutral {
positions.push(TradePosition {
stock1_idx,
stock2_idx,
signal,
entry_spread: current_spread,
timestamp: current_time,
});
}
}
positions
}
Ottimizzazione delle Prestazioni
Per i sistemi di trading ad alta frequenza, le prestazioni sono fondamentali. Le istruzioni SIMD (Single Instruction, Multiple Data) possono accelerare significativamente i calcoli delle distanze:
Accelerazione SIMD: utilizzo del parallelismo a livello di dati in Rust per elaborare più punti di prezzo contemporaneamente, riducendo drasticamente la latenza
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
#[cfg(target_arch = "x86_64")]
#[inline]
unsafe fn euclidean_distance_simd(x: &[f32], y: &[f32]) -> f32 {
assert_eq!(x.len(), y.len());
let mut sum = _mm256_setzero_ps();
let chunks = x.len() / 8;
for i in 0..chunks {
let xi = _mm256_loadu_ps(&x[i * 8]);
let yi = _mm256_loadu_ps(&y[i * 8]);
let diff = _mm256_sub_ps(xi, yi);
let squared = _mm256_mul_ps(diff, diff);
sum = _mm256_add_ps(sum, squared);
}
// Handle the remaining elements
let mut result = _mm256_reduce_add_ps(sum);
for i in (chunks * 8)..x.len() {
result += (x[i] - y[i]).powi(2);
}
result.sqrt()
}
// Helper function to sum SIMD vector
#[cfg(target_arch = "x86_64")]
#[inline(always)]
unsafe fn _mm256_reduce_add_ps(v: __m256) -> f32 {
let hilow = _mm256_extractf128_ps(v, 1);
let low = _mm256_castps256_ps128(v);
let sum128 = _mm_add_ps(hilow, low);
let hi64 = _mm_extractf128_si128(_mm_castps_si128(sum128), 1);
let low64 = _mm_castps_si128(sum128);
let sum64 = _mm_add_ps(_mm_castsi128_ps(hi64), _mm_castsi128_ps(low64));
_mm_cvtss_f32(_mm_hadd_ps(sum64, sum64))
}
L'elaborazione asincrona può migliorare ulteriormente il throughput, specialmente quando si gestiscono molteplici coppie di titoli:
use tokio::task;
use futures::future::join_all;
async fn process_pairs_async(
normalized_prices: &[Vec<f64>],
stock_count: usize,
chunk_size: usize
) -> Vec<StockPair> {
let mut tasks = Vec::new();
// Split work into chunks
let chunks = (stock_count + chunk_size - 1) / chunk_size;
for chunk in 0..chunks {
let start = chunk * chunk_size;
let end = std::cmp::min((chunk + 1) * chunk_size, stock_count);
let prices_clone = normalized_prices.to_vec();
let task = task::spawn(async move {
let mut pairs = Vec::new();
for i in start..end {
for j in (i+1)..stock_count {
let distance = euclidean_squared_distance(&prices_clone[i], &prices_clone[j]);
pairs.push(StockPair {
stock1_idx: i,
stock2_idx: j,
distance,
});
}
}
pairs
});
tasks.push(task);
}
// Await all tasks and combine results
let results = join_all(tasks).await;
let mut all_pairs = Vec::new();
for result in results {
if let Ok(pairs) = result {
all_pairs.extend(pairs);
}
}
// Sort by distance
all_pairs.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal));
all_pairs
}
Test dell'Implementazione della Strategia
Per valutare la nostra implementazione, abbiamo bisogno di un'infrastruttura di test adeguata:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalization() {
let prices = vec![10.0, 15.0, 12.0, 18.0, 20.0];
let normalized = min_max_normalize(&prices);
let expected = vec![0.0, 0.5, 0.2, 0.8, 1.0];
for (a, b) in normalized.iter().zip(expected.iter()) {
assert!((a - b).abs() < 0.001);
}
}
#[test]
fn test_euclidean_distance() {
let x = vec![0.1, 0.2, 0.3, 0.4, 0.5];
let y = vec![0.15, 0.22, 0.35, 0.38, 0.53];
let distance = euclidean_squared_distance(&x, &y);
let expected = 0.0049; // Calculated manually
assert!((distance - expected).abs() < 0.0001);
}
#[test]
fn test_pearson_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![5.0, 4.0, 3.0, 2.0, 1.0];
let corr = pearson_correlation(&x, &y);
let expected = -1.0; // Perfect negative correlation
assert!((corr - expected).abs() < 0.0001);
}
// Integration tests would be implemented in tests/ directory
}
Per i test di integrazione, seguiremmo la convenzione Rust di collocare i test in una directory tests separata nella root del progetto[15][18].
Conclusione
L'Approccio della Distanza fornisce un framework robusto per il pairs trading, con metodologie sia di base che avanzate che offrono preziose opportunità di arbitraggio statistico. L'approccio di base, con la sua attenzione alla distanza euclidea, offre semplicità ed efficacia, mentre l'approccio della Correlazione di Pearson fornisce maggiore flessibilità e potenzialmente migliori caratteristiche di reversione alla divergenza.
Le caratteristiche prestazionali di Rust lo rendono un linguaggio ideale per implementare queste strategie computazionalmente intensive, specialmente con ottimizzazioni come SIMD e l'elaborazione concorrente. La combinazione di rigore statistico e implementazione efficiente crea un potente toolkit per i trader algoritmici.
Nell'implementare un sistema di pairs trading, occorre considerare diversi aspetti:
- Il compromesso tra semplicità (approccio di base) e maggiore potere statistico (approccio di Pearson)
- Le risorse computazionali necessarie per l'analisi di coppie su larga scala
- I costi di transazione, che possono influire significativamente sulla redditività[3]
- La necessità di monitoraggio continuo e ricalibrazione delle coppie
Combinando l'Approccio della Distanza con le capacità prestazionali di Rust, i trader possono sviluppare sistemi di arbitraggio statistico altamente efficienti ed efficaci, in grado di operare alla velocità e alla scala richieste dai mercati moderni.
Citazione
@software{soloviov2025distanceapproach,
author = {Soloviov, Eugen},
title = {Distance Approach in Pairs Trading: Implementation and Analysis with Rust},
year = {2025},
url = {https://marketmaker.cc/it/blog/post/distance-approach-pairs-trading},
version = {0.1.0},
description = {Un'analisi completa delle metodologie di base e avanzate dell'Approccio della Distanza per il pairs trading, con implementazioni pratiche in Rust pensate per trader ad alta frequenza e sviluppatori algoritmici.}
}
Riferimenti
- Hudson Thames - Introduzione all'Approccio della Distanza nel Pairs Trading Parte II
- Hudson Thames - Approccio della Distanza nel Pairs Trading Parte I
- Reddit - Il Pairs Trading è Troppo Bello per Essere Vero?
- GitHub - Kucoin Arbitrage
- docs.rs - Distanza Euclidea nel crate geo
- Regressione Lineare Semplice in Rust
- GitHub - correlation_rust
- docs.rs - Cointegrazione in algolotl-ta
- GitHub - trading_engine_rust
- docs.rs - crate distances
- Reddit - Cerco un crate stats per Dickey-Fuller
- crates.io - crypto-pair-trader
- w3resource - Esercizio su Struct ed Enum in Rust
- Rust Book - Organizzazione dei Test
- Design Patterns in Rust
- GitHub - simd-euclidean
- Rust by Example - Test di Integrazione
- YouTube - Test di Integrazione in Rust
- Stack Overflow - Calcolare la Distanza Totale tra Più Punti
- Databento - Esempio di Pairs Trading
- Rust std - Primitivo f64
- Hudson & Thames - Documentazione dell'Approccio della Distanza
- GitHub - trading-algorithms-rust
- docs.rs - crate linreg
- Rust Book - Riferimenti e Borrowing
- Stack Overflow - Come Interpretare i Risultati del Test adfuller
- lib.rs - crate arima
- Econometrics with R - Cointegrazione
- DolphinDB - Funzione adfuller
- docs.rs - crate arima (latest)
- Wikipedia - Cointegrazione
Autori
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.