mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 18:48:05 +00:00
Refactor EMA, SMA, and WMA indicators to improve warmup tracking and coverage calculations; enhance tests for IsHot behavior and period dependency; update project files for better structure and maintainability.
This commit is contained in:
@@ -17,6 +17,7 @@ public class EmaIndicator : Indicator, IWatchlistIndicator
|
||||
private Ema? ma;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
private int _warmupBarIndex = -1;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
@@ -38,6 +39,7 @@ public class EmaIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
ma = new Ema(Period);
|
||||
SourceName = Source.ToString();
|
||||
_warmupBarIndex = -1; // Reset warmup tracking when period changes
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
@@ -48,11 +50,16 @@ public class EmaIndicator : Indicator, IWatchlistIndicator
|
||||
TValue result = ma!.Update(input, isNew);
|
||||
Series!.SetValue(result.Value);
|
||||
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
|
||||
|
||||
// Track when IsHot becomes true for the first time
|
||||
if (_warmupBarIndex < 0 && ma!.IsHot)
|
||||
_warmupBarIndex = Count;
|
||||
}
|
||||
|
||||
public override void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
base.OnPaintChart(args);
|
||||
this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2);
|
||||
int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count;
|
||||
this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,22 +100,17 @@ public class EmaTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ema_IsHot_BecomesTrueAfterWarmup()
|
||||
public void Ema_IsHot_BecomesTrueAt95PercentCoverage()
|
||||
{
|
||||
var ema = new Ema(10);
|
||||
|
||||
// Initially IsHot should be false
|
||||
Assert.False(ema.IsHot);
|
||||
|
||||
// Feed values until it warms up
|
||||
// Warmup condition is state.E <= 1e-10
|
||||
// state.E starts at 1.0 and decays by (1 - alpha) each step
|
||||
// alpha = 2 / (10 + 1) = 2/11 ~= 0.1818
|
||||
// (1 - alpha) ~= 0.8181
|
||||
// 1.0 * (0.8181)^n <= 1e-10
|
||||
// n * log(0.8181) <= log(1e-10)
|
||||
// n * -0.200 <= -23.02
|
||||
// n >= 115 steps roughly
|
||||
// IsHot triggers at 95% coverage (E <= 0.05)
|
||||
// E = (1 - alpha)^N where alpha = 2 / (period + 1)
|
||||
// For period 10: alpha = 2/11 ≈ 0.1818, (1-alpha) ≈ 0.8182
|
||||
// N = ln(0.05) / ln(0.8182) ≈ 14.93, so ~15 bars
|
||||
|
||||
int steps = 0;
|
||||
while (!ema.IsHot && steps < 1000)
|
||||
@@ -125,7 +120,46 @@ public class EmaTests
|
||||
}
|
||||
|
||||
Assert.True(ema.IsHot);
|
||||
Assert.True(steps > 0); // Should take some steps
|
||||
Assert.True(steps > 0);
|
||||
// For period 10, should become hot around 15 bars
|
||||
Assert.InRange(steps, 14, 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ema_IsHot_IsPeriodDependent()
|
||||
{
|
||||
// Test that different periods result in different warmup times
|
||||
// Formula: N = ln(0.05) / ln((p-1)/(p+1))
|
||||
|
||||
int[] periods = [10, 20, 50, 100];
|
||||
int[] expectedSteps = new int[periods.Length];
|
||||
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
int period = periods[i];
|
||||
var ema = new Ema(period);
|
||||
|
||||
int steps = 0;
|
||||
while (!ema.IsHot && steps < 500)
|
||||
{
|
||||
ema.Update(new TValue(DateTime.UtcNow, 100));
|
||||
steps++;
|
||||
}
|
||||
|
||||
expectedSteps[i] = steps;
|
||||
}
|
||||
|
||||
// Verify warmup times increase with period
|
||||
// Period 10 → ~15 bars, Period 20 → ~30 bars, Period 50 → ~75 bars, Period 100 → ~150 bars
|
||||
Assert.True(expectedSteps[0] < expectedSteps[1], $"Period 10 ({expectedSteps[0]}) should be less than Period 20 ({expectedSteps[1]})");
|
||||
Assert.True(expectedSteps[1] < expectedSteps[2], $"Period 20 ({expectedSteps[1]}) should be less than Period 50 ({expectedSteps[2]})");
|
||||
Assert.True(expectedSteps[2] < expectedSteps[3], $"Period 50 ({expectedSteps[2]}) should be less than Period 100 ({expectedSteps[3]})");
|
||||
|
||||
// Verify approximate expected values (N ≈ 1.5 * period for 95% coverage)
|
||||
Assert.InRange(expectedSteps[0], 14, 17); // Period 10 → ~15
|
||||
Assert.InRange(expectedSteps[1], 28, 32); // Period 20 → ~30
|
||||
Assert.InRange(expectedSteps[2], 73, 78); // Period 50 → ~75
|
||||
Assert.InRange(expectedSteps[3], 147, 153); // Period 100 → ~150
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+32
-10
@@ -28,19 +28,20 @@ public class Ema
|
||||
private struct State : IEquatable<State>
|
||||
{
|
||||
public double Ema;
|
||||
public double E;
|
||||
public bool IsHot;
|
||||
public double E; // Compensator: decays from 1.0 to 1e-10 for bias correction
|
||||
public bool IsHot; // True when 95% coverage reached (E <= 0.05)
|
||||
public bool IsCompensated; // True when compensator fully decayed (E <= 1e-10)
|
||||
|
||||
public static State New() => new() { Ema = 0, E = 1.0, IsHot = false };
|
||||
public static State New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false };
|
||||
|
||||
public readonly bool Equals(State other) =>
|
||||
Ema == other.Ema && E == other.E && IsHot == other.IsHot;
|
||||
Ema == other.Ema && E == other.E && IsHot == other.IsHot && IsCompensated == other.IsCompensated;
|
||||
|
||||
public override readonly bool Equals(object? obj) =>
|
||||
obj is State other && Equals(other);
|
||||
|
||||
public override readonly int GetHashCode() =>
|
||||
HashCode.Combine(Ema, E, IsHot);
|
||||
HashCode.Combine(Ema, E, IsHot, IsCompensated);
|
||||
|
||||
public static bool operator ==(State left, State right) => left.Equals(right);
|
||||
public static bool operator !=(State left, State right) => !left.Equals(right);
|
||||
@@ -107,9 +108,16 @@ public class Ema
|
||||
return _lastValidValue;
|
||||
}
|
||||
|
||||
// 95% coverage threshold: E = 1 - 0.95 = 0.05
|
||||
private const double COVERAGE_THRESHOLD = 0.05;
|
||||
// Compensator decay threshold for bias correction
|
||||
private const double COMPENSATOR_THRESHOLD = 1e-10;
|
||||
|
||||
/// <summary>
|
||||
/// Core EMA calculation kernel.
|
||||
/// Assumes input has already been validated via GetValidValue().
|
||||
/// IsHot becomes true at 95% coverage (E <= 0.05).
|
||||
/// Bias correction continues until compensator decays to 1e-10.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double Compute(double input, double alpha, ref State state)
|
||||
@@ -117,11 +125,24 @@ public class Ema
|
||||
state.Ema += alpha * (input - state.Ema);
|
||||
|
||||
double result;
|
||||
if (!state.IsHot)
|
||||
if (!state.IsCompensated)
|
||||
{
|
||||
state.E *= (1.0 - alpha);
|
||||
state.IsHot = state.E <= 1e-10;
|
||||
result = state.Ema / (1.0 - state.E);
|
||||
|
||||
// IsHot triggers at 95% coverage
|
||||
if (!state.IsHot && state.E <= COVERAGE_THRESHOLD)
|
||||
state.IsHot = true;
|
||||
|
||||
// Continue bias correction until compensator fully decays
|
||||
if (state.E <= COMPENSATOR_THRESHOLD)
|
||||
{
|
||||
state.IsCompensated = true;
|
||||
result = state.Ema;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = state.Ema / (1.0 - state.E);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -227,6 +248,7 @@ public class Ema
|
||||
/// <summary>
|
||||
/// Calculates EMA in-place using alpha, writing results to pre-allocated output span.
|
||||
/// Zero-allocation method for maximum performance.
|
||||
/// Bias correction continues until compensator decays to 1e-10.
|
||||
/// </summary>
|
||||
/// <param name="source">Input values</param>
|
||||
/// <param name="output">Output span (must be same length as source)</param>
|
||||
@@ -256,8 +278,8 @@ public class Ema
|
||||
ema += alpha * (val - ema);
|
||||
e *= oneMinusAlpha;
|
||||
|
||||
// Bias correction until warmed up
|
||||
output[i] = e > 1e-10 ? ema / (1.0 - e) : ema;
|
||||
// Bias correction until compensator fully decays
|
||||
output[i] = e > COMPENSATOR_THRESHOLD ? ema / (1.0 - e) : ema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ public class SmaIndicator : Indicator, IWatchlistIndicator
|
||||
private Sma? ma;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
private int _warmupBarIndex = -1;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
@@ -39,6 +40,7 @@ public class SmaIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
ma = new Sma(Period);
|
||||
SourceName = Source.ToString();
|
||||
_warmupBarIndex = -1; // Reset warmup tracking when period changes
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
@@ -49,11 +51,16 @@ public class SmaIndicator : Indicator, IWatchlistIndicator
|
||||
TValue result = ma!.Update(input, isNew);
|
||||
Series!.SetValue(result.Value);
|
||||
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
|
||||
|
||||
// Track when IsHot becomes true for the first time
|
||||
if (_warmupBarIndex < 0 && ma!.IsHot)
|
||||
_warmupBarIndex = Count;
|
||||
}
|
||||
|
||||
public override void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
base.OnPaintChart(args);
|
||||
this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2);
|
||||
int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count;
|
||||
this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public class WmaIndicator : Indicator, IWatchlistIndicator
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Wma? ma;
|
||||
private int _warmupBarIndex = -1;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
|
||||
@@ -38,6 +39,7 @@ public class WmaIndicator : Indicator, IWatchlistIndicator
|
||||
protected override void OnInit()
|
||||
{
|
||||
ma = new Wma(Period);
|
||||
_warmupBarIndex = -1;
|
||||
SourceName = Source.ToString();
|
||||
base.OnInit();
|
||||
}
|
||||
@@ -47,6 +49,8 @@ public class WmaIndicator : Indicator, IWatchlistIndicator
|
||||
TValue input = this.GetInputValue(args, Source);
|
||||
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
|
||||
TValue result = ma!.Update(input, isNew);
|
||||
if (_warmupBarIndex < 0 && ma!.IsHot)
|
||||
_warmupBarIndex = Count;
|
||||
Series!.SetValue(result.Value);
|
||||
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
|
||||
}
|
||||
@@ -54,6 +58,6 @@ public class WmaIndicator : Indicator, IWatchlistIndicator
|
||||
public override void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
base.OnPaintChart(args);
|
||||
this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2);
|
||||
this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace QuanTAlib;
|
||||
/// Stateless design - only maintains minimal state needed for price continuity.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
#pragma warning disable S101 // Types should be named in PascalCase - GBM is a standard acronym
|
||||
// Types should be named in PascalCase - GBM is a standard acronym
|
||||
public class GBM : IFeed
|
||||
#pragma warning restore S101
|
||||
{
|
||||
@@ -190,9 +190,11 @@ public class GBM : IFeed
|
||||
double open = currentPrice;
|
||||
double close = price;
|
||||
|
||||
#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes
|
||||
double rnd1 = _rnd.NextDouble();
|
||||
double rnd2 = _rnd.NextDouble();
|
||||
double rnd3 = _rnd.NextDouble();
|
||||
#pragma warning restore S2245
|
||||
|
||||
t[i] = currentTime;
|
||||
o[i] = open;
|
||||
|
||||
Reference in New Issue
Block a user