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:
Miha Kralj
2026-01-18 19:02:03 -08:00
committed by GitHub
co-authored by Claude Opus 4.5 aider Warp
parent 5bcdf8d614
commit 86fe32a682
1750 changed files with 198235 additions and 80539 deletions
@@ -0,0 +1,167 @@
using Xunit;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class NormalizeIndicatorTests
{
[Fact]
public void NormalizeIndicator_Constructor_SetsDefaults()
{
var indicator = new NormalizeIndicator();
Assert.Equal(SourceType.Close, indicator.Source);
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("NORMALIZE - Min-Max Normalization", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void NormalizeIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new NormalizeIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
}
[Fact]
public void NormalizeIndicator_ShortName_IncludesPeriod()
{
var indicator = new NormalizeIndicator { Period = 10 };
Assert.Equal("NORM(10)", indicator.ShortName);
}
[Fact]
public void NormalizeIndicator_Initialize_CreatesLineSeries()
{
var indicator = new NormalizeIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
Assert.Equal("Normalize", indicator.LinesSeries[0].Name);
}
[Fact]
public void NormalizeIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new NormalizeIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Single bar: value = min = max, so normalized = 0.5
Assert.Equal(0.5, indicator.LinesSeries[0].GetValue(0), 1e-10);
}
[Fact]
public void NormalizeIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new NormalizeIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add bars with varying close values
indicator.HistoricalData.AddBar(now, 0, 1, 0, 0); // Close = 0 (min)
indicator.HistoricalData.AddBar(now.AddMinutes(1), 0, 1, 0, 10); // Close = 10 (max)
indicator.HistoricalData.AddBar(now.AddMinutes(2), 0, 1, 0, 5); // Close = 5 (mid)
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(3, indicator.LinesSeries[0].Count);
// Last value: 5 normalized to [0,10] = 0.5
Assert.Equal(0.5, indicator.LinesSeries[0].GetValue(0), 1e-10);
}
[Fact]
public void NormalizeIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new NormalizeIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void NormalizeIndicator_OutputAlwaysBounded()
{
var indicator = new NormalizeIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add various bars
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), i * 10, i * 10 + 5, i * 10 - 5, i * 10);
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
// All normalized values should be in [0, 1]
for (int i = 0; i < indicator.LinesSeries[0].Count; i++)
{
double val = indicator.LinesSeries[0].GetValue(i);
Assert.True(val >= 0.0 && val <= 1.0, $"Value {val} at index {i} is outside [0,1]");
}
}
[Fact]
public void NormalizeIndicator_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 NormalizeIndicator { Source = source, Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 10, 20, 5, 15);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val) && val >= 0 && val <= 1);
}
}
[Fact]
public void NormalizeIndicator_DifferentPeriods_Work()
{
var periods = new[] { 1, 5, 14, 50, 100 };
foreach (var period in periods)
{
var indicator = new NormalizeIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < period + 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), i, i + 1, i - 1, i);
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
Assert.Equal(period + 5, indicator.LinesSeries[0].Count);
}
}
}
@@ -0,0 +1,59 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// NORMALIZE (Min-Max Normalization) Quantower indicator.
/// Scales values to [0, 1] range using min-max scaling over a lookback period.
/// </summary>
public class NormalizeIndicator : Indicator, IWatchlistIndicator
{
[DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Period", sortIndex: 0, minimum: 1, maximum: 1000, increment: 1)]
public int Period { get; set; } = 14;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Normalize? _normalize;
private Func<IHistoryItem, double>? _selector;
public int MinHistoryDepths => Period;
public override string ShortName => $"NORM({Period})";
public NormalizeIndicator()
{
Name = "NORMALIZE - Min-Max Normalization";
Description = "Scales values to [0, 1] range using min-max scaling over a lookback period";
SeparateWindow = true;
OnBackGround = true;
}
protected override void OnInit()
{
_normalize = new Normalize(Period);
_selector = Source.GetPriceSelector();
AddLineSeries(new LineSeries("Normalize", Color.Green, 2, LineStyle.Solid));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_normalize == null || _selector == null) return;
var item = HistoricalData[0, SeekOriginHistory.End];
double value = _selector(item);
bool isNew = args.IsNewBar();
TValue input = new(item.TimeLeft, value);
_normalize.Update(input, isNew);
bool isHot = _normalize.IsHot;
LinesSeries[0].SetValue(_normalize.Last.Value, isHot, ShowColdValues);
}
}
+264
View File
@@ -0,0 +1,264 @@
using Xunit;
namespace QuanTAlib.Tests;
public class NormalizeTests
{
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
[Fact]
public void Normalize_Constructor_ValidPeriod_SetsProperties()
{
var norm = new Normalize(20);
Assert.Equal("Normalize(20)", norm.Name);
Assert.Equal(20, norm.WarmupPeriod);
Assert.False(norm.IsHot);
}
[Fact]
public void Normalize_Constructor_InvalidPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new Normalize(0));
Assert.Throws<ArgumentException>(() => new Normalize(-1));
}
[Fact]
public void Normalize_Update_BasicCalculation()
{
var norm = new Normalize(5);
// Feed values: 10, 20, 30, 40, 50
// After 5 values: min=10, max=50, range=40
// Current value 50: (50-10)/40 = 1.0
norm.Update(new TValue(DateTime.UtcNow, 10));
norm.Update(new TValue(DateTime.UtcNow, 20));
norm.Update(new TValue(DateTime.UtcNow, 30));
norm.Update(new TValue(DateTime.UtcNow, 40));
var result = norm.Update(new TValue(DateTime.UtcNow, 50));
Assert.Equal(1.0, result.Value, 1e-10);
}
[Fact]
public void Normalize_Update_MinValueReturnsZero()
{
var norm = new Normalize(5);
norm.Update(new TValue(DateTime.UtcNow, 50));
norm.Update(new TValue(DateTime.UtcNow, 40));
norm.Update(new TValue(DateTime.UtcNow, 30));
norm.Update(new TValue(DateTime.UtcNow, 20));
var result = norm.Update(new TValue(DateTime.UtcNow, 10));
// min=10, max=50, value=10: (10-10)/40 = 0.0
Assert.Equal(0.0, result.Value, 1e-10);
}
[Fact]
public void Normalize_Update_MidValueReturnsFifty()
{
var norm = new Normalize(5);
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 25));
norm.Update(new TValue(DateTime.UtcNow, 75));
var result = norm.Update(new TValue(DateTime.UtcNow, 50));
// min=0, max=100, value=50: (50-0)/100 = 0.5
Assert.Equal(0.5, result.Value, 1e-10);
}
[Fact]
public void Normalize_Update_FlatRange_ReturnsHalf()
{
var norm = new Normalize(5);
// All same values
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 100));
var result = norm.Update(new TValue(DateTime.UtcNow, 100));
// Flat range returns 0.5
Assert.Equal(0.5, result.Value, 1e-10);
}
[Fact]
public void Normalize_Update_IsNew_False_RollsBack()
{
var norm = new Normalize(5);
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 50));
var result1 = norm.Update(new TValue(DateTime.UtcNow, 25), isNew: true);
var result2 = norm.Update(new TValue(DateTime.UtcNow, 75), isNew: false);
// Both should use the same buffer state before the update
// The last isNew=false should overwrite the isNew=true result
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Normalize_Update_NaN_UsesLastValid()
{
var norm = new Normalize(5);
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
var valid = norm.Update(new TValue(DateTime.UtcNow, 50));
var nanResult = norm.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.Equal(valid.Value, nanResult.Value, 1e-10);
}
[Fact]
public void Normalize_Update_Infinity_UsesLastValid()
{
var norm = new Normalize(5);
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
var valid = norm.Update(new TValue(DateTime.UtcNow, 50));
var infResult = norm.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.Equal(valid.Value, infResult.Value, 1e-10);
}
[Fact]
public void Normalize_IsHot_BecomesTrue_AfterWarmup()
{
var norm = new Normalize(5);
for (int i = 0; i < 4; i++)
{
norm.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.False(norm.IsHot);
}
norm.Update(new TValue(DateTime.UtcNow, 40));
Assert.True(norm.IsHot);
}
[Fact]
public void Normalize_Reset_ClearsState()
{
var norm = new Normalize(5);
for (int i = 0; i < 10; i++)
norm.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.True(norm.IsHot);
norm.Reset();
Assert.False(norm.IsHot);
}
[Fact]
public void Normalize_OutputAlwaysInRange()
{
var norm = new Normalize(20);
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in series)
{
var result = norm.Update(new TValue(bar.Time, bar.Close));
Assert.True(result.Value >= 0.0 && result.Value <= 1.0,
$"Normalize output {result.Value} should be in [0, 1]");
}
}
[Fact]
public void Normalize_Chaining_WorksCorrectly()
{
var source = new TSeries();
var norm = new Normalize(source, 10);
for (int i = 0; i < 20; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), i * 5));
}
Assert.True(norm.IsHot);
// Last value is 95 (19*5), min in last 10 is 50 (10*5), max is 95
// (95 - 50) / (95 - 50) = 1.0
Assert.Equal(1.0, norm.Last.Value, 1e-10);
}
[Fact]
public void Normalize_StaticCalculate_TSeries_MatchesStreaming()
{
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var tseries = new TSeries();
foreach (var bar in series)
tseries.Add(new TValue(bar.Time, bar.Close), true);
// Static calculation
var staticResult = Normalize.Calculate(tseries, 14);
// Streaming calculation
var streamNorm = new Normalize(14);
var streamResult = new TSeries();
foreach (var bar in series)
streamResult.Add(streamNorm.Update(new TValue(bar.Time, bar.Close)), true);
// Compare last 50 values
for (int i = 50; i < 100; i++)
{
Assert.Equal(staticResult[i].Value, streamResult[i].Value, 1e-10);
}
}
[Fact]
public void Normalize_StaticCalculate_Span_MatchesStreaming()
{
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] values = series.Select(b => b.Close).ToArray();
double[] output = new double[values.Length];
// Span calculation
Normalize.Calculate(values, output, 14);
// Streaming calculation
var norm = new Normalize(14);
for (int i = 0; i < values.Length; i++)
{
var result = norm.Update(new TValue(DateTime.UtcNow, values[i]));
Assert.Equal(output[i], result.Value, 1e-10);
}
}
[Fact]
public void Normalize_StaticCalculate_Span_ValidatesParameters()
{
double[] source = { 1, 2, 3, 4, 5 };
double[] output = new double[5];
Assert.Throws<ArgumentException>(() => Normalize.Calculate(Array.Empty<double>(), output));
Assert.Throws<ArgumentException>(() => Normalize.Calculate(source, new double[3]));
Assert.Throws<ArgumentException>(() => Normalize.Calculate(source, output, 0));
}
[Fact]
public void Normalize_RollingWindow_DropsOldValues()
{
var norm = new Normalize(3);
// Feed: 0, 100, 50 -> range [0, 100]
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 50));
// Feed: 60, now window is [100, 50, 60] -> range [50, 100]
// 60 in range [50, 100]: (60-50)/50 = 0.2
var result = norm.Update(new TValue(DateTime.UtcNow, 60));
Assert.Equal(0.2, result.Value, 1e-10);
}
}
@@ -0,0 +1,303 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Normalize indicator.
/// Since Normalize is a basic mathematical transformation, validation focuses on
/// mathematical properties rather than external library comparison.
/// </summary>
public class NormalizeValidationTests
{
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
[Fact]
public void Normalize_OutputBounds_AlwaysZeroToOne()
{
// Test across multiple periods and data sets
int[] periods = { 5, 14, 50, 100 };
foreach (var period in periods)
{
var norm = new Normalize(period);
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in series)
{
var result = norm.Update(new TValue(bar.Time, bar.Close));
Assert.True(result.Value >= 0.0 && result.Value <= 1.0,
$"Period {period}: output {result.Value} not in [0,1]");
}
}
}
[Fact]
public void Normalize_MaxInWindow_ReturnsOne()
{
var norm = new Normalize(5);
// Create ascending sequence
double[] values = { 10, 20, 30, 40, 50 };
foreach (var v in values)
norm.Update(new TValue(DateTime.UtcNow, v));
// Max value (50) should normalize to 1.0
Assert.Equal(1.0, norm.Last.Value, 1e-10);
}
[Fact]
public void Normalize_MinInWindow_ReturnsZero()
{
var norm = new Normalize(5);
// Create descending sequence ending at min
double[] values = { 50, 40, 30, 20, 10 };
foreach (var v in values)
norm.Update(new TValue(DateTime.UtcNow, v));
// Min value (10) should normalize to 0.0
Assert.Equal(0.0, norm.Last.Value, 1e-10);
}
[Fact]
public void Normalize_LinearMapping_Correct()
{
var norm = new Normalize(5);
// Set up window with known range [0, 100]
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 50)); // Placeholder
norm.Update(new TValue(DateTime.UtcNow, 50)); // Placeholder
norm.Update(new TValue(DateTime.UtcNow, 50)); // Placeholder
// Test various values - (value - 0) / (100 - 0) = value / 100
double[] testValues = { 0, 25, 50, 75, 100 };
double[] expected = { 0.0, 0.25, 0.5, 0.75, 1.0 };
for (int i = 0; i < testValues.Length; i++)
{
// Reset and refill to maintain window [0, 100, test, test, test]
norm.Reset();
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, testValues[i]));
norm.Update(new TValue(DateTime.UtcNow, testValues[i]));
var result = norm.Update(new TValue(DateTime.UtcNow, testValues[i]));
Assert.Equal(expected[i], result.Value, 1e-10);
}
}
[Fact]
public void Normalize_ConstantInput_ReturnsHalf()
{
var norm = new Normalize(10);
// All same values
for (int i = 0; i < 20; i++)
norm.Update(new TValue(DateTime.UtcNow, 42.0));
// Flat range: should return 0.5
Assert.Equal(0.5, norm.Last.Value, 1e-10);
}
[Fact]
public void Normalize_RollingWindow_AdaptsToNewRange()
{
var norm = new Normalize(3);
// Initial window [10, 20, 30] - range 20
norm.Update(new TValue(DateTime.UtcNow, 10));
norm.Update(new TValue(DateTime.UtcNow, 20));
norm.Update(new TValue(DateTime.UtcNow, 30));
// Value 25 in range [10, 30]: (25-10)/(30-10) = 0.75
var result1 = norm.Update(new TValue(DateTime.UtcNow, 25));
// Window is now [20, 30, 25], range [20, 30]
// (25-20)/(30-20) = 0.5
Assert.Equal(0.5, result1.Value, 1e-10);
}
[Fact]
public void Normalize_NegativeValues_WorksCorrectly()
{
var norm = new Normalize(5);
// Range from -50 to +50
norm.Update(new TValue(DateTime.UtcNow, -50));
norm.Update(new TValue(DateTime.UtcNow, -25));
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 25));
norm.Update(new TValue(DateTime.UtcNow, 50));
// max=50, value=50: (50-(-50))/(50-(-50)) = 100/100 = 1.0
Assert.Equal(1.0, norm.Last.Value, 1e-10);
// Test zero: (0-(-50))/(50-(-50)) = 50/100 = 0.5
norm.Reset();
norm.Update(new TValue(DateTime.UtcNow, -50));
norm.Update(new TValue(DateTime.UtcNow, 50));
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 0));
var zeroResult = norm.Update(new TValue(DateTime.UtcNow, 0));
Assert.Equal(0.5, zeroResult.Value, 1e-10);
}
[Fact]
public void Normalize_SmallRange_HighPrecision()
{
var norm = new Normalize(5);
// Very small range
double baseVal = 100.0;
double epsilon = 1e-8;
norm.Update(new TValue(DateTime.UtcNow, baseVal));
norm.Update(new TValue(DateTime.UtcNow, baseVal + epsilon));
norm.Update(new TValue(DateTime.UtcNow, baseVal + epsilon / 2));
norm.Update(new TValue(DateTime.UtcNow, baseVal + epsilon / 4));
var result = norm.Update(new TValue(DateTime.UtcNow, baseVal + epsilon * 0.75));
// Should be in valid range
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
}
[Fact]
public void Normalize_LargeRange_StillPrecise()
{
var norm = new Normalize(5);
// Very large range
norm.Update(new TValue(DateTime.UtcNow, -1e10));
norm.Update(new TValue(DateTime.UtcNow, 1e10));
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 0));
var result = norm.Update(new TValue(DateTime.UtcNow, 0));
// 0 in range [-1e10, 1e10]: (0 - (-1e10)) / (2e10) = 0.5
Assert.Equal(0.5, result.Value, 1e-6);
}
[Fact]
public void Normalize_StreamingVsBatch_Match()
{
var series = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] values = series.Select(b => b.Close).ToArray();
// Streaming
var streamNorm = new Normalize(14);
var streamResults = new double[values.Length];
for (int i = 0; i < values.Length; i++)
{
streamResults[i] = streamNorm.Update(new TValue(DateTime.UtcNow, values[i])).Value;
}
// Batch
double[] batchResults = new double[values.Length];
Normalize.Calculate(values, batchResults, 14);
// Compare all values
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(batchResults[i], streamResults[i], 1e-10);
}
}
[Fact]
public void Normalize_AllModes_Consistent()
{
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int period = 14;
// Mode 1: Streaming via Update(TValue)
var norm1 = new Normalize(period);
var results1 = new List<double>();
foreach (var bar in series)
{
results1.Add(norm1.Update(new TValue(bar.Time, bar.Close)).Value);
}
// Mode 2: Batch via Update(TSeries)
var tseries = new TSeries();
foreach (var bar in series)
tseries.Add(new TValue(bar.Time, bar.Close), true);
var results2 = Normalize.Calculate(tseries, period);
// Mode 3: Static span Calculate
double[] values = series.Select(b => b.Close).ToArray();
double[] results3 = new double[values.Length];
Normalize.Calculate(values, results3, period);
// Mode 4: Event-based chaining
var source = new TSeries();
var norm4 = new Normalize(source, period);
foreach (var bar in series)
source.Add(new TValue(bar.Time, bar.Close), true);
var results4 = norm4.Last.Value;
// Compare all modes (use last 50 values for stability)
for (int i = 50; i < 100; i++)
{
Assert.Equal(results1[i], results2[i].Value, 1e-10);
Assert.Equal(results1[i], results3[i], 1e-10);
}
// Verify Mode 4 matches last value from other modes
Assert.Equal(results1[^1], results4, 1e-10);
}
[Fact]
public void Normalize_BarCorrection_WorksCorrectly()
{
var norm = new Normalize(5);
// Build up buffer
norm.Update(new TValue(DateTime.UtcNow, 0));
norm.Update(new TValue(DateTime.UtcNow, 100));
norm.Update(new TValue(DateTime.UtcNow, 50));
norm.Update(new TValue(DateTime.UtcNow, 50));
// New bar
var first = norm.Update(new TValue(DateTime.UtcNow, 75), isNew: true);
// Correction (same bar, different value)
var corrected = norm.Update(new TValue(DateTime.UtcNow, 25), isNew: false);
// Values should be different
Assert.NotEqual(first.Value, corrected.Value);
// Further correction should still work
var corrected2 = norm.Update(new TValue(DateTime.UtcNow, 50), isNew: false);
Assert.NotEqual(corrected.Value, corrected2.Value);
}
[Fact]
public void Normalize_Period1_ReturnsHalf()
{
var norm = new Normalize(1);
// With period 1, min = max = current value, so range = 0
var result = norm.Update(new TValue(DateTime.UtcNow, 42));
// Flat range returns 0.5
Assert.Equal(0.5, result.Value, 1e-10);
}
[Fact]
public void Normalize_VeryLargePeriod_StillWorks()
{
var norm = new Normalize(1000);
var series = _gbm.Fetch(1500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in series)
{
var result = norm.Update(new TValue(bar.Time, bar.Close));
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
}
Assert.True(norm.IsHot);
}
}
+212
View File
@@ -0,0 +1,212 @@
// NORMALIZE: Min-Max Normalization
// Scales values to [0, 1] range using min-max scaling over a lookback period
// Formula: (x - min) / (max - min)
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// NORMALIZE: Min-Max Normalization
/// Scales values to the range [0, 1] using min-max normalization over a lookback period.
/// </summary>
/// <remarks>
/// Key properties:
/// - Output always between 0 and 1 (inclusive when value equals min or max)
/// - Uses rolling window to track min and max
/// - Division by zero (flat range) returns 0.5 as neutral value
/// - Commonly used for feature scaling and bounded indicators
/// </remarks>
[SkipLocalsInit]
public sealed class Normalize : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidNorm, double Min, double Max);
private State _state, _p_state;
public override bool IsHot => _buffer.Count >= _period;
/// <param name="period">Lookback period for min/max calculation (default 14)</param>
public Normalize(int period = 14)
{
if (period < 1)
throw new ArgumentException("Period must be >= 1", nameof(period));
_period = period;
_buffer = new RingBuffer(period);
Name = $"Normalize({period})";
WarmupPeriod = period;
_state = new State(0.5, double.MaxValue, double.MinValue);
_p_state = _state;
}
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Lookback period (default 14)</param>
public Normalize(ITValuePublisher source, int period = 14) : this(period)
{
source.Pub += HandleUpdate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static (double min, double max) FindMinMax(ReadOnlySpan<double> values)
{
if (values.Length == 0)
return (double.MaxValue, double.MinValue);
double min = values[0];
double max = values[0];
for (int i = 1; i < values.Length; i++)
{
double v = values[i];
if (v < min) min = v;
if (v > max) max = v;
}
return (min, max);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
_p_state = _state;
else
_state = _p_state;
double value = input.Value;
double result;
if (double.IsFinite(value))
{
_buffer.Add(value, isNew);
// Find min and max in the buffer
var (min, max) = FindMinMax(_buffer.GetSpan());
double range = max - min;
if (range > 0)
{
result = (value - min) / range;
}
else
{
// Flat range: return 0.5 as neutral
result = 0.5;
}
_state = new State(result, min, max);
}
else
{
result = _state.LastValidNorm;
}
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 = 14)
{
var indicator = new Normalize(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates Min-Max Normalization over a span of values.
/// </summary>
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period = 14)
{
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));
double lastValid = 0.5;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
output[i] = lastValid;
continue;
}
// Determine window bounds
int start = Math.Max(0, i - period + 1);
// Find min/max in window - initialize to infinity to handle non-finite starting values
double min = double.PositiveInfinity;
double max = double.NegativeInfinity;
for (int j = start; j <= i; j++)
{
double v = source[j];
if (double.IsFinite(v))
{
if (v < min) min = v;
if (v > max) max = v;
}
}
// If no finite values found in window, use neutral output
if (!double.IsFinite(min) || !double.IsFinite(max))
{
output[i] = lastValid;
continue;
}
double range = max - min;
double result = range > 0 ? (val - min) / range : 0.5;
lastValid = result;
output[i] = result;
}
}
public override void Reset()
{
_buffer.Clear();
_state = new State(0.5, double.MaxValue, double.MinValue);
_p_state = _state;
Last = default;
}
}
+218
View File
@@ -0,0 +1,218 @@
# NORMALIZE: Min-Max Normalization
> "Normalization is the art of making apples and oranges comparable—by insisting that everything lives on the same scale from 0 to 1."
The Normalize transformer applies min-max scaling to map any value series into the bounded range [0, 1] based on the observed minimum and maximum within a rolling lookback window. This technique is fundamental for feature scaling, creating bounded oscillators, and comparing series with different magnitudes.
## Mathematical Foundation
### Core Formula
$$
\text{Norm}_t = \frac{x_t - \min_{[t-n+1, t]}}{\max_{[t-n+1, t]} - \min_{[t-n+1, t]}}
$$
where:
- $x_t$ is the input value at time $t$
- $n$ is the lookback period
- $\min_{[t-n+1, t]}$ is the minimum value in the window
- $\max_{[t-n+1, t]}$ is the maximum value in the window
### Edge Case: Flat Range
When $\max = \min$ (all values identical):
$$
\text{Norm}_t = 0.5
$$
This neutral value is returned since the "position" within a zero-width range is undefined.
### Key Properties
| Property | Value | Description |
|:---------|:------|:------------|
| **Range** | $[0, 1]$ | Guaranteed bounded output |
| **Min maps to** | 0 | Lowest value in window → 0 |
| **Max maps to** | 1 | Highest value in window → 1 |
| **Linear** | Yes | Preserves relative distances within window |
| **Invertible** | Yes* | If you know min/max |
*Given the min and max used, original value = Norm × (max - min) + min
## Financial Applications
### Oscillator Construction
Convert any price-based measure to oscillator form:
$$
\text{NormalizedRSI} = \text{Normalize}(\text{RSI}, 100)
$$
### Cross-Asset Comparison
Compare instruments with different price scales:
$$
\text{RelativeStrength} = \text{Normalize}(\text{Price}_A, n) - \text{Normalize}(\text{Price}_B, n)
$$
### Machine Learning Features
Prepare inputs for models requiring bounded features:
$$
\text{Feature}_i = \text{Normalize}(x_i, \text{lookback})
$$
### Dynamic Range Detection
Identify where price sits within recent range:
$$
\text{Position} = \text{Normalize}(\text{Close}, 20)
$$
Values near 1.0 indicate price at recent highs; near 0.0 at recent lows.
## Parameter Guide
### Period Selection
| Period | Behavior | Use Case |
|:-------|:---------|:---------|
| 5-10 | Highly responsive | Short-term oscillators |
| 14-20 | Standard | General normalization |
| 50-100 | Smooth | Position within broader context |
| 200+ | Very stable | Long-term percentile-like behavior |
### Period Effects
- **Shorter periods**: More volatile output, quicker adaptation to new ranges
- **Longer periods**: Smoother output, but slower to adapt; may stay near extremes longer
## Implementation Details
### Rolling Window Approach
The implementation maintains a ring buffer of size $n$ and recalculates min/max on each update. This provides O(n) complexity per update but ensures correctness with the rolling window semantics.
### Streaming Characteristics
| Metric | Value |
|:-------|:------|
| **Warmup Period** | $n$ (period) |
| **Memory** | O(n) for ring buffer |
| **Complexity** | O(n) per update |
### Precision Considerations
| Scenario | Handling |
|:---------|:---------|
| **Zero range** | Returns 0.5 |
| **Very small range** | Full precision maintained |
| **NaN/Infinity input** | Last valid value substituted |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
|:----------|:-----:|:------|
| Buffer add | 1 | O(1) ring buffer |
| Min scan | n | Linear scan of window |
| Max scan | n | Combined with min scan |
| SUB | 2 | value - min, max - min |
| DIV | 1 | Final division |
| **Total** | O(n) | Dominated by min/max scan |
### Quality Metrics
| Metric | Score | Notes |
|:-------|:-----:|:------|
| **Accuracy** | 10/10 | Exact min-max scaling |
| **Boundedness** | 10/10 | Guaranteed [0, 1] output |
| **Adaptability** | 8/10 | Adapts to rolling window |
| **Timeliness** | 7/10 | Requires warmup period |
## Usage Examples
### Basic Usage
```csharp
// Create Normalize with 14-period lookback
var norm = new Normalize(14);
// Feed price data
var price = new TValue(DateTime.UtcNow, 105.0);
var normalized = norm.Update(price); // Value in [0, 1]
```
### Creating Oscillator from Any Series
```csharp
var rsi = new Rsi(14);
var normRsi = new Normalize(rsi, 100); // Chain: RSI → Normalize
// RSI output (0-100) gets normalized to [0, 1] over 100 periods
foreach (var bar in data)
{
rsi.Update(new TValue(bar.Time, bar.Close));
// normRsi automatically updates via event
}
```
### Comparing Multiple Assets
```csharp
var normA = new Normalize(50);
var normB = new Normalize(50);
// Compare where each asset sits in its own range
var posA = normA.Update(new TValue(now, priceA));
var posB = normB.Update(new TValue(now, priceB));
var relativeStrength = posA.Value - posB.Value; // [-1, 1]
```
### Span API for Batch Processing
```csharp
double[] prices = GetHistoricalPrices();
double[] normalized = new double[prices.Length];
Normalize.Calculate(prices, normalized, period: 20);
```
## Common Pitfalls
1. **Lookback Dependency**: Output depends heavily on what's in the lookback window. Unusual spikes or crashes in the window can distort normalization for the entire period duration.
2. **Not Truly Bounded During Warmup**: Before the warmup period completes, the window is partial, which may produce less meaningful normalization.
3. **Flat Market Handling**: When a series has no variation over the period, output becomes 0.5. This may need special handling if your strategy interprets 0.5 differently.
4. **Window Lag**: When price breaks out of a long-established range, the old min/max remains in the window until it ages out, causing the normalized value to stay pinned at 0 or 1.
5. **Memory Requirements**: Each instance requires O(period) memory for the ring buffer. For many indicators with long periods, this can add up.
6. **Non-Stationarity**: Min-max normalization assumes the range is representative. In trending markets, the normalization may consistently return values near 0 or 1.
## Validation
| Test | Status |
|:-----|:------:|
| **Output in [0, 1]** | ✅ |
| **Max value → 1** | ✅ |
| **Min value → 0** | ✅ |
| **Flat range → 0.5** | ✅ |
| **Linear mapping** | ✅ |
| **Rolling window correctness** | ✅ |
| **Streaming = Batch** | ✅ |
## References
- Aksoy, S., & Haralick, R. M. (2001). "Feature normalization and likelihood-based similarity measures for image retrieval." *Pattern Recognition Letters*.
- Patro, S., & Sahu, K. K. (2015). "Normalization: A preprocessing stage." *IARJSET*.
- Géron, A. (2019). *Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow*. O'Reilly Media.
+38
View File
@@ -0,0 +1,38 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Min-Max Normalization (NORMALIZE)", "NORMALIZE", overlay=false, precision=6)
//@function Normalizes a source series to the fixed range [0, 1] using Min-Max scaling over a lookback period.
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/numerics/normalize.md
//@param src The source series to normalize.
//@param len The lookback period to determine min and max values. Must be >= 1.
//@returns The normalized series (scaled to [0, 1]).
normalize(series float src, simple int len) =>
float min_val_in_period = src
float max_val_in_period = src
for i = 1 to len - 1
current_val = src[i]
if na(current_val)
continue
if current_val < min_val_in_period
min_val_in_period := current_val
if current_val > max_val_in_period
max_val_in_period := current_val
range_val = max_val_in_period - min_val_in_period
normalized_value = 0.0
if range_val != 0.0 and not na(range_val)
normalized_value := (src - min_val_in_period) / range_val
normalized_value
// ---------- Main loop ----------
// Inputs
i_source = input.source(close, "Source")
i_length = input.int(200, "Lookback Length", minval=1, tooltip="Lookback period for finding min/max. Must be >= 1.")
// Calculation
normalizedValue = normalize(i_source, i_length)
// Plot
plot(normalizedValue, "Normalized Value [0,1]", color=color.yellow, linewidth=2)