mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-17 18:18: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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user