mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 18:48:05 +00:00
adding missing validations
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class EthermIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EthermIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
|
||||
Assert.Equal(22, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("ETHERM - Elder's Thermometer", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new EthermIndicator { Period = 14 };
|
||||
Assert.Equal("ETHERM 14", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
|
||||
Assert.Equal(0, EthermIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_Initialize_CreatesInternalEtherm()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (2: temperature + signal)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new EthermIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Temperature line series should have a value
|
||||
double tempVal = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(tempVal));
|
||||
|
||||
// Signal line series should have a value
|
||||
double sigVal = indicator.LinesSeries[1].GetValue(0);
|
||||
Assert.True(double.IsFinite(sigVal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new EthermIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 128, 115, 125, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
int[] periods = { 5, 10, 14, 22, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new EthermIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double tempVal = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(tempVal), $"Period {period} should produce finite temperature");
|
||||
|
||||
double sigVal = indicator.LinesSeries[1].GetValue(0);
|
||||
Assert.True(double.IsFinite(sigVal), $"Period {period} should produce finite signal");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
Assert.Equal(22, indicator.Period);
|
||||
|
||||
indicator.Period = 14;
|
||||
Assert.Equal(14, indicator.Period);
|
||||
|
||||
indicator.Period = 5;
|
||||
Assert.Equal(5, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Etherm.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthermIndicator_HasTwoLineSeries_WithCorrectNames()
|
||||
{
|
||||
var indicator = new EthermIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
Assert.Equal("Temperature", indicator.LinesSeries[0].Name);
|
||||
Assert.Equal("Signal", indicator.LinesSeries[1].Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class EthermIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
|
||||
public int Period { get; set; } = 22;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Etherm _etherm = null!;
|
||||
private readonly LineSeries _tempSeries;
|
||||
private readonly LineSeries _signalSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"ETHERM {Period}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/etherm/Etherm.Quantower.cs";
|
||||
|
||||
public EthermIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "ETHERM - Elder's Thermometer";
|
||||
Description = "Measures bar-to-bar range extension to quantify market volatility";
|
||||
|
||||
_tempSeries = new LineSeries(name: "Temperature", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Histogramm);
|
||||
_signalSeries = new LineSeries(name: "Signal", color: Color.Yellow, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_tempSeries);
|
||||
AddLineSeries(_signalSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_etherm = new Etherm(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TBar bar = this.GetInputBar(args);
|
||||
TValue result = _etherm.Update(bar, args.IsNewBar());
|
||||
|
||||
_tempSeries.SetValue(result.Value, _etherm.IsHot, ShowColdValues);
|
||||
_signalSeries.SetValue(_etherm.Signal, _etherm.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class EthermTests
|
||||
{
|
||||
// ============== A) Constructor & Parameter Validation ==============
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Etherm(0));
|
||||
Assert.Throws<ArgumentException>(() => new Etherm(-1));
|
||||
Assert.Throws<ArgumentException>(() => new Etherm(-100));
|
||||
|
||||
var etherm = new Etherm(22);
|
||||
Assert.NotNull(etherm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultPeriod_Is22()
|
||||
{
|
||||
var etherm = new Etherm();
|
||||
Assert.Contains("22", etherm.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Period1_Works()
|
||||
{
|
||||
var etherm = new Etherm(1);
|
||||
Assert.NotNull(etherm);
|
||||
Assert.Contains("1", etherm.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ============== B) Basic Calculation ==============
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(etherm.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
|
||||
Assert.Equal(0, etherm.Last.Value);
|
||||
|
||||
TValue result = etherm.Update(bar);
|
||||
|
||||
// First bar temperature = 0 (no previous bar)
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
Assert.Equal(result.Value, etherm.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstValue_ReturnsZero()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
|
||||
TValue result = etherm.Update(bar);
|
||||
|
||||
// First bar: no previous bar to compare, temperature = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondBar_ReturnsRangeExtension()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: H=115, L=85 → highDiff=|115-110|=5, lowDiff=|90-85|=5
|
||||
// NOT inside bar (115 > 110), temp = max(5, 5) = 5
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 100, 115, 85, 105, 1000);
|
||||
TValue result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(5.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsideBar_ReturnsZero()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=110, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: H=105, L=95 → inside bar (105 < 110 AND 95 > 90) → temp = 0
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 100, 105, 95, 102, 1000);
|
||||
TValue result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
Assert.Equal(0, etherm.Last.Value);
|
||||
Assert.False(etherm.IsHot);
|
||||
Assert.Contains("Etherm", etherm.Name, StringComparison.Ordinal);
|
||||
Assert.True(etherm.WarmupPeriod > 0);
|
||||
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
etherm.Update(bar);
|
||||
|
||||
// After first bar, signal EMA should have a value
|
||||
Assert.True(double.IsFinite(etherm.Signal));
|
||||
}
|
||||
|
||||
// ============== C) State Management & Bar Correction ==============
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
etherm.Update(bar1, isNew: true);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 100, 108, 1000);
|
||||
etherm.Update(bar2, isNew: true);
|
||||
|
||||
// Second bar should produce a range extension value
|
||||
Assert.True(etherm.Last.Value >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
etherm.Update(bar1, isNew: true);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 85, 108, 1000);
|
||||
etherm.Update(bar2, isNew: true);
|
||||
double beforeUpdate = etherm.Last.Value;
|
||||
|
||||
// Modify bar2 with wider range
|
||||
var bar2Modified = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 130, 70, 108, 1000);
|
||||
etherm.Update(bar2Modified, isNew: false);
|
||||
double afterUpdate = etherm.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeUpdate, afterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_Consistency()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed first 99
|
||||
for (int i = 0; i < 99; i++)
|
||||
{
|
||||
etherm.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Update with 100th bar (isNew=true)
|
||||
etherm.Update(bars[99], true);
|
||||
|
||||
// Update with modified 100th bar (isNew=false)
|
||||
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 10.0, bars[99].Low - 10.0, bars[99].Close, bars[99].Volume);
|
||||
double val2 = etherm.Update(modifiedBar, false).Value;
|
||||
|
||||
// Create new instance and feed up to modified
|
||||
var etherm2 = new Etherm(22);
|
||||
for (int i = 0; i < 99; i++)
|
||||
{
|
||||
etherm2.Update(bars[i]);
|
||||
}
|
||||
double val3 = etherm2.Update(modifiedBar, true).Value;
|
||||
|
||||
Assert.Equal(val3, val2, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed 10 new values
|
||||
TBar tenthBar = default;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
tenthBar = bars[i];
|
||||
etherm.Update(tenthBar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 10 values
|
||||
double stateAfterTen = etherm.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 10; i < 19; i++)
|
||||
{
|
||||
etherm.Update(bars[i], isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 10th bar again with isNew=false
|
||||
TValue finalResult = etherm.Update(tenthBar, isNew: false);
|
||||
|
||||
// State should match the original state after 10 values
|
||||
Assert.Equal(stateAfterTen, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Works()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
Assert.True(etherm.Signal != 0 || etherm.Last.Value >= 0);
|
||||
|
||||
etherm.Reset();
|
||||
Assert.Equal(0, etherm.Last.Value);
|
||||
Assert.False(etherm.IsHot);
|
||||
Assert.Equal(0, etherm.Signal);
|
||||
|
||||
// After reset, should accept new values
|
||||
etherm.Update(bars[0]);
|
||||
Assert.True(double.IsFinite(etherm.Last.Value));
|
||||
}
|
||||
|
||||
// ============== D) Warmup & Convergence ==============
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
|
||||
Assert.False(etherm.IsHot);
|
||||
|
||||
int steps = 0;
|
||||
var baseTime = DateTime.UtcNow;
|
||||
while (!etherm.IsHot && steps < 100)
|
||||
{
|
||||
var bar = new TBar(baseTime.AddMinutes(steps), 100 + steps, 110 + steps, 90 + steps, 100 + steps, 1000);
|
||||
etherm.Update(bar);
|
||||
steps++;
|
||||
}
|
||||
|
||||
Assert.True(etherm.IsHot);
|
||||
Assert.True(steps > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsPositive()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
Assert.True(etherm.WarmupPeriod > 0);
|
||||
|
||||
var etherm2 = new Etherm(50);
|
||||
Assert.True(etherm2.WarmupPeriod > 0);
|
||||
}
|
||||
|
||||
// ============== E) NaN/Infinity Handling ==============
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 98, 108, 1000);
|
||||
etherm.Update(bar2);
|
||||
|
||||
// Feed bar with NaN high
|
||||
var barWithNaN = new TBar(DateTime.UtcNow.AddMinutes(2), double.NaN, double.NaN, 100, 112, 1000);
|
||||
var resultAfterNaN = etherm.Update(barWithNaN);
|
||||
|
||||
Assert.True(double.IsFinite(resultAfterNaN.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 98, 108, 1000);
|
||||
etherm.Update(bar2);
|
||||
|
||||
// Feed bar with Infinity
|
||||
var barWithInf = new TBar(DateTime.UtcNow.AddMinutes(2), 108, double.PositiveInfinity, 100, 112, 1000);
|
||||
var resultAfterInf = etherm.Update(barWithInf);
|
||||
|
||||
Assert.True(double.IsFinite(resultAfterInf.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_Safe()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed some normal bars first
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
etherm.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Feed several NaN bars
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(100 + i), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
var result = etherm.Update(nanBar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
// Resume normal bars
|
||||
for (int i = 10; i < 20; i++)
|
||||
{
|
||||
var result = etherm.Update(bars[i]);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// ============== F) Consistency Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var ethermIterative = new Etherm(14);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Calculate iteratively
|
||||
var iterativeResults = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
iterativeResults.Add(ethermIterative.Update(bar));
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = Etherm.Batch(bars, 14);
|
||||
|
||||
// Compare
|
||||
Assert.Equal(iterativeResults.Count, batchResults.Count);
|
||||
for (int i = 0; i < iterativeResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Update_MatchesStreaming()
|
||||
{
|
||||
var etherm1 = new Etherm(14);
|
||||
var etherm2 = new Etherm(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm1.Update(bar);
|
||||
}
|
||||
|
||||
// Batch
|
||||
etherm2.Update(bars);
|
||||
|
||||
Assert.Equal(etherm1.Last.Value, etherm2.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesStreaming()
|
||||
{
|
||||
var etherm = new Etherm(14);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streamResults = new double[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
streamResults[i] = etherm.Update(bars[i]).Value;
|
||||
}
|
||||
|
||||
// Span batch
|
||||
double[] highs = new double[100];
|
||||
double[] lows = new double[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
highs[i] = bars[i].High;
|
||||
lows[i] = bars[i].Low;
|
||||
}
|
||||
|
||||
double[] spanResults = new double[100];
|
||||
Etherm.Batch(highs, lows, spanResults, 14);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanResults[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventBased_MatchesStreaming()
|
||||
{
|
||||
var etherm1 = new Etherm(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Collect event-based results
|
||||
var eventResults = new List<double>();
|
||||
etherm1.Pub += (object? _, in TValueEventArgs e) => eventResults.Add(e.Value.Value);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm1.Update(bar);
|
||||
}
|
||||
|
||||
// Collect streaming results
|
||||
var etherm2 = new Etherm(14);
|
||||
var streamResults = new List<double>();
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
streamResults.Add(etherm2.Update(bar).Value);
|
||||
}
|
||||
|
||||
Assert.Equal(streamResults.Count, eventResults.Count);
|
||||
for (int i = 0; i < streamResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], eventResults[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== G) Span API Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesLengths()
|
||||
{
|
||||
double[] high = new double[10];
|
||||
double[] low = new double[5]; // mismatched
|
||||
double[] output = new double[10];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Etherm.Batch(high, low, output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesOutputLength()
|
||||
{
|
||||
double[] high = new double[10];
|
||||
double[] low = new double[10];
|
||||
double[] output = new double[5]; // too small
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Etherm.Batch(high, low, output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesPeriod()
|
||||
{
|
||||
double[] high = new double[10];
|
||||
double[] low = new double[10];
|
||||
double[] output = new double[10];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Etherm.Batch(high, low, output, period: 0));
|
||||
Assert.Throws<ArgumentException>(() => Etherm.Batch(high, low, output, period: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_EmptyInput_NoOp()
|
||||
{
|
||||
double[] high = Array.Empty<double>();
|
||||
double[] low = Array.Empty<double>();
|
||||
double[] output = Array.Empty<double>();
|
||||
|
||||
// Should not throw
|
||||
var ex = Record.Exception(() => Etherm.Batch(high, low, output));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_NaN_HandledGracefully()
|
||||
{
|
||||
double[] high = { 100, 110, double.NaN, 115, 120 };
|
||||
double[] low = { 90, 85, double.NaN, 88, 92 };
|
||||
double[] output = new double[5];
|
||||
|
||||
Etherm.Batch(high, low, output);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]), $"Output[{i}] should be finite but was {output[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
// ============== H) Chainability ==============
|
||||
|
||||
[Fact]
|
||||
public void Chainability_Works()
|
||||
{
|
||||
var etherm = new Etherm(14);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var result = etherm.Update(bars);
|
||||
Assert.Equal(50, result.Count);
|
||||
Assert.Equal(etherm.Last.Value, result.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_Fires()
|
||||
{
|
||||
var etherm = new Etherm(14);
|
||||
int eventCount = 0;
|
||||
etherm.Pub += (object? _, in TValueEventArgs _) => eventCount++;
|
||||
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
Assert.Equal(10, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_ViaConstructor_Works()
|
||||
{
|
||||
// Create a source indicator (e.g., TR)
|
||||
var tr = new Tr();
|
||||
// Subscribe Etherm to TR's events (TValue-based chain)
|
||||
var etherm = new Etherm(tr, 14);
|
||||
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// When TR updates, the chained etherm should also update
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tr.Update(bar);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(etherm.Last.Value));
|
||||
}
|
||||
|
||||
// ============== ETHERM-Specific Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void Signal_IsEmaOfTemperature()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.5);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
// Signal should be finite and non-negative after warmup
|
||||
Assert.True(double.IsFinite(etherm.Signal));
|
||||
Assert.True(etherm.Signal >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlatBars_ZeroTemperature()
|
||||
{
|
||||
var etherm = new Etherm(5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
// Flat bars: no range extension, temperature = 0
|
||||
Assert.Equal(0.0, etherm.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighDiff_DominatesWhenLarger()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=100, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 95, 100, 90, 95, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: H=120, L=89 → highDiff=|120-100|=20, lowDiff=|90-89|=1
|
||||
// Not inside bar (120 > 100), temp = max(20, 1) = 20
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 120, 89, 110, 1000);
|
||||
TValue result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(20.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowDiff_DominatesWhenLarger()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=100, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 95, 100, 90, 95, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: H=101, L=70 → highDiff=|101-100|=1, lowDiff=|90-70|=20
|
||||
// Not inside bar (101 > 100), temp = max(1, 20) = 20
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 85, 101, 70, 80, 1000);
|
||||
TValue result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(20.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_Works()
|
||||
{
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var results = Etherm.Batch(bars, 14);
|
||||
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(double.IsFinite(results.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var (results, indicator) = Etherm.Calculate(bars, 14);
|
||||
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.NotNull(indicator);
|
||||
Assert.True(double.IsFinite(indicator.Last.Value));
|
||||
Assert.True(double.IsFinite(indicator.Signal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleBar_ReturnsZero()
|
||||
{
|
||||
var etherm = new Etherm(14);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
|
||||
var result = etherm.Update(bar);
|
||||
|
||||
// First bar temperature = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ETHERM Validation Tests — Self-consistency validation.
|
||||
/// Elder's Thermometer is not widely available in TA-Lib, Skender, Tulip, or Ooples,
|
||||
/// so validation focuses on internal consistency and mathematical correctness.
|
||||
/// </summary>
|
||||
public sealed class EthermValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private bool _disposed;
|
||||
|
||||
public EthermValidationTests()
|
||||
{
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Self-Consistency ==============
|
||||
|
||||
[Fact]
|
||||
public void Validation_BatchMatchesStreaming()
|
||||
{
|
||||
int[] periods = { 5, 14, 22, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Streaming
|
||||
var ethermStream = new Etherm(period);
|
||||
var streamResults = new List<double>();
|
||||
foreach (var bar in _testData.Bars)
|
||||
{
|
||||
streamResults.Add(ethermStream.Update(bar).Value);
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batchResults = Etherm.Batch(_testData.Bars, period);
|
||||
|
||||
Assert.Equal(streamResults.Count, batchResults.Count);
|
||||
for (int i = 0; i < streamResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SpanMatchesStreaming()
|
||||
{
|
||||
int[] periods = { 5, 14, 22 };
|
||||
int len = _testData.Bars.Count;
|
||||
|
||||
double[] highs = new double[len];
|
||||
double[] lows = new double[len];
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
highs[i] = _testData.Bars[i].High;
|
||||
lows[i] = _testData.Bars[i].Low;
|
||||
}
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Streaming
|
||||
var ethermStream = new Etherm(period);
|
||||
var streamResults = new double[len];
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
streamResults[i] = ethermStream.Update(_testData.Bars[i]).Value;
|
||||
}
|
||||
|
||||
// Span batch (raw temperature only)
|
||||
double[] spanResults = new double[len];
|
||||
Etherm.Batch(highs, lows, spanResults, period);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanResults[i], 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Known-Value Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void Validation_InsideBars_ReturnZero()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=110, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: inside bar (H=105 < 110 AND L=95 > 90) → temp = 0
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 100, 105, 95, 100, 1000);
|
||||
var result = etherm.Update(bar2);
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
|
||||
// Bar3: inside bar again (H=103 < 105... wait, that's relative to bar2)
|
||||
// Re-check: prevH=105, prevL=95 → H=104 < 105, L=96 > 95 → inside
|
||||
var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 100, 104, 96, 100, 1000);
|
||||
var result3 = etherm.Update(bar3);
|
||||
Assert.Equal(0.0, result3.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_FlatMarket_ZeroTemperature()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(baseTime.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
etherm.Update(bar);
|
||||
}
|
||||
|
||||
// Flat market: all H=L=O=C → highDiff=0, lowDiff=0, not inside bar, temp=0
|
||||
Assert.Equal(0.0, etherm.Last.Value, 1e-10);
|
||||
Assert.Equal(0.0, etherm.Signal, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_GapUp_MeasuresHighExtension()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=100, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 95, 100, 90, 95, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: Gap up → H=120, L=105 → highDiff=|120-100|=20, lowDiff=|90-105|=15
|
||||
// Not inside (120 > 100), temp = max(20, 15) = 20
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 110, 120, 105, 115, 1000);
|
||||
var result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(20.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_GapDown_MeasuresLowExtension()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
|
||||
// Bar1: H=100, L=90
|
||||
var bar1 = new TBar(DateTime.UtcNow, 95, 100, 90, 95, 1000);
|
||||
etherm.Update(bar1);
|
||||
|
||||
// Bar2: Gap down → H=95, L=70 → highDiff=|95-100|=5, lowDiff=|90-70|=20
|
||||
// Not inside (95 < 100 but 70 < 90, so not BOTH conditions met)
|
||||
// Inside = H < prevH AND L > prevL → 95 < 100 is true, but 70 > 90 is false → NOT inside
|
||||
// temp = max(5, 20) = 20
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 80, 95, 70, 75, 1000);
|
||||
var result = etherm.Update(bar2);
|
||||
|
||||
Assert.Equal(20.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
// ============== Different Periods ==============
|
||||
|
||||
[Fact]
|
||||
public void Validation_DifferentPeriods_ProduceDifferentSignals()
|
||||
{
|
||||
var etherm5 = new Etherm(5);
|
||||
var etherm22 = new Etherm(22);
|
||||
var etherm50 = new Etherm(50);
|
||||
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.5);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm5.Update(bar);
|
||||
etherm22.Update(bar);
|
||||
etherm50.Update(bar);
|
||||
}
|
||||
|
||||
// Temperature (Last) should be the same regardless of period
|
||||
// (EMA period only affects Signal)
|
||||
Assert.Equal(etherm5.Last.Value, etherm22.Last.Value, 1e-10);
|
||||
Assert.Equal(etherm22.Last.Value, etherm50.Last.Value, 1e-10);
|
||||
|
||||
// But signals should differ (different EMA periods)
|
||||
// Note: They can be equal in degenerate cases, but generally should differ
|
||||
Assert.True(double.IsFinite(etherm5.Signal));
|
||||
Assert.True(double.IsFinite(etherm22.Signal));
|
||||
Assert.True(double.IsFinite(etherm50.Signal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_Calculate_ReturnsHotIndicator()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.5);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var (results, indicator) = Etherm.Calculate(bars, 14);
|
||||
|
||||
Assert.Equal(bars.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.True(double.IsFinite(indicator.Signal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_BarCorrection_Consistent()
|
||||
{
|
||||
var etherm1 = new Etherm(14);
|
||||
var etherm2 = new Etherm(14);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.3);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Etherm1: feed all bars normally
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm1.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
// Etherm2: feed bars with corrections (isNew=false for last bar, then replace)
|
||||
for (int i = 0; i < bars.Count - 1; i++)
|
||||
{
|
||||
etherm2.Update(bars[i], isNew: true);
|
||||
}
|
||||
// Feed an incorrect last bar first
|
||||
var wrongBar = new TBar(bars[^1].Time, 0, 999, 1, 500, 1000);
|
||||
etherm2.Update(wrongBar, isNew: true);
|
||||
// Correct it
|
||||
etherm2.Update(bars[^1], isNew: false);
|
||||
|
||||
Assert.Equal(etherm1.Last.Value, etherm2.Last.Value, 1e-10);
|
||||
Assert.Equal(etherm1.Signal, etherm2.Signal, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_Temperature_AlwaysNonNegative()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 1.0);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = etherm.Update(bar);
|
||||
Assert.True(result.Value >= 0, $"Temperature must be non-negative, got {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_Signal_AlwaysNonNegative()
|
||||
{
|
||||
var etherm = new Etherm(22);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 1.0);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
etherm.Update(bar);
|
||||
Assert.True(etherm.Signal >= 0, $"Signal must be non-negative, got {etherm.Signal}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// ETHERM: Elder's Thermometer
|
||||
/// Measures bar-to-bar range extension to quantify market volatility.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Calculation steps:</b>
|
||||
/// <list type="number">
|
||||
/// <item>highDiff = |High - prevHigh|, lowDiff = |prevLow - Low|</item>
|
||||
/// <item>Inside bar (High < prevHigh AND Low > prevLow) → Temperature = 0</item>
|
||||
/// <item>Otherwise Temperature = max(highDiff, lowDiff)</item>
|
||||
/// <item>Signal = EMA(Temperature, period) with bias compensation</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Sources:</b>
|
||||
/// Dr. Alexander Elder (2002). "Come Into My Trading Room" p.162
|
||||
/// </remarks>
|
||||
/// <seealso href="Etherm.md">Detailed documentation</seealso>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Etherm : AbstractBase
|
||||
{
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double PrevHigh,
|
||||
double PrevLow,
|
||||
double Ema,
|
||||
double E,
|
||||
double LastValidHigh,
|
||||
double LastValidLow,
|
||||
double LastValidTemp,
|
||||
int Count
|
||||
)
|
||||
{
|
||||
public bool IsCompensated => E <= 1e-10;
|
||||
}
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
|
||||
/// <summary>
|
||||
/// Creates ETHERM with specified EMA smoothing period.
|
||||
/// </summary>
|
||||
/// <param name="period">EMA period for signal line (must be > 0, default 22)</param>
|
||||
public Etherm(int period = 22)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
_alpha = 2.0 / (period + 1);
|
||||
_decay = 1.0 - _alpha;
|
||||
Name = $"Etherm({period})";
|
||||
WarmupPeriod = period;
|
||||
_s = new State(double.NaN, double.NaN, 0, 1.0, 0, 0, 0, 0);
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates ETHERM with specified source and period.
|
||||
/// </summary>
|
||||
public Etherm(ITValuePublisher source, int period = 22) : this(period)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// IsHot when bias compensator E <= 0.05 (95% coverage).
|
||||
/// </summary>
|
||||
public override bool IsHot => _s.E <= 0.05;
|
||||
|
||||
/// <summary>
|
||||
/// The current EMA signal line value.
|
||||
/// </summary>
|
||||
public double Signal { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a TBar input (preferred method).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar bar, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(bar.Time, bar.High, bar.Low, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a TValue input.
|
||||
/// Treats the value as H=L (degenerate case, zero temperature).
|
||||
/// Prefer Update(TBar) for standard OHLC data.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(input.Time, input.Value, input.Value, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a bar series.
|
||||
/// </summary>
|
||||
public TSeries Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
tSpan[i] = source[i].Time;
|
||||
}
|
||||
|
||||
// Stream each bar to build state
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var result = Update(source[i], isNew: true);
|
||||
vSpan[i] = result.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
// TSeries has no OHLC — treat values as H=L (degenerate case)
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
var values = source.Values;
|
||||
var times = source.Times;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
tSpan[i] = times[i];
|
||||
var result = Update(new TValue(times[i], values[i]), isNew: true);
|
||||
vSpan[i] = result.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override void Reset()
|
||||
{
|
||||
_s = new State(double.NaN, double.NaN, 0, 1.0, 0, 0, 0, 0);
|
||||
_ps = _s;
|
||||
Signal = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates ETHERM for the entire bar series using a new instance.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TBarSeries source, int period = 22)
|
||||
{
|
||||
var etherm = new Etherm(period);
|
||||
return etherm.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Span-based batch calculation for high and low price arrays.
|
||||
/// </summary>
|
||||
/// <param name="high">High prices.</param>
|
||||
/// <param name="low">Low prices.</param>
|
||||
/// <param name="output">Output thermometer temperature values.</param>
|
||||
/// <param name="period">EMA smoothing period (used for signal, output is raw temp).</param>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
Span<double> output,
|
||||
int period = 22)
|
||||
{
|
||||
int len = high.Length;
|
||||
if (low.Length != len)
|
||||
{
|
||||
throw new ArgumentException("High and low spans must have the same length", nameof(low));
|
||||
}
|
||||
if (output.Length < len)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as input spans", nameof(output));
|
||||
}
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double lastValidHigh = 0;
|
||||
double lastValidLow = 0;
|
||||
double lastValidTemp = 0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double h = high[i];
|
||||
double l = low[i];
|
||||
|
||||
// Handle non-finite values
|
||||
if (!double.IsFinite(h))
|
||||
{
|
||||
h = lastValidHigh;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidHigh = h;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(l))
|
||||
{
|
||||
l = lastValidLow;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidLow = l;
|
||||
}
|
||||
|
||||
double temp;
|
||||
if (i == 0)
|
||||
{
|
||||
// First bar: no previous bar, temp = 0
|
||||
temp = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double prevH = high[i - 1];
|
||||
double prevL = low[i - 1];
|
||||
if (!double.IsFinite(prevH))
|
||||
{
|
||||
prevH = lastValidHigh;
|
||||
}
|
||||
if (!double.IsFinite(prevL))
|
||||
{
|
||||
prevL = lastValidLow;
|
||||
}
|
||||
|
||||
double highDiff = Math.Abs(h - prevH);
|
||||
double lowDiff = Math.Abs(prevL - l);
|
||||
bool isInsideBar = h < prevH && l > prevL;
|
||||
temp = isInsideBar ? 0 : Math.Max(highDiff, lowDiff);
|
||||
}
|
||||
|
||||
if (!double.IsFinite(temp) || temp < 0)
|
||||
{
|
||||
temp = lastValidTemp;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidTemp = temp;
|
||||
}
|
||||
|
||||
output[i] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates ETHERM and returns both results and the indicator instance.
|
||||
/// </summary>
|
||||
public static (TSeries Results, Etherm Indicator) Calculate(TBarSeries source, int period = 22)
|
||||
{
|
||||
var indicator = new Etherm(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
// ---- Private implementation ----
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private TValue UpdateCore(long timeTicks, double high, double low, bool isNew)
|
||||
{
|
||||
// Snapshot/restore for bar correction
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Handle non-finite values — use last valid
|
||||
if (!double.IsFinite(high))
|
||||
{
|
||||
high = s.LastValidHigh;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidHigh = high;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(low))
|
||||
{
|
||||
low = s.LastValidLow;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidLow = low;
|
||||
}
|
||||
|
||||
// Calculate thermometer temperature
|
||||
double temp;
|
||||
if (s.Count == 0 || !double.IsFinite(s.PrevHigh))
|
||||
{
|
||||
// First bar: no previous bar to compare, temperature = 0
|
||||
temp = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double highDiff = Math.Abs(high - s.PrevHigh);
|
||||
double lowDiff = Math.Abs(s.PrevLow - low);
|
||||
bool isInsideBar = high < s.PrevHigh && low > s.PrevLow;
|
||||
temp = isInsideBar ? 0 : Math.Max(highDiff, lowDiff);
|
||||
}
|
||||
|
||||
// NaN/Infinity safety on computed temp
|
||||
if (!double.IsFinite(temp) || temp < 0)
|
||||
{
|
||||
temp = s.LastValidTemp;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidTemp = temp;
|
||||
}
|
||||
|
||||
// EMA smoothing with bias compensation (FMA pattern)
|
||||
// ema = ema * decay + alpha * temp
|
||||
s.Ema = Math.FusedMultiplyAdd(s.Ema, _decay, _alpha * temp);
|
||||
s.E *= _decay;
|
||||
|
||||
double signal = s.IsCompensated ? s.Ema : s.Ema / (1.0 - s.E);
|
||||
|
||||
// Update previous bar state
|
||||
s.PrevHigh = high;
|
||||
s.PrevLow = low;
|
||||
if (isNew)
|
||||
{
|
||||
s.Count++;
|
||||
}
|
||||
|
||||
_s = s;
|
||||
|
||||
Signal = signal;
|
||||
Last = new TValue(timeTicks, temp);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
# ETHERM: Elder's Thermometer
|
||||
|
||||
> "Markets run a fever before they crash. The thermometer tells you when to reach for the aspirin."
|
||||
|
||||
Elder's Thermometer (ETHERM) measures how far today's price bar extends beyond yesterday's range, capturing the maximum absolute expansion in either direction. Developed by Dr. Alexander Elder and described in *Come Into My Trading Room* (2002, p.162), the indicator distinguishes between sleepy, quiet periods and hot episodes when market crowds become excited. The raw thermometer reading is smoothed with an EMA to produce a signal line; when temperature spikes to triple the signal, it flags an explosive move worth fading. At 5 operations per bar for the raw value and O(1) EMA update, ETHERM is among the cheapest volatility measures to compute.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Dr. Alexander Elder, a psychiatrist-turned-trader who emigrated from the Soviet Union in the 1970s, built his reputation on applying behavioral psychology to market analysis. His first book *Trading for a Living* (1993) introduced the Elder-Ray Index and the Triple Screen system. His second, *Come Into My Trading Room* (2002), added the Market Thermometer on page 162, filling a gap he identified: existing volatility tools (ATR, Bollinger Width) measured absolute dispersion, but none specifically isolated the *bar-to-bar range extension* that characterizes crowd excitement.
|
||||
|
||||
Elder's insight was deceptively simple. Adjacent bars in a quiet market overlap. The high barely exceeds yesterday's high; the low barely undercuts yesterday's low. When the crowd gets excited, bars start pushing outside previous ranges. The thermometer captures exactly this phenomenon: how many price units did today's bar extend beyond yesterday's boundaries?
|
||||
|
||||
The formula differs from True Range in a critical way. TR measures the total possible price excursion including gaps (max of H-L, |H-prevC|, |L-prevC|). ETHERM ignores the close entirely and focuses on high-to-high and low-to-low comparisons. A stock that gaps up 5 points but trades within a 1-point range registers TR=5 but ETHERM near zero (assuming yesterday's high was close to today's high). The two indicators answer different questions: TR asks "how far could price have traveled?" while ETHERM asks "how much did today's bar escape yesterday's?"
|
||||
|
||||
Several implementations exist across platforms. The ProRealCode and MotiveWave versions match Elder's original formula precisely. The LightningChart JS version diverges significantly, comparing current bars to N-periods-ago bars rather than the previous bar. This QuanTAlib implementation follows Elder's original specification: previous bar comparison with inside-bar detection.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
ETHERM has three components: raw temperature calculation, EMA signal smoothing, and threshold detection.
|
||||
|
||||
### 1. Raw Temperature Calculation
|
||||
|
||||
The thermometer measures the maximum absolute extension beyond the previous bar:
|
||||
|
||||
$$
|
||||
\text{highDiff}_t = |H_t - H_{t-1}|
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{lowDiff}_t = |L_{t-1} - L_t|
|
||||
$$
|
||||
|
||||
Three cases determine the output:
|
||||
|
||||
$$
|
||||
T_t = \begin{cases}
|
||||
0 & \text{if } H_t < H_{t-1} \text{ AND } L_t > L_{t-1} \text{ (inside bar)} \\
|
||||
\max(\text{highDiff}_t, \text{lowDiff}_t) & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
The inside bar case is significant. When today's entire range fits within yesterday's range, there is zero range extension in either direction. The crowd is dormant.
|
||||
|
||||
### 2. EMA Signal Line
|
||||
|
||||
The raw temperature is smoothed with an exponential moving average:
|
||||
|
||||
$$
|
||||
\alpha = \frac{2}{N + 1}
|
||||
$$
|
||||
|
||||
$$
|
||||
S_t = \alpha \cdot T_t + (1 - \alpha) \cdot S_{t-1}
|
||||
$$
|
||||
|
||||
Default period $N = 22$ (approximately one trading month). The EMA provides a baseline "normal temperature" against which spikes and troughs are measured.
|
||||
|
||||
### 3. Threshold Detection
|
||||
|
||||
Elder defined two key thresholds:
|
||||
|
||||
**Explosive move:** When the thermometer reaches or exceeds the signal multiplied by a factor (default 3.0):
|
||||
|
||||
$$
|
||||
\text{Explosive} = T_t \geq S_t \times M
|
||||
$$
|
||||
|
||||
where $M$ is the multiplier (default 3.0).
|
||||
|
||||
**Idle market:** When the thermometer remains below the signal for a sustained number of consecutive bars (Elder suggested 5-7 bars). This is a secondary signal not computed in the indicator itself but observable from the histogram.
|
||||
|
||||
### 4. First Bar Handling
|
||||
|
||||
For the first bar (no previous bar available):
|
||||
|
||||
$$
|
||||
T_0 = 0
|
||||
$$
|
||||
|
||||
Using `nz(high[1], high)` maps the previous high to today's high, making highDiff and lowDiff both zero. This is correct: with no history, there is no range extension to measure.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Why Absolute Values?
|
||||
|
||||
Consider a bar where today's high is 102 and yesterday's high was 105. The extension is $|102 - 105| = 3$. Without the absolute value, the result would be $-3$, hiding the magnitude. Elder's thermometer cares about *size* of escape, not direction. A 3-point high compression and a 3-point low extension represent equal amounts of crowd activity.
|
||||
|
||||
### Relationship to True Range
|
||||
|
||||
True Range and ETHERM share a structural similarity but measure different phenomena:
|
||||
|
||||
| Scenario | TR | ETHERM |
|
||||
| :--- | :--- | :--- |
|
||||
| No gap, wide bar | $H - L$ | $\max(\|H-H_{-1}\|, \|L_{-1}-L\|)$ |
|
||||
| Large gap up, narrow bar | $H - C_{-1}$ (large) | Near 0 (similar H-to-H) |
|
||||
| Breakout bar exceeding prior range | $H - L$ | Large (extension detected) |
|
||||
| Inside bar | $H - L$ (positive) | 0 (no extension) |
|
||||
|
||||
ETHERM specifically detects range *expansion*. TR detects total price travel. A market that gaps and then consolidates shows high TR but low ETHERM.
|
||||
|
||||
### EMA Warmup Compensation
|
||||
|
||||
The PineScript reference implementation uses warmup-compensated EMA to eliminate initialization bias:
|
||||
|
||||
$$
|
||||
e_t = e_{t-1} \cdot (1 - \alpha), \quad e_0 = 1
|
||||
$$
|
||||
|
||||
$$
|
||||
S_{compensated} = \frac{S_{raw}}{1 - e_t} \quad \text{when } e_t > \epsilon
|
||||
$$
|
||||
|
||||
This ensures accurate signal values from the first bar rather than waiting for the EMA to "fill up."
|
||||
|
||||
### Convergence
|
||||
|
||||
For EMA period $N = 22$, $\alpha = 2/23 \approx 0.087$:
|
||||
|
||||
$$
|
||||
\text{WarmupPeriod} \approx \frac{\ln(0.05)}{\ln(1 - \alpha)} \approx \frac{-3.0}{-0.091} \approx 33 \text{ bars}
|
||||
$$
|
||||
|
||||
After 33 bars, the initialization bias drops below 5%.
|
||||
|
||||
### Inside Bar Probability
|
||||
|
||||
In typical equity markets, inside bars occur approximately 15-25% of trading days. The zero-temperature reading for inside bars creates a natural floor that keeps the EMA signal from rising without genuine range extension. This asymmetry is intentional: Elder wanted the thermometer to measure heat, not cold.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
Per-bar operations:
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| SUB | 2 | 1 | 2 |
|
||||
| ABS | 2 | 1 | 2 |
|
||||
| CMP | 3 | 1 | 3 |
|
||||
| MAX | 1 | 1 | 1 |
|
||||
| FMA | 1 | 5 | 5 |
|
||||
| MUL | 2 | 3 | 6 |
|
||||
| DIV | 1 | 15 | 15 |
|
||||
| **Total** | **12** | | **~34 cycles** |
|
||||
|
||||
ETHERM is extremely lightweight. No logarithms, no square roots, no transcendental functions. The EMA update dominates at ~60% of total cost.
|
||||
|
||||
### Batch Mode (512 values, SIMD/FMA)
|
||||
|
||||
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Subtractions (H-prevH, prevL-L) | 1024 | 128 | 8x |
|
||||
| Absolute values | 1024 | 128 | 8x |
|
||||
| Comparisons + MAX | 1536 | 192 | 8x |
|
||||
| EMA update | 512 | 512 | 1x (sequential) |
|
||||
|
||||
The raw temperature calculation vectorizes perfectly. The EMA is inherently sequential (each value depends on the previous), limiting overall batch speedup to roughly 3-4x.
|
||||
|
||||
### Memory Profile
|
||||
|
||||
- **Per instance:** ~64 bytes (state struct with prevHigh, prevLow, EMA state, warmup)
|
||||
- **No ring buffer required** (only needs previous bar's high and low)
|
||||
- **100 instances:** ~6.4 KB
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact calculation, no approximations |
|
||||
| **Timeliness** | 9/10 | Minimal lag; raw value is instantaneous, EMA adds slight delay |
|
||||
| **Smoothness** | 4/10 | Raw thermometer is spiky by design; signal line smooths |
|
||||
| **Simplicity** | 9/10 | Two subtractions, two abs, one max, one EMA |
|
||||
| **Interpretability** | 8/10 | Direct physical meaning: price units of range extension |
|
||||
|
||||
## Validation
|
||||
|
||||
ETHERM is not widely implemented in major open-source libraries under a standard name. Most implementations are custom scripts.
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | N/A | Not implemented |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **OoplesFinance** | N/A | Not implemented |
|
||||
| **PineScript** | ✅ | Matches etherm.pine reference |
|
||||
| **ProRealCode** | ✅ | Matches Elder's original formula |
|
||||
| **MotiveWave** | ✅ | Confirms formula: "highest absolute difference" |
|
||||
| **Manual** | ✅ | Validated against Elder p.162 formula |
|
||||
|
||||
The absence from standard libraries is unsurprising. ETHERM was published in a trading book, not an academic paper. It lacks the institutional pedigree of Wilder's indicators (ATR, RSI) or Bollinger's Bands. The algorithm is simple enough that most platforms implement it as a custom script rather than a built-in function.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Confusing ETHERM with ATR**: ATR measures total price excursion including gaps (uses close). ETHERM measures bar-to-bar range extension (ignores close entirely). A large gap-up with a narrow range produces high ATR but near-zero ETHERM. Using one where the other is intended produces meaningfully wrong signals.
|
||||
|
||||
2. **Inside bar handling**: Some implementations omit the inside bar check, computing `max(highDiff, lowDiff)` even when both differences are negative (meaning compression, not expansion). This incorrectly reports range contraction as if it were expansion. Elder's original formula explicitly returns zero for inside bars.
|
||||
|
||||
3. **Absolute value omission**: The formula requires absolute values of the differences. When `Low_today > Low_yesterday`, `Low_yesterday - Low_today` is negative. Without `abs()`, the max function may select highDiff by default even when lowDiff is the dominant extension. Approximately 10-15% of signals will be wrong.
|
||||
|
||||
4. **EMA period sensitivity**: Elder's default of 22 bars (roughly one trading month) works for daily charts. On 5-minute charts, 22 bars spans less than 2 hours. For intraday use, scale the period proportionally: ~250 for 5-min, ~50 for hourly. Using period 22 on intraday data produces an overly responsive signal line.
|
||||
|
||||
5. **Multiplier calibration**: The default 3.0 multiplier for explosive moves was designed for daily equity data in the late 1990s. Crypto and high-volatility assets may need higher multipliers (4.0-5.0) to avoid false positives. Low-volatility instruments (bonds, utilities) may need lower multipliers (2.0-2.5). Test the multiplier against historical data before relying on it.
|
||||
|
||||
6. **Zero-temperature clustering**: Inside bars cluster during consolidation. Extended periods of zero readings followed by a breakout bar produce a spike that appears dramatic relative to the suppressed EMA. This is feature, not bug: Elder designed the indicator to flag exactly this transition. But traders should be aware that the spike magnitude reflects the prior calm as much as the current excitement.
|
||||
|
||||
7. **No directional information**: ETHERM measures magnitude of range extension but not direction. A 10-point extension could be bullish (new highs) or bearish (new lows). Pair ETHERM with directional indicators (Elder-Ray, Impulse System) for complete context.
|
||||
|
||||
## Trading Applications
|
||||
|
||||
### Entry Timing
|
||||
|
||||
Elder's primary recommendation: enter positions when Thermometer < Signal:
|
||||
|
||||
```text
|
||||
If system generates entry signal AND ETHERM < Signal:
|
||||
Execute entry (low slippage environment)
|
||||
If system generates entry signal AND ETHERM > Signal:
|
||||
Wait or reduce size (hot market, slippage likely)
|
||||
```
|
||||
|
||||
### Profit-Taking on Spikes
|
||||
|
||||
Exit (or take partial profits) when Thermometer >= Signal x 3:
|
||||
|
||||
```text
|
||||
If ETHERM >= Signal × multiplier:
|
||||
Take profits on existing positions
|
||||
Panics are short-lived; cash in before reversion
|
||||
```
|
||||
|
||||
### Volatility Regime Filter
|
||||
|
||||
Track consecutive bars below the signal line:
|
||||
|
||||
```text
|
||||
If ETHERM < Signal for 7+ consecutive bars:
|
||||
Market is idle/consolidating
|
||||
Prepare for potential breakout
|
||||
Tighten stops or reduce position size
|
||||
```
|
||||
|
||||
## Relationship to Other Indicators
|
||||
|
||||
| Indicator | Relationship to ETHERM |
|
||||
| :--- | :--- |
|
||||
| **TR** | TR measures total price travel (with gaps); ETHERM measures range extension only |
|
||||
| **ATR** | Smoothed TR; both measure volatility but from different perspectives |
|
||||
| **Elder-Ray** | Bull/Bear Power measures distance from EMA; complements ETHERM's range extension |
|
||||
| **Impulse System** | Directional classification; pair with ETHERM for timing |
|
||||
| **Bollinger Width** | Measures band expansion/contraction; slower-moving volatility gauge |
|
||||
| **ADX** | Trend strength; ETHERM measures volatility regardless of trend |
|
||||
|
||||
## References
|
||||
|
||||
- Elder, A. (2002). *Come Into My Trading Room: A Complete Guide to Trading*. John Wiley & Sons. pp. 162-164.
|
||||
- Elder, A. (1993). *Trading for a Living: Psychology, Trading Tactics, Money Management*. John Wiley & Sons.
|
||||
- Elder, A. (2014). *The New Trading for a Living*. John Wiley & Sons. (Updated treatment of the Thermometer.)
|
||||
- LazyBear. (2015). "Elder's Market Thermometer." TradingView Community Scripts.
|
||||
- MotiveWave Documentation. "Elders Thermometer (THER)." docs.motivewave.com.
|
||||
@@ -0,0 +1,54 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Elder's Thermometer", "ETHERM", overlay=false)
|
||||
|
||||
//@function Calculates Elder's Market Thermometer with EMA signal line
|
||||
//@param period The EMA smoothing period for the signal line
|
||||
//@returns [thermometer, signal] The raw thermometer value and EMA signal line
|
||||
//@optimized Beta precomputation for EMA warmup compensation
|
||||
etherm(simple int period) =>
|
||||
if period <= 0
|
||||
runtime.error("Period must be greater than 0")
|
||||
|
||||
// Step 1: Calculate raw thermometer value
|
||||
// Temperature = max(abs(High - prevHigh), abs(prevLow - Low))
|
||||
// Inside bar (High < prevHigh AND Low > prevLow) => 0
|
||||
float prevHigh = nz(high[1], high)
|
||||
float prevLow = nz(low[1], low)
|
||||
float highDiff = math.abs(high - prevHigh)
|
||||
float lowDiff = math.abs(prevLow - low)
|
||||
bool isInsideBar = high < prevHigh and low > prevLow
|
||||
float temp = isInsideBar ? 0.0 : math.max(highDiff, lowDiff)
|
||||
|
||||
// Step 2: EMA of thermometer with warmup compensation
|
||||
float alpha = 2.0 / float(period + 1)
|
||||
float beta = 1.0 - alpha
|
||||
var float EPSILON = 1e-10
|
||||
var float raw_ema = 0.0
|
||||
var float e = 1.0
|
||||
float signal = na
|
||||
if not na(temp)
|
||||
raw_ema := raw_ema * beta + temp * alpha
|
||||
e *= beta
|
||||
signal := e > EPSILON ? raw_ema / (1.0 - e) : raw_ema
|
||||
|
||||
[temp, signal]
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_period = input.int(22, "EMA Period", minval=1, tooltip="Number of bars for the EMA signal line")
|
||||
i_multiplier = input.float(3.0, "Explosive Threshold", minval=0.1, step=0.5, tooltip="Multiplier for explosive move detection")
|
||||
|
||||
// Calculation
|
||||
[thermValue, signalValue] = etherm(i_period)
|
||||
|
||||
// Colors
|
||||
bool isExplosive = thermValue >= signalValue * i_multiplier
|
||||
bool isHot = thermValue >= signalValue
|
||||
color thermColor = isExplosive ? color.red : isHot ? color.orange : color.new(color.blue, 30)
|
||||
|
||||
// Plot
|
||||
plot(thermValue, "Thermometer", color=thermColor, style=plot.style_histogram, linewidth=2)
|
||||
plot(signalValue, "Signal", color=color.yellow, linewidth=2)
|
||||
Reference in New Issue
Block a user