Merge branch 'dev' into main

This commit is contained in:
Miha Kralj
2026-03-16 12:46:19 -07:00
131 changed files with 1582 additions and 1583 deletions
+450
View File
@@ -0,0 +1,450 @@
using System.Runtime.CompilerServices;
using static System.Math;
namespace QuanTAlib;
/// <summary>
/// Correlation: Calculates Pearson's correlation coefficient between two price series
/// using a streaming single-pass algorithm with circular buffers and Kahan compensated
/// summation for numerical stability over long streams.
/// </summary>
/// <remarks>
/// The Pearson correlation coefficient measures the linear relationship between two variables.
/// It ranges from -1 (perfect negative correlation) to +1 (perfect positive correlation).
///
/// Algorithm:
/// 1. Maintain running sums: Σx, Σy, Σx², Σy², Σxy
/// 2. Calculate means: μx = Σx/n, μy = Σy/n
/// 3. Calculate variances: σx² = Σx²/n - μx², σy² = Σy²/n - μy²
/// 4. Calculate covariance: cov(x,y) = Σxy/n - μx×μy
/// 5. Correlation: r = cov(x,y) / (σx × σy)
///
/// Interpretation:
/// - r = +1: Perfect positive linear relationship
/// - r = -1: Perfect negative linear relationship
/// - r = 0: No linear relationship
/// - |r| > 0.7: Strong correlation
/// - 0.3 < |r| < 0.7: Moderate correlation
/// - |r| < 0.3: Weak correlation
/// </remarks>
[SkipLocalsInit]
public sealed class Correl : AbstractBase
{
private readonly RingBuffer _bufferX;
private readonly RingBuffer _bufferY;
// Running sums for O(1) statistics
private double _sumX, _sumY;
private double _sumX2, _sumY2;
private double _sumXY;
// Kahan compensation terms
private double _sumXComp, _sumYComp;
private double _sumX2Comp, _sumY2Comp;
private double _sumXYComp;
// Previous compensation state for rollback
private double _p_sumXComp, _p_sumYComp;
private double _p_sumX2Comp, _p_sumY2Comp;
private double _p_sumXYComp;
// Last valid values for NaN handling
private double _lastValidX, _lastValidY;
private double _p_lastValidX, _p_lastValidY;
private const double Epsilon = 1e-10;
/// <inheritdoc />
public override bool IsHot => _bufferX.Count >= WarmupPeriod;
/// <summary>
/// Creates a new Correl indicator.
/// </summary>
/// <param name="period">Lookback period for calculation (must be > 1)</param>
public Correl(int period = 20)
{
if (period <= 1)
{
throw new ArgumentException("Period must be greater than 1", nameof(period));
}
_bufferX = new RingBuffer(period);
_bufferY = new RingBuffer(period);
Name = $"Correl({period})";
WarmupPeriod = period;
}
/// <summary>
/// Updates the Correlation indicator with new values from both series.
/// </summary>
/// <param name="seriesX">First series value</param>
/// <param name="seriesY">Second series value</param>
/// <param name="isNew">Whether this is a new bar</param>
/// <returns>The Pearson correlation coefficient (-1 to +1)</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue seriesX, TValue seriesY, bool isNew = true)
{
if (isNew)
{
_p_lastValidX = _lastValidX;
_p_lastValidY = _lastValidY;
_p_sumXComp = _sumXComp;
_p_sumYComp = _sumYComp;
_p_sumX2Comp = _sumX2Comp;
_p_sumY2Comp = _sumY2Comp;
_p_sumXYComp = _sumXYComp;
}
else
{
_lastValidX = _p_lastValidX;
_lastValidY = _p_lastValidY;
_sumXComp = _p_sumXComp;
_sumYComp = _p_sumYComp;
_sumX2Comp = _p_sumX2Comp;
_sumY2Comp = _p_sumY2Comp;
_sumXYComp = _p_sumXYComp;
}
double x = SanitizeX(seriesX.Value);
double y = SanitizeY(seriesY.Value);
if (isNew)
{
ProcessNewBar(x, y);
}
else
{
ProcessBarCorrection(x, y);
}
double correlation = CalculateCorrel();
Last = new TValue(seriesX.Time, correlation);
PubEvent(Last);
return Last;
}
/// <summary>
/// Updates with raw double values.
/// </summary>
/// <remarks>
/// Stamps both inputs with <c>DateTime.UtcNow</c> as their timestamp. For
/// deterministic or replay-safe sequences use
/// <see cref="Update(TValue, TValue, bool)"/> with explicit timestamps instead.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(double seriesX, double seriesY, bool isNew = true)
{
DateTime now = DateTime.UtcNow;
return Update(new TValue(now, seriesX), new TValue(now, seriesY), isNew);
}
/// <summary>Not supported. This indicator requires two inputs; use <see cref="Update(TValue, TValue, bool)"/> instead.</summary>
/// <remarks>Not supported for bi-input indicator. Use Update(seriesX, seriesY) instead.</remarks>
public override TValue Update(TValue input, bool isNew = true)
{
throw new NotSupportedException("Correl requires two inputs (seriesX and seriesY). Use Update(seriesX, seriesY).");
}
/// <summary>Not supported. This indicator requires two inputs; use <see cref="Batch(TSeries, TSeries, int)"/> instead.</summary>
/// <remarks>Not supported for bi-input indicator. Use Calculate(seriesX, seriesY, period) instead.</remarks>
public override TSeries Update(TSeries source)
{
throw new NotSupportedException("Correl requires two inputs. Use Batch(seriesX, seriesY, period).");
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double SanitizeX(double value)
{
if (double.IsFinite(value))
{
_lastValidX = value;
return value;
}
return double.IsFinite(_lastValidX) ? _lastValidX : 0.0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double SanitizeY(double value)
{
if (double.IsFinite(value))
{
_lastValidY = value;
return value;
}
return double.IsFinite(_lastValidY) ? _lastValidY : 0.0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ProcessNewBar(double x, double y)
{
// Remove oldest values if buffer is full
if (_bufferX.IsFull)
{
double oldX = _bufferX.Oldest;
double oldY = _bufferY.Oldest;
// Kahan subtract oldX from _sumX
{
double yk = -oldX - _sumXComp;
double t = _sumX + yk;
_sumXComp = (t - _sumX) - yk;
_sumX = t;
}
// Kahan subtract oldY from _sumY
{
double yk = -oldY - _sumYComp;
double t = _sumY + yk;
_sumYComp = (t - _sumY) - yk;
_sumY = t;
}
// Kahan subtract oldX² from _sumX2
{
double yk = -(oldX * oldX) - _sumX2Comp;
double t = _sumX2 + yk;
_sumX2Comp = (t - _sumX2) - yk;
_sumX2 = t;
}
// Kahan subtract oldY² from _sumY2
{
double yk = -(oldY * oldY) - _sumY2Comp;
double t = _sumY2 + yk;
_sumY2Comp = (t - _sumY2) - yk;
_sumY2 = t;
}
// Kahan subtract oldX*oldY from _sumXY
{
double yk = -(oldX * oldY) - _sumXYComp;
double t = _sumXY + yk;
_sumXYComp = (t - _sumXY) - yk;
_sumXY = t;
}
}
// Add new values
_bufferX.Add(x);
_bufferY.Add(y);
// Kahan add x to _sumX
{
double yk = x - _sumXComp;
double t = _sumX + yk;
_sumXComp = (t - _sumX) - yk;
_sumX = t;
}
// Kahan add y to _sumY
{
double yk = y - _sumYComp;
double t = _sumY + yk;
_sumYComp = (t - _sumY) - yk;
_sumY = t;
}
// Kahan add x² to _sumX2
{
double yk = (x * x) - _sumX2Comp;
double t = _sumX2 + yk;
_sumX2Comp = (t - _sumX2) - yk;
_sumX2 = t;
}
// Kahan add y² to _sumY2
{
double yk = (y * y) - _sumY2Comp;
double t = _sumY2 + yk;
_sumY2Comp = (t - _sumY2) - yk;
_sumY2 = t;
}
// Kahan add x*y to _sumXY
{
double yk = (x * y) - _sumXYComp;
double t = _sumXY + yk;
_sumXYComp = (t - _sumXY) - yk;
_sumXY = t;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ProcessBarCorrection(double x, double y)
{
if (_bufferX.Count == 0)
{
// Nothing to correct yet; no current bar exists
return;
}
// Get the current newest values (which are wrong and need to be corrected)
double oldX = _bufferX.Newest;
double oldY = _bufferY.Newest;
// Kahan subtract old + add new for _sumX
{
double yk = (-oldX + x) - _sumXComp;
double t = _sumX + yk;
_sumXComp = (t - _sumX) - yk;
_sumX = t;
}
// Kahan subtract old + add new for _sumY
{
double yk = (-oldY + y) - _sumYComp;
double t = _sumY + yk;
_sumYComp = (t - _sumY) - yk;
_sumY = t;
}
// Kahan subtract old² + add new² for _sumX2
{
double yk = (-(oldX * oldX) + (x * x)) - _sumX2Comp;
double t = _sumX2 + yk;
_sumX2Comp = (t - _sumX2) - yk;
_sumX2 = t;
}
// Kahan subtract old² + add new² for _sumY2
{
double yk = (-(oldY * oldY) + (y * y)) - _sumY2Comp;
double t = _sumY2 + yk;
_sumY2Comp = (t - _sumY2) - yk;
_sumY2 = t;
}
// Kahan subtract old*old + add new*new for _sumXY
{
double yk = (-(oldX * oldY) + (x * y)) - _sumXYComp;
double t = _sumXY + yk;
_sumXYComp = (t - _sumXY) - yk;
_sumXY = t;
}
// Update the buffer values
_bufferX.UpdateNewest(x);
_bufferY.UpdateNewest(y);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateCorrel()
{
int n = _bufferX.Count;
if (n < 2)
{
return double.NaN;
}
// Calculate means
double meanX = _sumX / n;
double meanY = _sumY / n;
// Calculate variances (population variance)
double varX = Max(0.0, (_sumX2 / n) - (meanX * meanX));
double varY = Max(0.0, (_sumY2 / n) - (meanY * meanY));
// Calculate covariance
double cov = (_sumXY / n) - (meanX * meanY);
// Calculate standard deviations
double stdX = Sqrt(varX);
double stdY = Sqrt(varY);
// Calculate correlation
double denominator = stdX * stdY;
if (Abs(denominator) < Epsilon)
{
return double.NaN;
}
double correlation = cov / denominator;
// Clamp to [-1, 1] range to handle floating point precision issues
return Max(-1.0, Min(1.0, correlation));
}
/// <summary>Not supported. This indicator requires two input spans.</summary>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
throw new NotSupportedException("Correl requires two inputs.");
}
/// <inheritdoc />
public override void Reset()
{
_bufferX.Clear();
_bufferY.Clear();
_sumX = 0;
_sumY = 0;
_sumX2 = 0;
_sumY2 = 0;
_sumXY = 0;
_sumXComp = 0;
_sumYComp = 0;
_sumX2Comp = 0;
_sumY2Comp = 0;
_sumXYComp = 0;
_lastValidX = 0;
_lastValidY = 0;
_p_lastValidX = 0;
_p_lastValidY = 0;
Last = default;
}
/// <summary>
/// Calculates correlation for two time series.
/// </summary>
public static TSeries Batch(TSeries seriesX, TSeries seriesY, int period = 20)
=> Calculate(seriesX, seriesY, period).Results;
/// <summary>
/// Static batch calculation for span-based processing.
/// </summary>
public static void Batch(
ReadOnlySpan<double> seriesX,
ReadOnlySpan<double> seriesY,
Span<double> output,
int period = 20)
{
if (seriesX.Length != seriesY.Length)
{
throw new ArgumentException("Series must have the same length", nameof(seriesY));
}
if (seriesX.Length != output.Length)
{
throw new ArgumentException("Output must have the same length as input", nameof(output));
}
if (period <= 1)
{
throw new ArgumentException("Period must be greater than 1", nameof(period));
}
var indicator = new Correl(period);
for (int i = 0; i < seriesX.Length; i++)
{
var result = indicator.Update(seriesX[i], seriesY[i], isNew: true);
output[i] = result.Value;
}
}
/// <summary>
/// Calculates Pearson correlation for two time series and returns both the result series and the live indicator instance.
/// </summary>
public static (TSeries Results, Correl Indicator) Calculate(TSeries seriesX, TSeries seriesY, int period = 20)
{
if (seriesX.Count != seriesY.Count)
{
throw new ArgumentException("Series must have the same length", nameof(seriesY));
}
var indicator = new Correl(period);
var result = new TSeries(seriesX.Count);
var timesX = seriesX.Times;
var valuesX = seriesX.Values;
var valuesY = seriesY.Values;
for (int i = 0; i < seriesX.Count; i++)
{
result.Add(indicator.Update(new TValue(timesX[i], valuesX[i]), new TValue(timesX[i], valuesY[i]), isNew: true));
}
return (result, indicator);
}
}
+282
View File
@@ -0,0 +1,282 @@
# CORR: Pearson Correlation Coefficient
> *Correlation is not causation, but it sure is a hint. The market doesn't care why two instruments move together—only that they do, and whether that relationship will persist long enough for you to profit from it.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Statistic |
| **Inputs** | Two series (X, Y) |
| **Parameters** | `period` (default 20) |
| **Outputs** | Single series (Pearson r) |
| **Output range** | Varies (see docs) |
| **Warmup** | `period` bars |
| **PineScript** | [correl.pine](correl.pine) |
- The Pearson Correlation Coefficient measures the linear relationship between two variables, returning a value from -1 (perfect negative correlation...
- **Similar:** [Spearman](../spearman/Spearman.md), [Kendall](../kendall/Kendall.md) | **Trading note:** Pearson correlation; measures linear relationship strength. Used for portfolio diversification and pairs trading.
- Validated against TradingView reference behavior and mathematical invariants.
The Pearson Correlation Coefficient measures the linear relationship between two variables, returning a value from -1 (perfect negative correlation) to +1 (perfect positive correlation). Zero indicates no linear relationship. This implementation uses running sums for O(1) streaming updates, making it suitable for real-time analysis of price relationships.
## Historical Context
Karl Pearson formalized the correlation coefficient in the 1890s, building on earlier work by Francis Galton. The formula has remained unchanged for over a century because it elegantly captures what traders intuitively understand: when two instruments move together, there's an exploitable relationship.
Unlike cointegration (which tests for long-run equilibrium), correlation measures instantaneous co-movement. Two stocks can be highly correlated yet drift apart permanently—correlation tells you about direction, not destination. This distinction matters enormously for pairs trading: correlation helps with hedging and timing, but cointegration determines whether mean-reversion is statistically justified.
This implementation follows the PineScript reference, using circular buffers and running sums to achieve constant-time updates regardless of lookback period.
## Architecture & Physics
### 1. Running Sums Framework
The indicator maintains five running sums updated incrementally:
| Sum | Description | Formula |
| :--- | :--- | :--- |
| $S_X$ | Sum of X values | $\sum_{i=1}^{n} X_i$ |
| $S_Y$ | Sum of Y values | $\sum_{i=1}^{n} Y_i$ |
| $S_{X^2}$ | Sum of X squared | $\sum_{i=1}^{n} X_i^2$ |
| $S_{Y^2}$ | Sum of Y squared | $\sum_{i=1}^{n} Y_i^2$ |
| $S_{XY}$ | Sum of X×Y products | $\sum_{i=1}^{n} X_i Y_i$ |
### 2. Circular Buffer
A `RingBuffer` of capacity `period` stores paired values. When full, the oldest pair is subtracted from running sums before adding the new pair—maintaining O(1) complexity regardless of period length.
### 3. Correlation Formula
The Pearson coefficient is computed as:
$$r = \frac{\text{Cov}(X, Y)}{\sigma_X \cdot \sigma_Y}$$
Expanded using running sums:
$$r = \frac{n \cdot S_{XY} - S_X \cdot S_Y}{\sqrt{(n \cdot S_{X^2} - S_X^2)(n \cdot S_{Y^2} - S_Y^2)}}$$
Where $n$ is the number of observations (capped at `period`).
### 4. Edge Case Handling
| Condition | Result | Rationale |
| :--- | :--- | :--- |
| Zero variance in X or Y | NaN | Division by zero—undefined correlation |
| Insufficient data | NaN | Need at least 2 points |
| NaN/Infinity input | Last valid value | Substitution preserves series continuity |
## Mathematical Foundation
### Derivation from Covariance
Starting with the population covariance:
$$\text{Cov}(X, Y) = \frac{\sum(X_i - \bar{X})(Y_i - \bar{Y})}{n}$$
Expanding:
$$\text{Cov}(X, Y) = \frac{\sum X_i Y_i}{n} - \bar{X} \cdot \bar{Y}$$
$$= \frac{S_{XY}}{n} - \frac{S_X}{n} \cdot \frac{S_Y}{n}$$
$$= \frac{n \cdot S_{XY} - S_X \cdot S_Y}{n^2}$$
Similarly for standard deviations:
$$\sigma_X = \sqrt{\frac{S_{X^2}}{n} - \left(\frac{S_X}{n}\right)^2} = \frac{\sqrt{n \cdot S_{X^2} - S_X^2}}{n}$$
Combining:
$$r = \frac{\text{Cov}(X, Y)}{\sigma_X \cdot \sigma_Y} = \frac{n \cdot S_{XY} - S_X \cdot S_Y}{\sqrt{(n \cdot S_{X^2} - S_X^2)(n \cdot S_{Y^2} - S_Y^2)}}$$
### Update Mechanics
When a new pair $(x_{new}, y_{new})$ arrives and an old pair $(x_{old}, y_{old})$ exits the window:
$$S_X \leftarrow S_X - x_{old} + x_{new}$$
$$S_Y \leftarrow S_Y - y_{old} + y_{new}$$
$$S_{X^2} \leftarrow S_{X^2} - x_{old}^2 + x_{new}^2$$
$$S_{Y^2} \leftarrow S_{Y^2} - y_{old}^2 + y_{new}^2$$
$$S_{XY} \leftarrow S_{XY} - x_{old} \cdot y_{old} + x_{new} \cdot y_{new}$$
This achieves O(1) per-bar complexity.
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| ADD/SUB | 12 | 1 | 12 |
| MUL | 8 | 3 | 24 |
| DIV | 1 | 15 | 15 |
| SQRT | 1 | 15 | 15 |
| Buffer Access | 2 | 3 | 6 |
| **Total** | **24** | — | **~72 cycles** |
Correlation is significantly cheaper than cointegration (~72 vs ~282 cycles) because it doesn't require the ADF regression step.
### Memory Footprint
| Component | Size |
| :--- | :--- |
| Ring buffer (period × 2 doubles) | 16 × period bytes |
| Running sums (5 doubles) | 40 bytes |
| State variables | 32 bytes |
| **Total per instance** | **~16 × period + 72 bytes** |
For period=20: ~392 bytes per indicator instance.
### Batch Mode (SIMD Potential)
The correlation formula is not directly SIMD-friendly due to the final division and square root. However, the running sum accumulation phase can benefit from vectorization when processing batches:
| Phase | SIMD Benefit |
| :--- | :--- |
| Sum accumulation | 4-8× (AVX2/AVX-512) |
| Final formula | 1× (scalar) |
| **Overall improvement** | ~2-3× for batch processing |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact Pearson formula |
| **Timeliness** | 8/10 | Responsive to recent changes |
| **Robustness** | 9/10 | Handles edge cases gracefully |
| **Interpretability** | 10/10 | Universal [-1, +1] scale |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | No correlation implementation |
| **Skender** | N/A | No direct correlation (has Beta) |
| **Tulip** | N/A | No correlation implementation |
| **Ooples** | N/A | No correlation implementation |
| **TradingView** | ✅ | Matches PineScript `ta.correlation()` |
| **Mathematical** | ✅ | Validated against known properties |
Note: Correlation is typically found in statistical packages rather than TA libraries. This implementation validates against mathematical properties (symmetry, boundedness, scale invariance) and the PineScript reference.
## Use Cases
### 1. Hedging
Find correlated instruments to offset risk:
- **r > 0.7**: Strong positive correlation, use for portfolio diversification analysis
- **r < -0.7**: Strong negative correlation, natural hedges
### 2. Pairs Trading (Short-Term)
Identify co-moving pairs for short-term mean reversion:
- High correlation indicates pairs move together
- Combine with cointegration for statistical justification
### 3. Sector Analysis
Measure how closely a stock tracks its sector or index:
- Rolling correlation reveals changing relationships
- Divergence from sector may signal alpha opportunities
### 4. Risk Management
Monitor correlation stability:
- Correlations tend toward 1 during market stress
- "Correlation breakdown" can devastate hedged portfolios
## API Usage
### Streaming Mode (Bi-Input)
```csharp
var corr = new Correl(period: 20);
foreach (var (priceA, priceB) in pricePairs)
{
var result = corr.Update(priceA, priceB);
if (corr.IsHot)
{
Console.WriteLine($"Correlation: {result.Value:F4}");
}
}
```
### Batch Mode
```csharp
var seriesA = new TSeries();
var seriesB = new TSeries();
// ... populate series ...
var results = Correl.Calculate(seriesA, seriesB, period: 20);
```
### Span Mode (Zero Allocation)
```csharp
double[] pricesA = new double[1000];
double[] pricesB = new double[1000];
double[] output = new double[1000];
// ... populate inputs ...
Correl.Batch(pricesA.AsSpan(), pricesB.AsSpan(), output.AsSpan(), period: 20);
```
### Bar Correction Support
```csharp
var corr = new Correl(20);
// New bar
corr.Update(100.0, 50.0, isNew: true); // r = 0.85
// Same bar corrected (e.g., real-time tick update)
corr.Update(101.0, 51.0, isNew: false); // Recalculates without advancing state
```
## Interpreting Results
| Correlation | Interpretation |
| :---: | :--- |
| **+0.7 to +1.0** | Strong positive: move in same direction |
| **+0.3 to +0.7** | Moderate positive |
| **-0.3 to +0.3** | Weak or no linear relationship |
| **-0.7 to -0.3** | Moderate negative |
| **-1.0 to -0.7** | Strong negative: move in opposite directions |
**Warning**: Correlation only measures *linear* relationships. Two variables with a perfect quadratic relationship (Y = X²) may show r ≈ 0.
## Common Pitfalls
1. **Confusing Correlation with Causation**: High correlation does not imply one variable causes changes in the other. Both may be driven by a third factor (confounding).
2. **Assuming Stability**: Correlations change over time. A 0.9 correlation over the past year doesn't guarantee 0.9 tomorrow. Rolling correlation reveals regime changes.
3. **Ignoring Non-Linear Relationships**: Pearson correlation misses curvilinear dependencies. If you suspect non-linear relationships, consider Spearman rank correlation instead.
4. **Crisis Correlation Spike**: During market stress, correlations tend toward 1.0 (or -1.0 for inverse ETFs). Diversification benefits evaporate precisely when you need them most.
5. **Lookback Period Selection**: Short periods (5-10) are noisy but responsive. Long periods (50-100) are stable but slow to adapt. Match the period to your trading horizon.
6. **Zero-Variance Edge Case**: If either series is constant within the window, variance is zero and correlation is undefined (NaN). This is mathematically correct.
7. **Warmup Period**: The indicator requires `period` bars before producing valid results. During warmup, `IsHot` returns false.
8. **Outlier Sensitivity**: Pearson correlation is sensitive to outliers. A single extreme observation can dramatically shift the coefficient. Consider winsorizing data or using Spearman for robustness.
## Correlation vs Cointegration
| Aspect | Correlation | Cointegration |
| :--- | :--- | :--- |
| **Measures** | Linear co-movement | Long-run equilibrium |
| **Range** | [-1, +1] | ADF statistic (unbounded) |
| **Time horizon** | Short-term | Long-term |
| **Use case** | Hedging, risk | Pairs trading |
| **Computational cost** | ~72 cycles | ~282 cycles |
| **Stationarity required** | No | Yes (I(1) series) |
**Rule of thumb**: Use correlation for hedging and short-term analysis. Use cointegration for pairs trading and mean-reversion strategies.
## References
- Pearson, K. (1895). "Notes on regression and inheritance in the case of two parents." *Proceedings of the Royal Society of London*, 58, 240-242.
- TradingView. "ta.correlation() function." *Pine Script Language Reference Manual*.
- Vidyamurthy, G. (2004). "Pairs Trading: Quantitative Methods and Analysis." *Wiley Finance*. Chapter on correlation analysis.
- Embrechts, P., McNeil, A., & Straumann, D. (2002). "Correlation and dependence in risk management: properties and pitfalls." *Risk Management: Value at Risk and Beyond*, Cambridge University Press.
+68
View File
@@ -0,0 +1,68 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Pearson's Correlation (CORREL)", "CORREL", overlay=false)
//@function Calculates Pearson correlation coefficient using single pass with circular buffer
//@param src1 series float First series to analyze
//@param src2 series float Second series to analyze
//@param len simple int Lookback period for calculation
//@returns float Pearson correlation coefficient between -1 and 1
//@optimized for performance using combined covariance and variance calculation
correlation(series float src1, series float src2, simple int len) =>
if len <= 0
runtime.error("Period must be greater than 0")
var int p = math.max(1, len)
var array<float> buffer1 = array.new_float(p, na)
var array<float> buffer2 = array.new_float(p, na)
var int head = 0, var int count = 0
var float sum1 = 0.0, var float sum2 = 0.0
var float sumSq1 = 0.0, var float sumSq2 = 0.0
var float sumProd = 0.0
float oldest1 = array.get(buffer1, head)
float oldest2 = array.get(buffer2, head)
if not na(oldest1) and not na(oldest2)
sum1 -= oldest1, sum2 -= oldest2
sumSq1 -= oldest1 * oldest1, sumSq2 -= oldest2 * oldest2
sumProd -= oldest1 * oldest2
count -= 1
if not na(src1) and not na(src2)
sum1 += src1, sum2 += src2
sumSq1 += src1 * src1, sumSq2 += src2 * src2
sumProd += src1 * src2
count += 1
array.set(buffer1, head, src1)
array.set(buffer2, head, src2)
else
array.set(buffer1, head, na)
array.set(buffer2, head, na)
head := (head + 1) % p
if count > 1
mean1 = sum1 / count, mean2 = sum2 / count
cov = (sumProd / count) - mean1 * mean2
var1 = (sumSq1 / count) - mean1 * mean1
var2 = (sumSq2 / count) - mean2 * mean2
stddev1 = math.sqrt(math.max(0.0, var1))
stddev2 = math.sqrt(math.max(0.0, var2))
denominator = stddev1 * stddev2
if denominator != 0
cov / denominator
else
na
else
na
// ---------- Main loop ----------
// Inputs
i_source1 = input.source(close, "Source 1")
i_source2_ticker = input.symbol("SPY", "Source 2 Ticker (e.g., SPY, AAPL)")
i_period = input.int(20, "Period", minval=2)
i_source2 = request.security(i_source2_ticker, timeframe.period, close, lookahead=barmerge.lookahead_off)
// Calculation
correlation_value = correlation(i_source1, i_source2, i_period)
// Plot
plot(correlation_value, "Correlation", color=color.yellow, linewidth=2)
+392
View File
@@ -0,0 +1,392 @@
namespace QuanTAlib.Tests;
public class CorrelTests
{
[Fact]
public void Constructor_ValidPeriod_CreatesIndicator()
{
var indicator = new Correl(20);
Assert.Equal("Correl(20)", indicator.Name);
Assert.Equal(20, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_MinimumValidPeriod_CreatesIndicator()
{
var indicator = new Correl(2);
Assert.Equal("Correl(2)", indicator.Name);
}
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Correl(1));
Assert.Throws<ArgumentException>(() => new Correl(0));
Assert.Throws<ArgumentException>(() => new Correl(-5));
}
[Fact]
public void Update_SingleValue_ReturnsNaN()
{
var indicator = new Correl(5);
var result = indicator.Update(100.0, 200.0, true);
Assert.True(double.IsNaN(result.Value));
}
[Fact]
public void Update_TwoValues_ReturnsValidCorrel()
{
var indicator = new Correl(5);
indicator.Update(100.0, 200.0, true);
var result = indicator.Update(102.0, 204.0, true);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_PerfectPositiveCorrelation_ReturnsOne()
{
var indicator = new Correl(5);
// Same values scaled by constant should give correlation = 1
for (int i = 0; i < 10; i++)
{
double x = 100.0 + i;
double y = 200.0 + (2 * i); // y = 200 + 2x (perfectly correlated)
indicator.Update(x, y, true);
}
Assert.True(indicator.IsHot);
Assert.InRange(indicator.Last.Value, 0.999, 1.001);
}
[Fact]
public void Update_PerfectNegativeCorrelation_ReturnsMinusOne()
{
var indicator = new Correl(5);
// Opposite movements should give correlation = -1
for (int i = 0; i < 10; i++)
{
double x = 100.0 + i;
double y = 200.0 - (2 * i); // y = 200 - 2x (perfectly negatively correlated)
indicator.Update(x, y, true);
}
Assert.True(indicator.IsHot);
Assert.InRange(indicator.Last.Value, -1.001, -0.999);
}
[Fact]
public void Update_ConstantValues_ReturnsNaN()
{
var indicator = new Correl(5);
// Constant values have zero variance, so correlation is undefined
for (int i = 0; i < 10; i++)
{
indicator.Update(100.0, 200.0, true);
}
Assert.True(double.IsNaN(indicator.Last.Value));
}
[Fact]
public void Update_BarCorrection_RestoresState()
{
var indicator1 = new Correl(5);
var indicator2 = new Correl(5);
// Feed same initial data
for (int i = 0; i < 10; i++)
{
double x = 100.0 + i;
double y = 200.0 + (i * 0.5);
indicator1.Update(x, y, true);
indicator2.Update(x, y, true);
}
// indicator1: Add another bar
indicator1.Update(110.0, 205.0, true);
// indicator2: Add bar, then correct it
indicator2.Update(999.0, 999.0, true); // Wrong values
indicator2.Update(110.0, 205.0, false); // Correct them
// Values should match
Assert.Equal(indicator1.Last.Value, indicator2.Last.Value, 1e-9);
}
[Fact]
public void Update_IterativeCorrections_Restore()
{
var corrected = new Correl(5);
var direct = new Correl(5);
// Feed identical initial state
for (int i = 0; i < 8; i++)
{
double x = 100.0 + i;
double y = 200.0 + (i * 2);
corrected.Update(x, y, true);
direct.Update(x, y, true);
}
// Target final value for the current bar
const double finalX = 108.0;
const double finalY = 216.0;
// Correction path: new bar, several rewrites, final rewrite back to target
corrected.Update(finalX, finalY, true);
corrected.Update(finalX + 1.0, finalY + 2.0, false);
corrected.Update(finalX - 0.5, finalY - 1.0, false);
corrected.Update(finalX + 0.25, finalY + 0.5, false);
corrected.Update(finalX, finalY, false);
// Direct path: same initial state + one new bar with final value
direct.Update(finalX, finalY, true);
Assert.Equal(direct.Last.Value, corrected.Last.Value, 1e-12);
}
[Fact]
public void Update_NaNInput_UsesLastValidValue()
{
var indicator = new Correl(5);
// Add valid data
for (int i = 0; i < 5; i++)
{
indicator.Update(100.0 + i, 200.0 + i, true);
}
_ = indicator.Last.Value;
// Add NaN - should use last valid value, result must be finite
var result = indicator.Update(double.NaN, double.NaN, true);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_InfinityInput_UsesLastValidValue()
{
var indicator = new Correl(5);
// Add valid data
for (int i = 0; i < 5; i++)
{
indicator.Update(100.0 + i, 200.0 + i, true);
}
// Add Infinity - should use last valid value, result must be finite
var result = indicator.Update(double.PositiveInfinity, double.NegativeInfinity, true);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void IsHot_BelowPeriod_ReturnsFalse()
{
var indicator = new Correl(10);
indicator.Update(100.0, 200.0, true);
Assert.False(indicator.IsHot);
}
[Fact]
public void IsHot_AtPeriod_ReturnsTrue()
{
var indicator = new Correl(10);
for (int i = 0; i < 10; i++)
{
indicator.Update(100.0 + i, 200.0 + i, true);
}
Assert.True(indicator.IsHot);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Correl(5);
// Add data
for (int i = 0; i < 10; i++)
{
indicator.Update(100.0 + i, 200.0 + (i * 2), true);
}
Assert.True(indicator.IsHot);
// Reset
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
[Fact]
public void Update_TValue_ThrowsNotSupportedException()
{
var indicator = new Correl(5);
Assert.Throws<NotSupportedException>(() => indicator.Update(new TValue(DateTime.UtcNow, 100.0)));
}
[Fact]
public void Update_TSeries_ThrowsNotSupportedException()
{
var indicator = new Correl(5);
var series = new TSeries(10);
Assert.Throws<NotSupportedException>(() => indicator.Update(series));
}
[Fact]
public void Prime_ThrowsNotSupportedException()
{
var indicator = new Correl(5);
Assert.Throws<NotSupportedException>(() => indicator.Prime(new double[] { 1, 2, 3 }));
}
[Fact]
public void Calculate_TSeries_ReturnsCorrectLength()
{
var seriesX = new TSeries(20);
var seriesY = new TSeries(20);
for (int i = 0; i < 20; i++)
{
seriesX.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i));
seriesY.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 200.0 + (i * 2)));
}
var result = Correl.Batch(seriesX, seriesY, 5);
Assert.Equal(20, result.Count);
}
[Fact]
public void Calculate_TSeries_DifferentLengths_ThrowsArgumentException()
{
var seriesX = new TSeries(10);
var seriesY = new TSeries(15);
for (int i = 0; i < 10; i++)
{
seriesX.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i));
}
for (int i = 0; i < 15; i++)
{
seriesY.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 200.0 + i));
}
Assert.Throws<ArgumentException>(() => Correl.Batch(seriesX, seriesY, 5));
}
[Fact]
public void Calculate_Span_ReturnsCorrectValues()
{
double[] seriesX = new double[20];
double[] seriesY = new double[20];
double[] output = new double[20];
for (int i = 0; i < 20; i++)
{
seriesX[i] = 100.0 + i;
seriesY[i] = 200.0 + (i * 2);
}
Correl.Batch(seriesX, seriesY, output, 5);
// First value should be NaN (not enough data)
Assert.True(double.IsNaN(output[0]));
// After warmup, should have valid correlation
Assert.True(double.IsFinite(output[19]));
}
[Fact]
public void Calculate_Span_DifferentLengths_ThrowsArgumentException()
{
double[] seriesX = new double[10];
double[] seriesY = new double[15];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() => Correl.Batch(seriesX, seriesY, output, 5));
}
[Fact]
public void Calculate_Span_OutputWrongLength_ThrowsArgumentException()
{
double[] seriesX = new double[20];
double[] seriesY = new double[20];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() => Correl.Batch(seriesX, seriesY, output, 5));
}
[Fact]
public void Calculate_Span_InvalidPeriod_ThrowsArgumentException()
{
double[] seriesX = new double[20];
double[] seriesY = new double[20];
double[] output = new double[20];
Assert.Throws<ArgumentException>(() => Correl.Batch(seriesX, seriesY, output, 1));
}
[Fact]
public void CorrelationRange_AlwaysBetweenMinusOneAndOne()
{
var indicator = new Correl(10);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.3, seed: 12345);
var gbmY = new GBM(startPrice: 200, mu: 0.01, sigma: 0.5, seed: 54321);
for (int i = 0; i < 1000; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
var result = indicator.Update(x, y, true);
if (double.IsFinite(result.Value))
{
Assert.InRange(result.Value, -1.0, 1.0);
}
}
}
[Fact]
public void StreamingVsBatch_Consistency()
{
int period = 10;
int length = 100;
// Generate data
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.3, seed: 42);
var gbmY = new GBM(startPrice: 200, mu: 0.01, sigma: 0.4, seed: 123);
double[] seriesX = new double[length];
double[] seriesY = new double[length];
for (int i = 0; i < length; i++)
{
seriesX[i] = gbmX.Next().Close;
seriesY[i] = gbmY.Next().Close;
}
// Streaming calculation
var indicator = new Correl(period);
double[] streamingResults = new double[length];
for (int i = 0; i < length; i++)
{
streamingResults[i] = indicator.Update(seriesX[i], seriesY[i], true).Value;
}
// Batch calculation
double[] batchResults = new double[length];
Correl.Batch(seriesX, seriesY, batchResults, period);
// Compare last 50 values (after warmup)
for (int i = length - 50; i < length; i++)
{
if (double.IsFinite(streamingResults[i]) && double.IsFinite(batchResults[i]))
{
Assert.Equal(streamingResults[i], batchResults[i], 1e-9);
}
}
}
}
@@ -0,0 +1,793 @@
using Skender.Stock.Indicators;
using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Correlation (Pearson Correlation Coefficient) indicator.
/// Validates against Skender.Stock.Indicators.GetCorrelation and mathematical properties.
/// </summary>
public sealed class CorrelValidationTests : IDisposable
{
private const double Tolerance = 1e-10;
private readonly ValidationTestData _data;
private readonly ITestOutputHelper _output;
public CorrelValidationTests(ITestOutputHelper output)
{
_data = new ValidationTestData();
_output = output;
}
public void Dispose()
{
_data.Dispose();
GC.SuppressFinalize(this);
}
#region External Library Validation Skender
[Fact]
public void Validate_Skender_Correl()
{
// === DESCRIPTION ===
// Compares QuanTAlib Correlation against Skender.Stock.Indicators.GetCorrelation
// using Close prices (series A) vs Open prices (series B) from the same dataset.
const int period = 20;
// --- Skender: uses IQuote-based API ---
// GetCorrelation compares two quote series by their Close prices
// We use the same quotes for both but shift perspective: A=Close, B=Open
// To use GetCorrelation, we need two separate IEnumerable<Quote> that share the same dates
// Skender correlates the Close of quotesA with the Close of quotesB.
// So we create quotesB where Close = Open of the original data.
var quotesA = _data.SkenderQuotes; // Close = actual close prices
var quotesB = new Quote[_data.Count];
var closePrices = _data.ClosePrices.Span;
var openPrices = _data.OpenPrices.Span;
var timestamps = _data.Timestamps.Span;
for (int i = 0; i < _data.Count; i++)
{
quotesB[i] = new Quote
{
Date = new DateTime(timestamps[i], DateTimeKind.Utc),
Open = (decimal)openPrices[i],
High = (decimal)openPrices[i],
Low = (decimal)openPrices[i],
Close = (decimal)openPrices[i], // Use Open prices as the "Close" for series B
Volume = 0
};
}
var sResult = quotesA.GetCorrelation(quotesB, period).ToList();
// --- QuanTAlib: streaming API ---
var corr = new Correl(period);
var qValues = new List<double>();
for (int i = 0; i < _data.Count; i++)
{
var result = corr.Update(closePrices[i], openPrices[i]);
qValues.Add(result.Value);
}
// --- Compare ---
int matched = 0;
int compared = 0;
for (int i = period; i < _data.Count; i++)
{
double? sCorr = sResult[i].Correlation;
double qCorr = qValues[i];
if (!sCorr.HasValue || !double.IsFinite(qCorr))
{
continue;
}
compared++;
double diff = Math.Abs(qCorr - sCorr.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Correlation mismatch at [{i}]: QuanTAlib={qCorr:G17}, Skender={sCorr.Value:G17}, diff={diff:E3}");
matched++;
}
Assert.True(matched > 100, $"Only matched {matched} Correlation values (expected > 100)");
_output.WriteLine($"Correlation validated against Skender ({matched} values matched within tolerance {ValidationHelper.SkenderTolerance:E1})");
}
[Fact]
public void Validate_Skender_Correlation_MultiplePeriods()
{
// === DESCRIPTION ===
// Cross-validates QuanTAlib vs Skender across multiple lookback periods.
int[] periods = [10, 20, 50];
var closePrices = _data.ClosePrices.Span;
var openPrices = _data.OpenPrices.Span;
var timestamps = _data.Timestamps.Span;
// Build quotesB (Open prices as Close for series B)
var quotesB = new Quote[_data.Count];
for (int i = 0; i < _data.Count; i++)
{
quotesB[i] = new Quote
{
Date = new DateTime(timestamps[i], DateTimeKind.Utc),
Close = (decimal)openPrices[i],
};
}
foreach (int period in periods)
{
var sResult = _data.SkenderQuotes.GetCorrelation(quotesB, period).ToList();
var corr = new Correl(period);
int matched = 0;
for (int i = 0; i < _data.Count; i++)
{
var result = corr.Update(closePrices[i], openPrices[i]);
if (i >= period)
{
double? sCorr = sResult[i].Correlation;
if (sCorr.HasValue && double.IsFinite(result.Value))
{
double diff = Math.Abs(result.Value - sCorr.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Period={period}, [{i}]: Q={result.Value:G17}, S={sCorr.Value:G17}, diff={diff:E3}");
matched++;
}
}
}
Assert.True(matched > 50, $"Period={period}: only matched {matched} values");
_output.WriteLine($" Period {period}: {matched} values matched");
}
}
[Fact]
public void Validate_Skender_Correlation_HighLow()
{
// === DESCRIPTION ===
// Validates correlation between High and Low price series against Skender.
const int period = 20;
var highPrices = _data.HighPrices.Span;
var lowPrices = _data.LowPrices.Span;
var timestamps = _data.Timestamps.Span;
// quotesA: Close = High prices
var quotesA = new Quote[_data.Count];
var quotesB = new Quote[_data.Count];
for (int i = 0; i < _data.Count; i++)
{
var date = new DateTime(timestamps[i], DateTimeKind.Utc);
quotesA[i] = new Quote { Date = date, Close = (decimal)highPrices[i] };
quotesB[i] = new Quote { Date = date, Close = (decimal)lowPrices[i] };
}
var sResult = quotesA.GetCorrelation(quotesB, period).ToList();
var corr = new Correl(period);
int matched = 0;
for (int i = 0; i < _data.Count; i++)
{
var result = corr.Update(highPrices[i], lowPrices[i]);
if (i >= period)
{
double? sCorr = sResult[i].Correlation;
if (sCorr.HasValue && double.IsFinite(result.Value))
{
double diff = Math.Abs(result.Value - sCorr.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"HighLow [{i}]: Q={result.Value:G17}, S={sCorr.Value:G17}, diff={diff:E3}");
matched++;
}
}
}
Assert.True(matched > 100, $"Only matched {matched} HighLow correlation values");
_output.WriteLine($"Correlation (High vs Low) validated against Skender ({matched} values matched)");
}
#endregion
#region Mathematical Property Validation
[Fact]
public void Correlation_PerfectLinearPositive_ReturnsOne()
{
// y = a + b*x with b > 0 should give r = 1
var indicator = new Correl(20);
for (int i = 0; i < 50; i++)
{
double x = 10.0 + (i * 2.5);
double y = 5.0 + (3.0 * x); // y = 5 + 3x
indicator.Update(x, y);
}
Assert.Equal(1.0, indicator.Last.Value, 1e-9);
}
[Fact]
public void Correlation_PerfectLinearNegative_ReturnsMinusOne()
{
// y = a + b*x with b < 0 should give r = -1
var indicator = new Correl(20);
for (int i = 0; i < 50; i++)
{
double x = 10.0 + (i * 2.5);
double y = 100.0 - (2.0 * x); // y = 100 - 2x
indicator.Update(x, y);
}
Assert.Equal(-1.0, indicator.Last.Value, 1e-9);
}
[Fact]
public void Correlation_SymmetryProperty_XY_Equals_YX()
{
// Correl(X, Y) should equal Correl(Y, X)
var indicatorXY = new Correl(10);
var indicatorYX = new Correl(10);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
for (int i = 0; i < 100; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
indicatorXY.Update(x, y);
indicatorYX.Update(y, x);
}
Assert.Equal(indicatorXY.Last.Value, indicatorYX.Last.Value, 1e-10);
}
[Fact]
public void Correlation_ScaleInvariance_AffineTransform()
{
// Correlation is invariant under positive linear transformations
// corr(X, Y) = corr(aX + b, cY + d) when a, c > 0
var indicator1 = new Correl(10);
var indicator2 = new Correl(10);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
double a = 2.5, b = 100.0, c = 0.5, d = -50.0;
for (int i = 0; i < 100; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
indicator1.Update(x, y);
indicator2.Update((a * x) + b, (c * y) + d);
}
// Relax tolerance due to floating point precision with large transformations
Assert.Equal(indicator1.Last.Value, indicator2.Last.Value, 1e-6);
}
[Fact]
public void Correlation_BoundedProperty_AlwaysBetweenMinusOneAndOne()
{
// Correlation coefficient is always in [-1, 1]
var indicator = new Correl(10);
var gbmX = new GBM(startPrice: 100, mu: 0.1, sigma: 0.5, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: -0.05, sigma: 0.3, seed: 54321);
for (int i = 0; i < 1000; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
var result = indicator.Update(x, y);
if (double.IsFinite(result.Value))
{
Assert.InRange(result.Value, -1.0, 1.0);
}
}
}
[Fact]
public void Correlation_ZeroVariance_ReturnsNaN()
{
// When one or both series have zero variance, correlation is undefined
var indicator = new Correl(10);
for (int i = 0; i < 20; i++)
{
indicator.Update(100.0, 50.0 + i); // X constant, Y varying
}
// Correlation with constant series is undefined (0/0)
Assert.True(double.IsNaN(indicator.Last.Value));
}
#endregion
#region Known Value Tests
[Fact]
public void Correlation_KnownValues_SimpleSet()
{
// Test with known values that can be hand-calculated
// X = [1, 2, 3, 4, 5], Y = [2, 4, 5, 4, 5]
// Mean(X) = 3, Mean(Y) = 4
// Cov(X,Y) = ((1-3)(2-4) + (2-3)(4-4) + (3-3)(5-4) + (4-3)(4-4) + (5-3)(5-4)) / 5
// = (4 + 0 + 0 + 0 + 2) / 5 = 1.2
// Var(X) = ((1-3)² + (2-3)² + (3-3)² + (4-3)² + (5-3)²) / 5 = (4+1+0+1+4)/5 = 2
// Var(Y) = ((2-4)² + (4-4)² + (5-4)² + (4-4)² + (5-4)²) / 5 = (4+0+1+0+1)/5 = 1.2
// r = Cov(X,Y) / sqrt(Var(X) * Var(Y)) = 1.2 / sqrt(2 * 1.2) = 1.2 / sqrt(2.4)
// = 1.2 / 1.5492 ≈ 0.7746
var indicator = new Correl(5);
double[] x = [1, 2, 3, 4, 5];
double[] y = [2, 4, 5, 4, 5];
for (int i = 0; i < 5; i++)
{
indicator.Update(x[i], y[i]);
}
double expected = 1.2 / Math.Sqrt(2.0 * 1.2); // ≈ 0.7746
Assert.Equal(expected, indicator.Last.Value, 1e-4);
}
[Fact]
public void Correlation_KnownValues_NoCorrel()
{
// X = [1, 2, 3, 4, 5], Y = [3, 3, 3, 3, 3] (constant)
// Should be NaN (or 0 with special handling)
var indicator = new Correl(5);
double[] x = [1, 2, 3, 4, 5];
double[] y = [3, 3, 3, 3, 3];
for (int i = 0; i < 5; i++)
{
indicator.Update(x[i], y[i]);
}
// Zero variance in Y means correlation is undefined
Assert.True(double.IsNaN(indicator.Last.Value));
}
#endregion
#region Consistency Tests
[Fact]
public void Correlation_BatchMatchesStreaming()
{
var seriesX = new TSeries();
var seriesY = new TSeries();
var baseTime = DateTime.UtcNow;
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
for (int i = 0; i < 100; i++)
{
seriesX.Add(baseTime.AddMinutes(i), gbmX.Next().Close);
seriesY.Add(baseTime.AddMinutes(i), gbmY.Next().Close);
}
// Batch calculation
var batchResult = Correl.Batch(seriesX, seriesY, 20);
// Streaming calculation
var streamingIndicator = new Correl(20);
for (int i = 0; i < seriesX.Count; i++)
{
streamingIndicator.Update(seriesX[i].Value, seriesY[i].Value);
}
// Last values should match
if (double.IsNaN(batchResult.Last.Value) && double.IsNaN(streamingIndicator.Last.Value))
{
Assert.True(true);
}
else
{
Assert.Equal(batchResult.Last.Value, streamingIndicator.Last.Value, Tolerance);
}
}
[Fact]
public void Correlation_SpanMatchesStreaming()
{
const int length = 100;
var seriesX = new double[length];
var seriesY = new double[length];
var output = new double[length];
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
for (int i = 0; i < length; i++)
{
seriesX[i] = gbmX.Next().Close;
seriesY[i] = gbmY.Next().Close;
}
// Span calculation
Correl.Batch(seriesX, seriesY, output, 20);
// Streaming calculation
var streamingIndicator = new Correl(20);
for (int i = 0; i < length; i++)
{
streamingIndicator.Update(seriesX[i], seriesY[i]);
}
// Last values should match
if (double.IsNaN(output[length - 1]) && double.IsNaN(streamingIndicator.Last.Value))
{
Assert.True(true);
}
else
{
Assert.Equal(output[length - 1], streamingIndicator.Last.Value, Tolerance);
}
}
[Fact]
public void Correlation_ResetProducesSameResults()
{
var indicator = new Correl(20);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
// First run
for (int i = 0; i < 50; i++)
{
indicator.Update(gbmX.Next().Close, gbmY.Next().Close);
}
var firstResult = indicator.Last.Value;
indicator.Reset();
// Second run with same seeds
gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
for (int i = 0; i < 50; i++)
{
indicator.Update(gbmX.Next().Close, gbmY.Next().Close);
}
var secondResult = indicator.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region Rolling Window Tests
[Fact]
public void Correlation_SlidingWindow_MovesCorrectly()
{
var indicator = new Correl(5);
// Build up with known values for period 5
// After 5 values, window should be full
double[] x = [10, 20, 30, 40, 50, 60, 70];
double[] y = [15, 25, 35, 45, 55, 65, 75];
for (int i = 0; i < 5; i++)
{
indicator.Update(x[i], y[i]);
}
// Perfect correlation with same-slope linear data
Assert.Equal(1.0, indicator.Last.Value, 1e-9);
// Add more - window should slide
indicator.Update(x[5], y[5]);
Assert.Equal(1.0, indicator.Last.Value, 1e-9); // Still perfect linear
indicator.Update(x[6], y[6]);
Assert.Equal(1.0, indicator.Last.Value, 1e-9); // Still perfect linear
}
[Fact]
public void Correlation_SlidingWindow_DropsOldValues()
{
var indicator = new Correl(3);
// First window: perfectly correlated
indicator.Update(1, 2);
indicator.Update(2, 4);
indicator.Update(3, 6);
Assert.Equal(1.0, indicator.Last.Value, 1e-9);
// Add value that breaks perfect correlation in new window
indicator.Update(4, 7); // Window is now [2,4,7] for Y, [2,3,4] for X
// Not perfect linear anymore
Assert.NotEqual(1.0, indicator.Last.Value);
}
#endregion
#region Numerical Stability
[Fact]
public void Correlation_LargeValues_MaintainsStability()
{
var indicator = new Correl(20);
for (int i = 0; i < 50; i++)
{
double x = 1e8 + (i * 1e5);
double y = 2e8 + (2.0 * (i * 1e5)); // Linear relationship
indicator.Update(x, y);
}
// Should still detect linear relationship
Assert.InRange(indicator.Last.Value, 0.99, 1.01);
}
[Fact]
public void Correlation_SmallValues_MaintainsStability()
{
var indicator = new Correl(20);
// Use values that are small but not so small they cause numerical issues
for (int i = 0; i < 50; i++)
{
double x = 0.001 + (i * 0.0001);
double y = 0.002 + (1.5 * (i * 0.0001)); // Linear relationship
indicator.Update(x, y);
}
// Should still detect linear relationship
Assert.InRange(indicator.Last.Value, 0.99, 1.01);
}
[Fact]
public void Correlation_MixedMagnitudes_HandlesCorrectly()
{
var indicator = new Correl(20);
for (int i = 0; i < 50; i++)
{
double x = 1000.0 + i;
double y = 0.001 * (1000.0 + i); // Same pattern, different scale
indicator.Update(x, y);
}
// Should detect perfect correlation despite scale difference
Assert.Equal(1.0, indicator.Last.Value, 1e-9);
}
#endregion
#region Statistical Scenarios
[Fact]
public void Correlation_HighPositiveCorrelation_DetectedCorrectly()
{
// Create two series with high positive correlation (r ≈ 0.95+)
var indicator = new Correl(20);
// Use deterministic data that creates high correlation
for (int i = 0; i < 100; i++)
{
double x = 100.0 + i + ((i % 3) * 0.1); // Small variation
double y = (0.9 * x) + ((i % 5) * 0.2); // High correlation with small noise
indicator.Update(x, y);
}
Assert.True(indicator.Last.Value > 0.9);
}
[Fact]
public void Correlation_NegativeCorrelation_DetectedCorrectly()
{
// Create two series with negative correlation
var indicator = new Correl(20);
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 42);
for (int i = 0; i < 100; i++)
{
double x = 100.0 + i + (Math.Log(random.Next().Close / 100.0) * 2);
double y = 200.0 - (0.8 * i) + (Math.Log(random.Next().Close / 100.0) * 2); // Negative relationship
indicator.Update(x, y);
}
Assert.True(indicator.Last.Value < -0.8);
}
[Fact]
public void Correlation_WeakCorrelation_DetectedCorrectly()
{
// Create two series with weak correlation: pure independent noise, no shared trend.
// Use two independent GBMs (different seeds) and feed their incremental log-returns directly.
// With period=20 and fully independent noise sequences, correlation should be near zero.
var indicator = new Correl(20);
var gbmX = new GBM(startPrice: 100.0, sigma: 0.2, seed: 43);
var gbmY = new GBM(startPrice: 100.0, sigma: 0.2, seed: 9871);
var barsX = gbmX.Fetch(101, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var barsY = gbmY.Fetch(101, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 1; i <= 100; i++)
{
// Pure independent white noise — no shared linear component
double x = Math.Log(barsX[i].Close / barsX[i - 1].Close);
double y = Math.Log(barsY[i].Close / barsY[i - 1].Close);
indicator.Update(x, y);
}
// Should be close to zero but may be positive or negative
Assert.InRange(Math.Abs(indicator.Last.Value), 0, 0.5);
}
#endregion
#region Different Period Tests
[Fact]
public void Correlation_DifferentPeriods_ProduceDifferentResults()
{
var indicator5 = new Correl(5);
var indicator20 = new Correl(20);
var indicator50 = new Correl(50);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
for (int i = 0; i < 100; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
indicator5.Update(x, y);
indicator20.Update(x, y);
indicator50.Update(x, y);
}
// Different periods should yield different values
Assert.NotEqual(indicator5.Last.Value, indicator20.Last.Value);
Assert.NotEqual(indicator20.Last.Value, indicator50.Last.Value);
}
[Fact]
public void Correlation_SmallPeriod_MoreVolatile()
{
var indicator3 = new Correl(3);
var indicator30 = new Correl(30);
var gbmX = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 12345);
var gbmY = new GBM(startPrice: 50, mu: 0.01, sigma: 0.15, seed: 54321);
var values3 = new List<double>();
var values30 = new List<double>();
for (int i = 0; i < 100; i++)
{
double x = gbmX.Next().Close;
double y = gbmY.Next().Close;
indicator3.Update(x, y);
indicator30.Update(x, y);
if (double.IsFinite(indicator3.Last.Value))
{
values3.Add(indicator3.Last.Value);
}
if (double.IsFinite(indicator30.Last.Value))
{
values30.Add(indicator30.Last.Value);
}
}
// Calculate variance of correlation values
double variance3 = CalculateVariance(values3);
double variance30 = CalculateVariance(values30);
// Shorter period should have higher variance (more volatile)
Assert.True(variance3 > variance30, $"Expected small period variance ({variance3}) > large period variance ({variance30})");
}
private static double CalculateVariance(List<double> values)
{
if (values.Count < 2)
{
return 0;
}
double mean = values.Average();
return values.Sum(v => (v - mean) * (v - mean)) / (values.Count - 1);
}
#endregion
#region External Library Validation TALib
[Fact]
public void Validate_Talib_Correlation_Batch()
{
// TALib Correl computes Pearson correlation coefficient between two price series.
// Uses Close prices (series A) vs Open prices (series B), matching the Skender tests.
// TALib and QuanTAlib use identical Pearson formulas → expect exact numeric match (1e-9).
const int period = 20;
var closePrices = _data.ClosePrices.Span;
var openPrices = _data.OpenPrices.Span;
double[] closeArr = closePrices.ToArray();
double[] openArr = openPrices.ToArray();
double[] taOut = new double[_data.Count];
var retCode = Functions.Correl<double>(closeArr, openArr, 0..^0, taOut, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
(int offset, int length) = outRange.GetOffsetAndLength(taOut.Length);
Assert.True(length > 100, $"TALib Correl produced only {length} values");
// QuanTAlib streaming
var corr = new Correl(period);
var qlValues = new double[_data.Count];
for (int i = 0; i < _data.Count; i++)
{
qlValues[i] = corr.Update(closePrices[i], openPrices[i]).Value;
}
// Compare outputs — offset aligns TALib to the full series
int mismatches = 0;
for (int j = 0; j < length; j++)
{
int qi = j + offset;
double diff = Math.Abs(qlValues[qi] - taOut[j]);
if (diff > ValidationHelper.SkenderTolerance)
{
mismatches++;
Assert.Fail($"Correl mismatch at index [{qi}]: QuanTAlib={qlValues[qi]:G17}, TALib={taOut[j]:G17}, diff={diff:E3}");
}
}
_output.WriteLine($"Correlation validated against TALib Correl ({length} values matched within tolerance {ValidationHelper.SkenderTolerance:E1})");
}
[Fact]
public void Validate_Talib_Correlation_MultiplePeriods()
{
// Verify match across periods 10, 20, 50 using High vs Low series.
var highArr = _data.HighPrices.Span.ToArray();
var lowArr = _data.LowPrices.Span.ToArray();
foreach (int period in new[] { 10, 20, 50 })
{
double[] taOut = new double[_data.Count];
var retCode = Functions.Correl<double>(highArr, lowArr, 0..^0, taOut, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
(int offset, int length) = outRange.GetOffsetAndLength(taOut.Length);
var corr = new Correl(period);
var qlValues = new double[_data.Count];
for (int i = 0; i < _data.Count; i++)
{
qlValues[i] = corr.Update(_data.HighPrices.Span[i], _data.LowPrices.Span[i]).Value;
}
for (int j = 0; j < length; j++)
{
int qi = j + offset;
double diff = Math.Abs(qlValues[qi] - taOut[j]);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Period={period}, [{qi}]: Q={qlValues[qi]:G17}, TALib={taOut[j]:G17}, diff={diff:E3}");
}
_output.WriteLine($" Period {period}: {length} values matched against TALib");
}
}
#endregion
}