mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 20:48:04 +00:00
SIMD Refactor: Merge simd-dev into dev (#55)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat> Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
co-authored by
Claude Opus 4.5
aider
Warp
parent
5bcdf8d614
commit
86fe32a682
@@ -0,0 +1,236 @@
|
||||
using Xunit;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class ChangeIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChangeIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
|
||||
Assert.Equal(1, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("CHANGE - Percentage Change", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.False(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_MinHistoryDepths_IsPeriodPlusOne()
|
||||
{
|
||||
var indicator = new ChangeIndicator { Period = 10 };
|
||||
Assert.Equal(11, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new ChangeIndicator { Period = 5 };
|
||||
Assert.Equal("CHANGE(5)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
Assert.Equal("Change", indicator.LinesSeries[0].Name);
|
||||
Assert.Equal("Zero", indicator.LinesSeries[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
Assert.Equal(1, indicator.LinesSeries[1].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(
|
||||
now.AddMinutes(i),
|
||||
100 + i * 2,
|
||||
105 + i * 2,
|
||||
95 + i * 2,
|
||||
102 + i * 2);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
Assert.Equal(20, indicator.LinesSeries[0].Count);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
|
||||
Assert.Equal(0, indicator.LinesSeries[1].GetValue(i));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
var sources = new[]
|
||||
{
|
||||
SourceType.Open,
|
||||
SourceType.High,
|
||||
SourceType.Low,
|
||||
SourceType.Close,
|
||||
SourceType.HL2,
|
||||
SourceType.HLC3,
|
||||
};
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new ChangeIndicator { Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_ShowColdValues_False_SetsNaN()
|
||||
{
|
||||
var indicator = new ChangeIndicator { ShowColdValues = false };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_Uptrend_ProducesPositiveChange()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100 + i * 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lastChange = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(lastChange > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_Downtrend_ProducesNegativeChange()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 200 - i * 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lastChange = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(lastChange < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_FlatPrices_ProducesZeroChange()
|
||||
{
|
||||
var indicator = new ChangeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lastChange = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(0, lastChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeIndicator_KnownChange_Correct()
|
||||
{
|
||||
var indicator = new ChangeIndicator { Period = 1 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Add bar at 100
|
||||
indicator.HistoricalData.AddBar(now, 100, 100, 100, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add bar at 110 (10% change)
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 110, 110, 110, 110);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// (110 - 100) / 100 = 0.1
|
||||
double change = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(0.1, change, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// CHANGE (Percentage Change) Quantower indicator.
|
||||
/// Calculates relative price movement over a lookback period.
|
||||
/// Formula: (current - past) / past
|
||||
/// </summary>
|
||||
public class ChangeIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", 0, 1, 999, 1, 0)]
|
||||
public int Period { get; set; } = 1;
|
||||
|
||||
[DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Change? _change;
|
||||
private Func<IHistoryItem, double>? _selector;
|
||||
|
||||
public int MinHistoryDepths => Period + 1;
|
||||
public override string ShortName => $"CHANGE({Period})";
|
||||
|
||||
public ChangeIndicator()
|
||||
{
|
||||
Name = "CHANGE - Percentage Change";
|
||||
Description = "Calculates relative price movement: (current - past) / past";
|
||||
SeparateWindow = true;
|
||||
OnBackGround = false;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_change = new Change(Period);
|
||||
_selector = Source.GetPriceSelector();
|
||||
|
||||
AddLineSeries(new LineSeries("Change", Momentum, 2, LineStyle.Histogramm));
|
||||
AddLineSeries(new LineSeries("Zero", Color.Gray, 1, LineStyle.Dot));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_change == null || _selector == null) return;
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double value = _selector(item);
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TValue input = new(item.TimeLeft, value);
|
||||
_change.Update(input, isNew);
|
||||
|
||||
bool isHot = _change.IsHot;
|
||||
|
||||
LinesSeries[0].SetValue(_change.Last.Value, isHot, ShowColdValues);
|
||||
LinesSeries[1].SetValue(0);
|
||||
|
||||
if (isHot || ShowColdValues)
|
||||
{
|
||||
double change = _change.Last.Value;
|
||||
Color color;
|
||||
if (change > 0)
|
||||
color = Color.Green;
|
||||
else if (change < 0)
|
||||
color = Color.Red;
|
||||
else
|
||||
color = Color.Gray;
|
||||
LinesSeries[0].SetMarker(0, new IndicatorLineMarker(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class ChangeTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private readonly TSeries _source;
|
||||
|
||||
public ChangeTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 60000);
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_source = bars.Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Constructor_ThrowsOnInvalidPeriod()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Change(0));
|
||||
Assert.Throws<ArgumentException>(() => new Change(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Constructor_ValidPeriod()
|
||||
{
|
||||
var indicator = new Change(5);
|
||||
Assert.Equal("Change(5)", indicator.Name);
|
||||
Assert.Equal(6, indicator.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Update_ReturnsValue()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var result = indicator.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal(0.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_BasicCalculation()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 110.0));
|
||||
|
||||
// (110 - 100) / 100 = 0.1
|
||||
Assert.Equal(0.1, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_NegativeChange()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 90.0));
|
||||
|
||||
// (90 - 100) / 100 = -0.1
|
||||
Assert.Equal(-0.1, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Period2()
|
||||
{
|
||||
var indicator = new Change(2);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 105.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(2), 120.0));
|
||||
|
||||
// (120 - 100) / 100 = 0.2
|
||||
Assert.Equal(0.2, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_IsHot_WhenWarmedUp()
|
||||
{
|
||||
var indicator = new Change(3);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.False(indicator.IsHot);
|
||||
indicator.Update(new TValue(time.AddMinutes(i), 100.0 + i));
|
||||
}
|
||||
|
||||
indicator.Update(new TValue(time.AddMinutes(3), 110.0));
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Reset_ClearsState()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 110.0));
|
||||
Assert.True(indicator.IsHot);
|
||||
|
||||
indicator.Reset();
|
||||
Assert.False(indicator.IsHot);
|
||||
Assert.Equal(default, indicator.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_IsNew_False_RollsBack()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0), true);
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 110.0), true);
|
||||
|
||||
// Update with isNew=false (correction)
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 115.0), false);
|
||||
|
||||
// Should recalculate: (115 - 100) / 100 = 0.15
|
||||
Assert.Equal(0.15, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_NaN_HandledGracefully()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 100.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), double.NaN));
|
||||
|
||||
// Should use last valid value (100), so (100 - 100) / 100 = 0
|
||||
Assert.True(double.IsFinite(indicator.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_ZeroDivision_ReturnsZero()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
indicator.Update(new TValue(time, 0.0));
|
||||
indicator.Update(new TValue(time.AddMinutes(1), 100.0));
|
||||
|
||||
// Division by zero returns 0
|
||||
Assert.Equal(0.0, indicator.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Batch_MatchesStreaming()
|
||||
{
|
||||
int period = 5;
|
||||
var batchResult = Change.Calculate(_source, period);
|
||||
var indicator = new Change(period);
|
||||
|
||||
for (int i = 0; i < _source.Count; i++)
|
||||
{
|
||||
indicator.Update(_source[i]);
|
||||
}
|
||||
|
||||
// Compare last 10 values
|
||||
for (int i = Math.Max(0, _source.Count - 10); i < _source.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchResult[i].Value, batchResult[i].Value, 1e-10);
|
||||
}
|
||||
|
||||
// Ensure final values match
|
||||
Assert.Equal(batchResult[^1].Value, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Span_MatchesBatch()
|
||||
{
|
||||
int period = 5;
|
||||
var values = _source.Values.ToArray();
|
||||
var output = new double[values.Length];
|
||||
|
||||
Change.Calculate(values, output, period);
|
||||
var batchResult = Change.Calculate(_source, period);
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
Assert.Equal(batchResult[i].Value, output[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Span_ThrowsOnInvalidArgs()
|
||||
{
|
||||
var source = new double[10];
|
||||
var output = new double[5];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Change.Calculate(ReadOnlySpan<double>.Empty, output, 1));
|
||||
Assert.Throws<ArgumentException>(() => Change.Calculate(source, output, 1));
|
||||
Assert.Throws<ArgumentException>(() => Change.Calculate(source, new double[10], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_EventChaining_Works()
|
||||
{
|
||||
var source = new Sma(5);
|
||||
var change = new Change(source, 1);
|
||||
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
source.Update(new TValue(time.AddMinutes(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(change.IsHot);
|
||||
Assert.NotEqual(0.0, change.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CHANGE validation tests - validates against direct mathematical computation
|
||||
/// and Tulip's ROC indicator (both return decimal format: 0.1 = 10%)
|
||||
/// </summary>
|
||||
public class ChangeValidationTests
|
||||
{
|
||||
private readonly GBM _gbm = new(sigma: 0.5, mu: 0.05, seed: 60100);
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
[Fact]
|
||||
public void Change_Batch_MatchesMathFormula()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
int period = 10;
|
||||
|
||||
var result = Change.Calculate(series, period);
|
||||
|
||||
for (int i = period; i < series.Count; i++)
|
||||
{
|
||||
double current = series[i].Value;
|
||||
double past = series[i - period].Value;
|
||||
double expected = past != 0.0 ? (current - past) / past : 0.0;
|
||||
Assert.Equal(expected, result[i].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Streaming_MatchesMathFormula()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
int period = 5;
|
||||
|
||||
var indicator = new Change(period);
|
||||
var results = new List<double>();
|
||||
ReadOnlySpan<double> values = series.Values;
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
indicator.Update(series[i]);
|
||||
results.Add(indicator.Last.Value);
|
||||
}
|
||||
|
||||
for (int i = period; i < series.Count; i++)
|
||||
{
|
||||
double current = values[i];
|
||||
double past = values[i - period];
|
||||
double expected = past != 0.0 ? (current - past) / past : 0.0;
|
||||
Assert.Equal(expected, results[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Span_MatchesMathFormula()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var values = bars.Close.Values.ToArray();
|
||||
var output = new double[values.Length];
|
||||
int period = 10;
|
||||
|
||||
Change.Calculate(values, output, period);
|
||||
|
||||
for (int i = period; i < values.Length; i++)
|
||||
{
|
||||
double current = values[i];
|
||||
double past = values[i - period];
|
||||
double expected = past != 0.0 ? (current - past) / past : 0.0;
|
||||
Assert.Equal(expected, output[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Validate_Tulip_Batch()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
double[] tData = source.Values.ToArray();
|
||||
int period = 10;
|
||||
|
||||
// Calculate QuanTAlib Change
|
||||
var qResult = Change.Calculate(source, period);
|
||||
|
||||
// Calculate Tulip ROC (returns percentage)
|
||||
var rocIndicator = Tulip.Indicators.roc;
|
||||
double[][] inputs = [tData];
|
||||
double[] options = [period];
|
||||
int lookback = period;
|
||||
double[][] outputs = [new double[tData.Length - lookback]];
|
||||
|
||||
rocIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare (Tulip ROC returns same format as QuanTAlib CHANGE)
|
||||
for (int i = 0; i < tResult.Length; i++)
|
||||
{
|
||||
int qIdx = i + lookback;
|
||||
Assert.Equal(tResult[i], qResult[qIdx].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Validate_Tulip_Streaming()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
double[] tData = source.Values.ToArray();
|
||||
int period = 10;
|
||||
|
||||
// Calculate QuanTAlib Change (streaming)
|
||||
var indicator = new Change(period);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in source)
|
||||
{
|
||||
qResults.Add(indicator.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate Tulip ROC
|
||||
var rocIndicator = Tulip.Indicators.roc;
|
||||
double[][] inputs = [tData];
|
||||
double[] options = [period];
|
||||
int lookback = period;
|
||||
double[][] outputs = [new double[tData.Length - lookback]];
|
||||
|
||||
rocIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare (Tulip ROC returns same format as QuanTAlib CHANGE)
|
||||
for (int i = 0; i < tResult.Length; i++)
|
||||
{
|
||||
int qIdx = i + lookback;
|
||||
Assert.Equal(tResult[i], qResults[qIdx], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_ManualCalculation()
|
||||
{
|
||||
var indicator = new Change(1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
double[] values = [100.0, 105.0, 102.0, 108.0, 104.0];
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
indicator.Update(new TValue(time.AddMinutes(i), values[i]));
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
Assert.Equal(0.0, indicator.Last.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
double expectedChange = (values[i] - values[i - 1]) / values[i - 1];
|
||||
Assert.Equal(expectedChange, indicator.Last.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_AllModesConsistent()
|
||||
{
|
||||
int count = 50;
|
||||
int period = 5;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 60103);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
|
||||
// Batch
|
||||
var batchResult = Change.Calculate(source, period);
|
||||
|
||||
// Streaming
|
||||
var streamingIndicator = new Change(period);
|
||||
var streamingResults = new double[count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamingIndicator.Update(source[i]);
|
||||
streamingResults[i] = streamingIndicator.Last.Value;
|
||||
}
|
||||
|
||||
// Span
|
||||
var values = source.Values.ToArray();
|
||||
var spanOutput = new double[count];
|
||||
Change.Calculate(values, spanOutput, period);
|
||||
|
||||
// Event-driven
|
||||
var eventIndicator = new Change(period);
|
||||
var eventResults = new double[count];
|
||||
int eventIdx = 0;
|
||||
eventIndicator.Pub += (object? _, in TValueEventArgs e) => eventResults[eventIdx++] = e.Value.Value;
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
eventIndicator.Update(source[i]);
|
||||
}
|
||||
|
||||
// Compare all modes
|
||||
for (int i = period; i < count; i++)
|
||||
{
|
||||
Assert.Equal(batchResult[i].Value, streamingResults[i], Tolerance);
|
||||
Assert.Equal(batchResult[i].Value, spanOutput[i], Tolerance);
|
||||
Assert.Equal(batchResult[i].Value, eventResults[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_DifferentPeriods_MatchTulip()
|
||||
{
|
||||
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = bars.Close;
|
||||
var values = source.Values.ToArray();
|
||||
|
||||
foreach (int period in new[] { 1, 5, 10, 20 })
|
||||
{
|
||||
var result = Change.Calculate(source, period);
|
||||
|
||||
// Calculate Tulip ROC
|
||||
var rocIndicator = Tulip.Indicators.roc;
|
||||
double[][] inputs = [values];
|
||||
double[] options = [period];
|
||||
int lookback = period;
|
||||
double[][] outputs = [new double[values.Length - lookback]];
|
||||
|
||||
rocIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare
|
||||
for (int i = 0; i < tResult.Length; i++)
|
||||
{
|
||||
int qIdx = i + lookback;
|
||||
Assert.Equal(tResult[i], result[qIdx].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_KnownValues()
|
||||
{
|
||||
// Test with simple known sequence
|
||||
double[] data = [100, 110, 99, 120, 100];
|
||||
int period = 1;
|
||||
|
||||
// Expected: 0, 0.1, -0.1, 0.21212..., -0.16666...
|
||||
double[] expected =
|
||||
[
|
||||
0.0,
|
||||
0.1, // (110-100)/100
|
||||
-0.1, // (99-110)/110
|
||||
120.0 / 99.0 - 1.0, // (120-99)/99
|
||||
100.0 / 120.0 - 1.0 // (100-120)/120
|
||||
];
|
||||
|
||||
var indicator = new Change(period);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
var result = indicator.Update(new TValue(DateTime.UtcNow, data[i]));
|
||||
Assert.Equal(expected[i], result.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Change_Period2_KnownValues()
|
||||
{
|
||||
double[] data = [100, 105, 120, 110, 130];
|
||||
int period = 2;
|
||||
|
||||
// Expected changes comparing to 2 bars ago:
|
||||
// [0]: 0 (not enough data)
|
||||
// [1]: 0 (not enough data)
|
||||
// [2]: (120-100)/100 = 0.2
|
||||
// [3]: (110-105)/105 = 0.0476...
|
||||
// [4]: (130-120)/120 = 0.0833...
|
||||
|
||||
var indicator = new Change(period);
|
||||
var results = new double[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
results[i] = indicator.Update(new TValue(DateTime.UtcNow, data[i])).Value;
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, results[0], Tolerance);
|
||||
Assert.Equal(0.0, results[1], Tolerance);
|
||||
Assert.Equal(0.2, results[2], Tolerance);
|
||||
Assert.Equal((110.0 - 105.0) / 105.0, results[3], Tolerance);
|
||||
Assert.Equal((130.0 - 120.0) / 120.0, results[4], Tolerance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// CHANGE: Relative price movement over lookback period
|
||||
// Calculates percentage change: (current - past) / past
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// CHANGE: Relative Price Change
|
||||
/// Calculates the percentage change between current value and value N periods ago.
|
||||
/// Formula: (current - past) / past
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Key properties:
|
||||
/// - Returns relative price movement as a decimal (multiply by 100 for percent)
|
||||
/// - Useful for momentum measurement, rate of change analysis
|
||||
/// - Can be validated against TA-Lib ROC function (when multiplied by 100)
|
||||
/// - Returns 0 when past value is 0 to avoid division by zero
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Change : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
private record struct State(double LastValid);
|
||||
private State _state, _p_state;
|
||||
|
||||
public override bool IsHot => _buffer.Count > _period;
|
||||
|
||||
/// <param name="period">Lookback period (must be >= 1)</param>
|
||||
public Change(int period = 1)
|
||||
{
|
||||
if (period < 1)
|
||||
throw new ArgumentException("Period must be >= 1", nameof(period));
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period + 1);
|
||||
Name = $"Change({period})";
|
||||
WarmupPeriod = period + 1;
|
||||
}
|
||||
|
||||
/// <param name="source">Source indicator for chaining</param>
|
||||
/// <param name="period">Lookback period</param>
|
||||
public Change(ITValuePublisher source, int period = 1) : this(period)
|
||||
{
|
||||
source.Pub += HandleUpdate;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
_p_state = _state;
|
||||
else
|
||||
_state = _p_state;
|
||||
|
||||
double value = double.IsFinite(input.Value) ? input.Value : _state.LastValid;
|
||||
_state = new State(value);
|
||||
|
||||
_buffer.Add(value, isNew);
|
||||
|
||||
double result;
|
||||
if (_buffer.Count <= _period)
|
||||
{
|
||||
result = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double past = _buffer[0];
|
||||
result = past != 0.0 ? (value - past) / past : 0.0;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
var result = new TSeries(source.Count);
|
||||
ReadOnlySpan<double> values = source.Values;
|
||||
ReadOnlySpan<long> times = source.Times;
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
|
||||
result.Add(tv, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
|
||||
DateTime time = DateTime.UtcNow - (interval * source.Length);
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(time, source[i]), true);
|
||||
time += interval;
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Calculate(TSeries source, int period = 1)
|
||||
{
|
||||
var indicator = new Change(period);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates relative change over a span of values.
|
||||
/// </summary>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period = 1)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
throw new ArgumentException("Source cannot be empty", nameof(source));
|
||||
if (output.Length < source.Length)
|
||||
throw new ArgumentException("Output length must be >= source length", nameof(output));
|
||||
if (period < 1)
|
||||
throw new ArgumentException("Period must be >= 1", nameof(period));
|
||||
|
||||
// Use ArrayPool for large periods to track past valid values
|
||||
const int StackAllocThreshold = 256;
|
||||
double[]? pastValidRented = null;
|
||||
|
||||
#pragma warning disable S1121
|
||||
Span<double> pastValidBuffer = period <= StackAllocThreshold
|
||||
? stackalloc double[period]
|
||||
: (pastValidRented = System.Buffers.ArrayPool<double>.Shared.Rent(period)).AsSpan(0, period);
|
||||
#pragma warning restore S1121
|
||||
|
||||
try
|
||||
{
|
||||
double lastValidCurrent = 0.0;
|
||||
int bufferIdx = 0;
|
||||
pastValidBuffer.Fill(0.0);
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
// Handle non-finite values by substitution for current
|
||||
double current = source[i];
|
||||
if (!double.IsFinite(current))
|
||||
{
|
||||
current = lastValidCurrent;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidCurrent = current;
|
||||
}
|
||||
|
||||
if (i < period)
|
||||
{
|
||||
output[i] = 0.0;
|
||||
// Store valid values for later past lookups
|
||||
pastValidBuffer[i] = current;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get past value with proper tracking
|
||||
double past = source[i - period];
|
||||
if (!double.IsFinite(past))
|
||||
{
|
||||
// Use the tracked valid value from period bars ago
|
||||
past = pastValidBuffer[bufferIdx];
|
||||
}
|
||||
|
||||
output[i] = past != 0.0 ? (current - past) / past : 0.0;
|
||||
|
||||
// Update circular buffer with current valid value for future past lookups
|
||||
pastValidBuffer[bufferIdx] = current;
|
||||
bufferIdx = (bufferIdx + 1) % period;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pastValidRented != null)
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(pastValidRented);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = default;
|
||||
_p_state = default;
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# CHANGE: Relative Price Change
|
||||
|
||||
> "The simplest measure of movement is often the most powerful."
|
||||
|
||||
CHANGE calculates the percentage change between the current value and a value N periods ago. This fundamental indicator forms the basis for momentum analysis, rate of change calculations, and relative performance comparisons.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The change calculation is straightforward:
|
||||
|
||||
$$
|
||||
\text{Change}_t = \frac{P_t - P_{t-n}}{P_{t-n}}
|
||||
$$
|
||||
|
||||
where:
|
||||
- $P_t$ = current price
|
||||
- $P_{t-n}$ = price N periods ago
|
||||
- Result is expressed as a decimal (multiply by 100 for percentage)
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Division by zero**: When $P_{t-n} = 0$, returns 0
|
||||
- **NaN/Infinity inputs**: Uses last valid value substitution
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Per Bar)
|
||||
|
||||
| Operation | Count | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| Subtraction | 1 | Current - Past |
|
||||
| Division | 1 | Conditional on past ≠ 0 |
|
||||
| Buffer access | 1 | Ring buffer lookup |
|
||||
| **Total** | **~3** | O(1) constant time |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact mathematical calculation |
|
||||
| **Timeliness** | 10/10 | No lag beyond lookback period |
|
||||
| **Smoothness** | 3/10 | Raw returns are noisy |
|
||||
| **Memory** | 9/10 | Only stores period+1 values |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | ✅ | ROC function (divide by 100) |
|
||||
| **Skender** | ✅ | Roc indicator |
|
||||
| **Manual** | ✅ | Direct calculation verified |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Percentage vs Decimal**: QuanTAlib returns decimal (0.1 = 10%), while TA-Lib ROC returns percentage (10.0 = 10%). Multiply by 100 when comparing.
|
||||
|
||||
2. **Warmup Period**: Requires `period + 1` bars before producing meaningful results. First `period` values return 0.
|
||||
|
||||
3. **Zero Division**: When the past value is zero, returns 0 rather than NaN/Infinity.
|
||||
|
||||
4. **Compounding**: For multi-period returns, geometric compounding may be more appropriate than simple arithmetic change.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```csharp
|
||||
// Period-1 change (simple return)
|
||||
var change = new Change(1);
|
||||
|
||||
// 10-period momentum
|
||||
var momentum = new Change(10);
|
||||
|
||||
// Chained from another indicator
|
||||
var smaChange = new Change(new Sma(20), 5);
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Murphy, J. (1999). "Technical Analysis of the Financial Markets." New York Institute of Finance.
|
||||
- Pring, M. (2002). "Technical Analysis Explained." McGraw-Hill.
|
||||
@@ -0,0 +1,31 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Percentage Change (CHANGE)", "CHANGE", overlay=false, format=format.percent)
|
||||
|
||||
//@function Calculates the percentage change of a source series over a specified length using the history referencing operator for efficiency.
|
||||
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/numerics/change.md
|
||||
//@param source The source series (e.g. close price).
|
||||
//@param length The lookback period (number of bars). Must be > 0.
|
||||
//@returns float The percentage change over the specified length. Returns `na` if the historical value is `na` or zero.
|
||||
//@optimized Uses direct history access `source[length]` instead of array manipulation.
|
||||
change(float source, int length) =>
|
||||
if length <= 0
|
||||
runtime.error("Length must be greater than 0")
|
||||
float oldValue = source[length]
|
||||
if na(oldValue) or oldValue == 0
|
||||
na
|
||||
else
|
||||
(source / oldValue - 1) // Already a percentage, Pine handles plotting format
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, "Source")
|
||||
i_length = input.int(1, "Length", minval = 1)
|
||||
|
||||
// Calculation
|
||||
result = change(i_source, i_length)
|
||||
|
||||
// Plot
|
||||
plot(result, "Change %", color.blue, color=color.yellow, linewidth=2)
|
||||
Reference in New Issue
Block a user