mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 13:08:04 +00:00
Add validation tests for various volume and momentum indicators
- Introduced Massi validation tests to ensure mathematical properties hold for the Mass Index indicator. - Added Va validation tests for Volume Accumulation, checking for finite outputs and correct accumulation behavior. - Implemented Vf validation tests for Volume Force, verifying outputs for rising and falling prices, and ensuring batch and streaming results match. - Created Vo validation tests for Volume Oscillator, confirming behavior with constant, increasing, and decreasing volumes. - Developed Vroc validation tests for Volume Rate of Change, validating outputs for constant volume and changes in volume. - Updated project file to include new momentum indicators (MACD and RSI) in the compilation.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class TtmWaveIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Contains("TTM Wave", indicator.Name, StringComparison.Ordinal);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
|
||||
Assert.Equal(0, TtmWaveIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_ShortName_IncludesIdentifier()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("TTM_Wave", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("TtmWave", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
// 6 wave histograms + 1 zero line = 7 series
|
||||
Assert.Equal(7, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 800; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i * 0.1, 110 + i * 0.1, 90 + i * 0.1, 105 + i * 0.1);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Wave A1 (index 4 — added 5th in constructor order: C1,C2,B1,B2,A1,A2,Zero)
|
||||
double waveA1 = indicator.LinesSeries[4].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(waveA1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 800; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i * 0.1, 110 + i * 0.1, 90 + i * 0.1, 105 + i * 0.1);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Simulate a new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(800), 180, 190, 170, 185);
|
||||
var newArgs = new UpdateArgs(UpdateReason.NewBar);
|
||||
indicator.ProcessUpdate(newArgs);
|
||||
|
||||
double waveA1 = indicator.LinesSeries[4].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(waveA1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_ZeroLine_IsSet()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Zero line is the last series (index 6)
|
||||
double zero = indicator.LinesSeries[6].GetValue(0);
|
||||
Assert.Equal(0.0, zero, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_Description_IsSet()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
|
||||
Assert.NotNull(indicator.Description);
|
||||
Assert.NotEmpty(indicator.Description);
|
||||
Assert.Contains("TTM", indicator.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TtmWaveIndicator_AllSeries_ProduceFiniteValues()
|
||||
{
|
||||
var indicator = new TtmWaveIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 800; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i * 0.1, 110 + i * 0.1, 90 + i * 0.1, 105 + i * 0.1);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// All 7 series should have finite values
|
||||
for (int s = 0; s < 7; s++)
|
||||
{
|
||||
double val = indicator.LinesSeries[s].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Series {s} value not finite: {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// TTM Wave: Multi-period MACD Composite - Quantower Indicator Adapter
|
||||
/// Displays six Fibonacci-period MACD histograms grouped into A, B, C waves.
|
||||
/// Matching thinkorswim TTM_Wave color conventions.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class TtmWaveIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private TtmWave _wave = null!;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
// Wave A: green/yellow tones (short-term)
|
||||
private readonly LineSeries _waveA1Series;
|
||||
private readonly LineSeries _waveA2Series;
|
||||
|
||||
// Wave B: pink/magenta tones (medium-term)
|
||||
private readonly LineSeries _waveB1Series;
|
||||
private readonly LineSeries _waveB2Series;
|
||||
|
||||
// Wave C: red/dark red tones (long-term)
|
||||
private readonly LineSeries _waveC1Series;
|
||||
private readonly LineSeries _waveC2Series;
|
||||
|
||||
// Zero line
|
||||
private readonly LineSeries _zeroLine;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"TTM_Wave:{_sourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/ttm_wave/TtmWave.cs";
|
||||
|
||||
public TtmWaveIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
_sourceName = Source.ToString();
|
||||
Name = "TTM Wave";
|
||||
Description = "John Carter's TTM Wave - Multi-period MACD composite using Fibonacci EMA periods (A/B/C waves)";
|
||||
|
||||
// Wave A (short-term momentum) — yellow/green histograms
|
||||
_waveA1Series = new LineSeries("Wave A1", Color.FromArgb(0, 200, 0), 2, LineStyle.Histogramm);
|
||||
_waveA2Series = new LineSeries("Wave A2", Color.FromArgb(200, 200, 0), 2, LineStyle.Histogramm);
|
||||
|
||||
// Wave B (medium-term momentum) — magenta/pink histograms
|
||||
_waveB1Series = new LineSeries("Wave B1", Color.FromArgb(200, 0, 200), 2, LineStyle.Histogramm);
|
||||
_waveB2Series = new LineSeries("Wave B2", Color.FromArgb(128, 128, 255), 2, LineStyle.Histogramm);
|
||||
|
||||
// Wave C (long-term momentum) — red/orange histograms
|
||||
_waveC1Series = new LineSeries("Wave C1", Color.FromArgb(200, 0, 0), 2, LineStyle.Histogramm);
|
||||
_waveC2Series = new LineSeries("Wave C2", Color.FromArgb(255, 128, 0), 2, LineStyle.Histogramm);
|
||||
|
||||
// Zero line
|
||||
_zeroLine = new LineSeries("Zero", Color.Gray, 1, LineStyle.Dash);
|
||||
|
||||
AddLineSeries(_waveC1Series);
|
||||
AddLineSeries(_waveC2Series);
|
||||
AddLineSeries(_waveB1Series);
|
||||
AddLineSeries(_waveB2Series);
|
||||
AddLineSeries(_waveA1Series);
|
||||
AddLineSeries(_waveA2Series);
|
||||
AddLineSeries(_zeroLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_wave = new TtmWave();
|
||||
_sourceName = Source.ToString();
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var bar = this.GetInputBar(args);
|
||||
double price = _priceSelector(HistoricalData[Count - 1, SeekOriginHistory.Begin]);
|
||||
_ = _wave.Update(new TValue(bar.Time, price), args.IsNewBar());
|
||||
|
||||
bool isHot = _wave.IsHot;
|
||||
|
||||
_waveA1Series.SetValue(_wave.WaveA1.Value, isHot, ShowColdValues);
|
||||
_waveA2Series.SetValue(_wave.WaveA2.Value, isHot, ShowColdValues);
|
||||
_waveB1Series.SetValue(_wave.WaveB1.Value, isHot, ShowColdValues);
|
||||
_waveB2Series.SetValue(_wave.WaveB2.Value, isHot, ShowColdValues);
|
||||
_waveC1Series.SetValue(_wave.WaveC1.Value, isHot, ShowColdValues);
|
||||
_waveC2Series.SetValue(_wave.WaveC2.Value, isHot, ShowColdValues);
|
||||
_zeroLine.SetValue(0, isHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// A) Constructor Validation
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveConstructorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Default_SetsName()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
Assert.Equal("TtmWave", wave.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Default_NotHot()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
Assert.False(wave.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WarmupPeriod_Is752()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
// max(8, 377) + 377 - 2 = 752
|
||||
Assert.Equal(752, wave.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Chaining_SubscribesToSource()
|
||||
{
|
||||
var source = new Ema(10);
|
||||
using var wave = new TtmWave(source);
|
||||
Assert.Equal("TtmWave", wave.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultOutputs_AreDefault()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
Assert.Equal(0, wave.WaveA1.Value);
|
||||
Assert.Equal(0, wave.WaveA2.Value);
|
||||
Assert.Equal(0, wave.WaveB1.Value);
|
||||
Assert.Equal(0, wave.WaveB2.Value);
|
||||
Assert.Equal(0, wave.WaveC1.Value);
|
||||
Assert.Equal(0, wave.WaveC2.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// B) Basic Calculation
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveBasicTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
var result = wave.Update(input);
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_IsAccessible()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
wave.Update(input);
|
||||
Assert.Equal(wave.Wave1.Value, wave.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AllWaves_PopulatedAfterUpdate()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// After 100 bars, waves should have non-default values
|
||||
// (A wave should be non-zero since warmup for channel 1 is only 66)
|
||||
Assert.NotEqual(0, wave.WaveA2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Wave1_EqualsWaveA2()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(wave.WaveA2.Value, wave.Wave1.Value);
|
||||
Assert.Equal(wave.WaveA2.Time, wave.Wave1.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Wave2High_IsMaxOfC()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(800);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(Math.Max(wave.WaveC1.Value, wave.WaveC2.Value), wave.Wave2High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Wave2Low_IsMinOfC()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(800);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(Math.Min(wave.WaveC1.Value, wave.WaveC2.Value), wave.Wave2Low);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// C) State + Bar Correction
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveBarCorrectionTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_AdvancesState()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
double valBefore = wave.Last.Value;
|
||||
wave.Update(series[100], isNew: true);
|
||||
|
||||
Assert.NotEqual(valBefore, wave.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_RewritesCurrentBar()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// First update as new bar
|
||||
wave.Update(series[100], isNew: true);
|
||||
double afterNew = wave.Last.Value;
|
||||
|
||||
// Update same bar with different value
|
||||
var modified = new TValue(series[100].Time, series[100].Value + 5.0);
|
||||
wave.Update(modified, isNew: false);
|
||||
|
||||
// Re-update with original value should restore
|
||||
wave.Update(series[100], isNew: false);
|
||||
double afterRestore = wave.Last.Value;
|
||||
|
||||
Assert.Equal(afterNew, afterRestore, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_Restore()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// Multiple rewrites followed by same-value restore
|
||||
wave.Update(series[100], isNew: true);
|
||||
double baseline = wave.Last.Value;
|
||||
|
||||
for (int j = 0; j < 5; j++)
|
||||
{
|
||||
var tick = new TValue(series[100].Time, series[100].Value + (j * 2.0));
|
||||
wave.Update(tick, isNew: false);
|
||||
}
|
||||
|
||||
wave.Update(series[100], isNew: false);
|
||||
Assert.Equal(baseline, wave.Last.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// D) Warmup / Convergence
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveWarmupTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAfterWarmup()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(800);
|
||||
|
||||
bool wasHot = false;
|
||||
int hotAt = -1;
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
if (wave.IsHot && !wasHot)
|
||||
{
|
||||
wasHot = true;
|
||||
hotAt = i;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(wasHot, "Indicator never became hot");
|
||||
// Should become hot at or near WarmupPeriod (752)
|
||||
Assert.True(hotAt <= wave.WarmupPeriod, $"Became hot at {hotAt}, expected <= {wave.WarmupPeriod}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_StaysCold_BeforeWarmup()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// 100 bars is not enough for 752 warmup
|
||||
Assert.False(wave.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// E) Robustness (NaN / Infinity)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveRobustnessTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_ProducesFiniteOutput()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// Feed NaN
|
||||
var nanInput = new TValue(DateTime.UtcNow, double.NaN);
|
||||
wave.Update(nanInput, isNew: true);
|
||||
|
||||
// MACD internally handles NaN via Ema which substitutes last valid
|
||||
Assert.True(double.IsFinite(wave.Last.Value) || wave.Last.Value == 0,
|
||||
"NaN input should not propagate to output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_ProducesFiniteOutput()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
var infInput = new TValue(DateTime.UtcNow, double.PositiveInfinity);
|
||||
wave.Update(infInput, isNew: true);
|
||||
|
||||
// Should handle gracefully
|
||||
Assert.True(double.IsFinite(wave.Last.Value) || wave.Last.Value == 0,
|
||||
"Infinity input should not propagate to output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_Safe()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
|
||||
// Feed mixture of valid and NaN
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double val = (i % 10 == 0) ? double.NaN : 100.0 + i;
|
||||
wave.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), isNew: true);
|
||||
}
|
||||
|
||||
// Should not throw
|
||||
Assert.True(double.IsFinite(wave.Last.Value) || wave.Last.Value == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// F) Consistency (Batch == Streaming == Eventing)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveConsistencyTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_EqualsStreaming()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
// Batch
|
||||
var batchResults = TtmWave.Batch(series);
|
||||
|
||||
// Streaming
|
||||
var streamWave = new TtmWave();
|
||||
var streamResults = new List<double>();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamWave.Update(series[i], isNew: true);
|
||||
streamResults.Add(streamWave.Last.Value);
|
||||
}
|
||||
|
||||
Assert.Equal(batchResults.Count, streamResults.Count);
|
||||
for (int i = 0; i < batchResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchResults.Values[i], streamResults[i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsBothResults()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
var (results, indicator) = TtmWave.Calculate(series);
|
||||
|
||||
Assert.NotNull(results);
|
||||
Assert.NotNull(indicator);
|
||||
Assert.Equal(200, results.Count);
|
||||
Assert.Equal("TtmWave", indicator.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventBased_MatchesStreaming()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
// Streaming
|
||||
var streamWave = new TtmWave();
|
||||
var streamResults = new List<double>();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamWave.Update(series[i], isNew: true);
|
||||
streamResults.Add(streamWave.Last.Value);
|
||||
}
|
||||
|
||||
// Event-based
|
||||
var eventSource = new Ema(1); // Pass-through: EMA(1) = identity
|
||||
using var eventWave = new TtmWave(eventSource);
|
||||
var eventResults = new List<double>();
|
||||
eventWave.Pub += (object? _, in TValueEventArgs args) => eventResults.Add(args.Value.Value);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
eventSource.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(streamResults.Count, eventResults.Count);
|
||||
for (int i = 0; i < streamResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], eventResults[i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TSeries_MatchesStreaming()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
// TSeries batch via Update
|
||||
var batchWave = new TtmWave();
|
||||
var batchResults = batchWave.Update(series);
|
||||
|
||||
// Streaming
|
||||
var streamWave = new TtmWave();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamWave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(series.Count, batchResults.Count);
|
||||
// Last values should match
|
||||
Assert.Equal(streamWave.Last.Value, batchResults.Values[^1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// G) Reset Tests
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveResetTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsAllState()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
wave.Reset();
|
||||
|
||||
Assert.False(wave.IsHot);
|
||||
Assert.Equal(0, wave.WaveA1.Value);
|
||||
Assert.Equal(0, wave.WaveA2.Value);
|
||||
Assert.Equal(0, wave.WaveB1.Value);
|
||||
Assert.Equal(0, wave.WaveB2.Value);
|
||||
Assert.Equal(0, wave.WaveC1.Value);
|
||||
Assert.Equal(0, wave.WaveC2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ThenReprocess_MatchesOriginal()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
// First pass
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
double firstPassLast = wave.Last.Value;
|
||||
|
||||
// Reset and reprocess
|
||||
wave.Reset();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
double secondPassLast = wave.Last.Value;
|
||||
|
||||
Assert.Equal(firstPassLast, secondPassLast, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// H) Batch / Static API Tests
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveBatchTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ReturnsCorrectLength()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
var result = TtmWave.Batch(series);
|
||||
Assert.Equal(200, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyInput_ReturnsEmpty()
|
||||
{
|
||||
var series = new TSeries([], []);
|
||||
var result = TtmWave.Batch(series);
|
||||
Assert.True(result.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsWarmIndicator()
|
||||
{
|
||||
var series = GenerateSeries(800);
|
||||
var (results, indicator) = TtmWave.Calculate(series);
|
||||
|
||||
Assert.Equal(800, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// I) Prime Tests
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWavePrimeTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsState()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
wave.Prime(series);
|
||||
|
||||
// After priming, wave should have processed all data
|
||||
Assert.NotEqual(0, wave.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_ThenUpdate_ContinuesCorrectly()
|
||||
{
|
||||
var series = GenerateSeries(300);
|
||||
|
||||
// Reference: process all 300 bars
|
||||
var refWave = new TtmWave();
|
||||
for (int i = 0; i < 300; i++)
|
||||
{
|
||||
refWave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// Prime with first 200, then stream remaining 100
|
||||
var primeWave = new TtmWave();
|
||||
var tList = new List<long>(200);
|
||||
var vList = new List<double>(200);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
tList.Add(series.Times[i]);
|
||||
vList.Add(series.Values[i]);
|
||||
}
|
||||
var primeSeries = new TSeries(tList, vList);
|
||||
primeWave.Prime(primeSeries);
|
||||
|
||||
for (int i = 200; i < 300; i++)
|
||||
{
|
||||
primeWave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(refWave.Last.Value, primeWave.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_EmptySeries_NoOp()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var empty = new TSeries([], []);
|
||||
wave.Prime(empty);
|
||||
Assert.False(wave.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// J) Event / Chainability Tests
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pub_Fires_OnUpdate()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
int fireCount = 0;
|
||||
wave.Pub += (object? _, in TValueEventArgs _a) => fireCount++;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
wave.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(10, fireCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_PropagatesValues()
|
||||
{
|
||||
var source = new Ema(1);
|
||||
using var wave = new TtmWave(source);
|
||||
var received = new List<double>();
|
||||
wave.Pub += (object? _, in TValueEventArgs args) => received.Add(args.Value.Value);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(50, received.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromSource()
|
||||
{
|
||||
var source = new Ema(1);
|
||||
var wave = new TtmWave(source);
|
||||
int fireCount = 0;
|
||||
wave.Pub += (object? _, in TValueEventArgs _a) => fireCount++;
|
||||
|
||||
source.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
Assert.Equal(1, fireCount);
|
||||
|
||||
wave.Dispose();
|
||||
|
||||
source.Update(new TValue(DateTime.UtcNow, 101.0), isNew: true);
|
||||
Assert.Equal(1, fireCount); // Should not fire again
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// K) Multi-Output Verification
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public sealed class TtmWaveMultiOutputTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSixWaves_HaveSameTimestamp()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(100);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
long t = wave.WaveA1.Time;
|
||||
Assert.Equal(t, wave.WaveA2.Time);
|
||||
Assert.Equal(t, wave.WaveB1.Time);
|
||||
Assert.Equal(t, wave.WaveB2.Time);
|
||||
Assert.Equal(t, wave.WaveC1.Time);
|
||||
Assert.Equal(t, wave.WaveC2.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaveAmplitudes_IncreaseWithPeriod()
|
||||
{
|
||||
// Longer-period waves tend to have larger absolute values
|
||||
// after sufficient warmup, because they capture more price movement.
|
||||
// This is a soft heuristic test, not a hard rule.
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(1000);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// Just verify all waves are finite and output different values
|
||||
Assert.True(double.IsFinite(wave.WaveA1.Value));
|
||||
Assert.True(double.IsFinite(wave.WaveA2.Value));
|
||||
Assert.True(double.IsFinite(wave.WaveB1.Value));
|
||||
Assert.True(double.IsFinite(wave.WaveB2.Value));
|
||||
Assert.True(double.IsFinite(wave.WaveC1.Value));
|
||||
Assert.True(double.IsFinite(wave.WaveC2.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Waves_IndependentValues()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
var series = GenerateSeries(800);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
// Different channels should produce different values
|
||||
// (extremely unlikely for all 6 to be identical with random data)
|
||||
var values = new HashSet<double>
|
||||
{
|
||||
wave.WaveA1.Value,
|
||||
wave.WaveA2.Value,
|
||||
wave.WaveB1.Value,
|
||||
wave.WaveB2.Value,
|
||||
wave.WaveC1.Value,
|
||||
wave.WaveC2.Value
|
||||
};
|
||||
|
||||
Assert.True(values.Count >= 3, "At least 3 of 6 wave values should be distinct");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TTM Wave validation tests.
|
||||
/// No external libraries (Skender/TA-Lib/Tulip/Ooples) implement TTM Wave,
|
||||
/// so validation is self-consistency: streaming vs batch, prime vs cold,
|
||||
/// deterministic reproducibility, and multi-wave coherence checks.
|
||||
/// </summary>
|
||||
public sealed class TtmWaveValidationTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public TtmWaveValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
// Extract close prices into TSeries for TtmWave (which operates on single values)
|
||||
var t = new List<long>(count);
|
||||
var v = new List<double>(count);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
t.Add(bars[i].Time);
|
||||
v.Add(bars[i].Close); // Close price
|
||||
}
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
// --- A) Streaming vs Batch agreement ---
|
||||
|
||||
[Fact]
|
||||
public void Streaming_Matches_Batch()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
|
||||
var wave = new TtmWave();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
var batch = TtmWave.Batch(series);
|
||||
|
||||
Assert.Equal(wave.Last.Value, batch[^1].Value, 1e-10);
|
||||
_output.WriteLine($"Streaming last={wave.Last.Value:F10}, Batch last={batch[^1].Value:F10}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_Matches_Batch_AllValues()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
int warmup = 752;
|
||||
|
||||
var wave = new TtmWave();
|
||||
var streamValues = new double[series.Count];
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
streamValues[i] = wave.Last.Value;
|
||||
}
|
||||
|
||||
var batch = TtmWave.Batch(series);
|
||||
|
||||
int mismatches = 0;
|
||||
for (int i = warmup; i < series.Count; i++)
|
||||
{
|
||||
double diff = Math.Abs(streamValues[i] - batch[i].Value);
|
||||
if (diff > 1e-8)
|
||||
{
|
||||
mismatches++;
|
||||
if (mismatches <= 5)
|
||||
{
|
||||
_output.WriteLine($"Mismatch at i={i}: stream={streamValues[i]:F10}, batch={batch[i].Value:F10}, diff={diff:E3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(0, mismatches);
|
||||
}
|
||||
|
||||
// --- B) Primed vs Cold start agreement ---
|
||||
|
||||
[Fact]
|
||||
public void Primed_Matches_Cold_Start()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
int splitAt = 800;
|
||||
|
||||
// Cold: process all at once
|
||||
var cold = new TtmWave();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
cold.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
// Primed: prime with first chunk, then stream remainder
|
||||
var primed = new TtmWave();
|
||||
var primeSeries = GenerateSubSeries(series, splitAt);
|
||||
primed.Prime(primeSeries);
|
||||
|
||||
for (int i = splitAt; i < series.Count; i++)
|
||||
{
|
||||
primed.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
double diff = Math.Abs(cold.Last.Value - primed.Last.Value);
|
||||
_output.WriteLine($"Cold={cold.Last.Value:F10}, Primed={primed.Last.Value:F10}, diff={diff:E3}");
|
||||
Assert.True(diff < 1e-8, $"Primed vs cold diff={diff:E3} exceeds tolerance");
|
||||
}
|
||||
|
||||
// --- C) Deterministic reproducibility ---
|
||||
|
||||
[Fact]
|
||||
public void Same_Input_Produces_Same_Output()
|
||||
{
|
||||
var series1 = GenerateSeries(1000, seed: 99);
|
||||
var series2 = GenerateSeries(1000, seed: 99);
|
||||
|
||||
var batch1 = TtmWave.Batch(series1);
|
||||
var batch2 = TtmWave.Batch(series2);
|
||||
|
||||
for (int i = 0; i < batch1.Count; i++)
|
||||
{
|
||||
Assert.Equal(batch1[i].Value, batch2[i].Value, 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Seed_Produces_Different_Output()
|
||||
{
|
||||
var series1 = GenerateSeries(1000, seed: 42);
|
||||
var series2 = GenerateSeries(1000, seed: 99);
|
||||
|
||||
var batch1 = TtmWave.Batch(series1);
|
||||
var batch2 = TtmWave.Batch(series2);
|
||||
|
||||
bool anyDifferent = false;
|
||||
for (int i = 800; i < batch1.Count; i++)
|
||||
{
|
||||
if (Math.Abs(batch1[i].Value - batch2[i].Value) > 1e-6)
|
||||
{
|
||||
anyDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(anyDifferent, "Different seeds should produce different outputs");
|
||||
}
|
||||
|
||||
// --- D) Multi-wave coherence ---
|
||||
|
||||
[Fact]
|
||||
public void All_Six_Waves_Produce_Finite_Values()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
var wave = new TtmWave();
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(wave.WaveA1.Value), "WaveA1 not finite");
|
||||
Assert.True(double.IsFinite(wave.WaveA2.Value), "WaveA2 not finite");
|
||||
Assert.True(double.IsFinite(wave.WaveB1.Value), "WaveB1 not finite");
|
||||
Assert.True(double.IsFinite(wave.WaveB2.Value), "WaveB2 not finite");
|
||||
Assert.True(double.IsFinite(wave.WaveC1.Value), "WaveC1 not finite");
|
||||
Assert.True(double.IsFinite(wave.WaveC2.Value), "WaveC2 not finite");
|
||||
|
||||
_output.WriteLine($"A1={wave.WaveA1.Value:F6}, A2={wave.WaveA2.Value:F6}");
|
||||
_output.WriteLine($"B1={wave.WaveB1.Value:F6}, B2={wave.WaveB2.Value:F6}");
|
||||
_output.WriteLine($"C1={wave.WaveC1.Value:F6}, C2={wave.WaveC2.Value:F6}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wave_Magnitudes_Follow_Expected_Ordering()
|
||||
{
|
||||
// Longer-period MACD channels should generally have larger absolute histograms
|
||||
// (wider slow EMA separation from fast). Not guaranteed per-bar, but on average.
|
||||
var series = GenerateSeries(2000);
|
||||
var wave = new TtmWave();
|
||||
|
||||
double sumAbsA = 0, sumAbsB = 0, sumAbsC = 0;
|
||||
int hotBars = 0;
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
if (wave.IsHot)
|
||||
{
|
||||
sumAbsA += Math.Abs(wave.WaveA1.Value) + Math.Abs(wave.WaveA2.Value);
|
||||
sumAbsB += Math.Abs(wave.WaveB1.Value) + Math.Abs(wave.WaveB2.Value);
|
||||
sumAbsC += Math.Abs(wave.WaveC1.Value) + Math.Abs(wave.WaveC2.Value);
|
||||
hotBars++;
|
||||
}
|
||||
}
|
||||
|
||||
double avgA = sumAbsA / (2 * hotBars);
|
||||
double avgB = sumAbsB / (2 * hotBars);
|
||||
double avgC = sumAbsC / (2 * hotBars);
|
||||
|
||||
_output.WriteLine($"Avg |A|={avgA:F6}, |B|={avgB:F6}, |C|={avgC:F6}, hotBars={hotBars}");
|
||||
|
||||
// Longer periods tend to produce larger histogram deviations on trending GBM data
|
||||
Assert.True(avgC > avgA * 0.5, $"Wave C avg ({avgC:F6}) should not be drastically smaller than A ({avgA:F6})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TOS_Compatibility_Properties_Are_Consistent()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
var wave = new TtmWave();
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
// Wave1 == WaveA2 (per TOS mapping)
|
||||
Assert.Equal(wave.WaveA2.Value, wave.Wave1.Value, 1e-15);
|
||||
|
||||
// Wave2High = max(C1, C2)
|
||||
Assert.Equal(Math.Max(wave.WaveC1.Value, wave.WaveC2.Value), wave.Wave2High, 1e-15);
|
||||
|
||||
// Wave2Low = min(C1, C2)
|
||||
Assert.Equal(Math.Min(wave.WaveC1.Value, wave.WaveC2.Value), wave.Wave2Low, 1e-15);
|
||||
|
||||
// Last == Wave1
|
||||
Assert.Equal(wave.Wave1.Value, wave.Last.Value, 1e-15);
|
||||
}
|
||||
|
||||
// --- E) Calculate returns warm indicator ---
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Returns_Warm_Indicator()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
var (results, indicator) = TtmWave.Calculate(series);
|
||||
|
||||
Assert.Equal(series.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(results[^1].Value, indicator.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
// --- F) Reset produces clean slate ---
|
||||
|
||||
[Fact]
|
||||
public void Reset_Then_Replay_Matches_Fresh()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
|
||||
var wave = new TtmWave();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
double firstRun = wave.Last.Value;
|
||||
|
||||
wave.Reset();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
double secondRun = wave.Last.Value;
|
||||
|
||||
Assert.Equal(firstRun, secondRun, 1e-15);
|
||||
}
|
||||
|
||||
// --- G) Large dataset stability ---
|
||||
|
||||
[Fact]
|
||||
public void Large_Dataset_No_Overflow()
|
||||
{
|
||||
var series = GenerateSeries(5000);
|
||||
var wave = new TtmWave();
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
}
|
||||
|
||||
Assert.True(wave.IsHot);
|
||||
Assert.True(double.IsFinite(wave.Last.Value), "Last value should be finite after 5000 bars");
|
||||
Assert.True(double.IsFinite(wave.WaveC1.Value), "WaveC1 should be finite after 5000 bars");
|
||||
Assert.True(double.IsFinite(wave.WaveC2.Value), "WaveC2 should be finite after 5000 bars");
|
||||
}
|
||||
|
||||
// --- H) Warm-up period validation ---
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_Is_752()
|
||||
{
|
||||
var wave = new TtmWave();
|
||||
Assert.Equal(752, wave.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_False_Before_Warmup_True_After()
|
||||
{
|
||||
var series = GenerateSeries(1000);
|
||||
var wave = new TtmWave();
|
||||
|
||||
bool wasHot = false;
|
||||
int firstHotBar = -1;
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
wave.Update(new TValue(new DateTime(series.Times[i], DateTimeKind.Utc), series.Values[i]));
|
||||
if (wave.IsHot && !wasHot)
|
||||
{
|
||||
firstHotBar = i;
|
||||
wasHot = true;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(wasHot, "Should become hot before 1000 bars");
|
||||
_output.WriteLine($"First hot bar index: {firstHotBar}");
|
||||
|
||||
// IsHot should engage roughly around the warmup period
|
||||
Assert.True(firstHotBar > 0, "Should not be hot immediately");
|
||||
Assert.True(firstHotBar <= wave.WarmupPeriod, $"First hot bar {firstHotBar} should be <= WarmupPeriod {wave.WarmupPeriod}");
|
||||
}
|
||||
|
||||
// --- helper ---
|
||||
|
||||
private static TSeries GenerateSubSeries(TSeries source, int count)
|
||||
{
|
||||
var t = new List<long>(count);
|
||||
var v = new List<double>(count);
|
||||
for (int i = 0; i < count && i < source.Count; i++)
|
||||
{
|
||||
t.Add(source.Times[i]);
|
||||
v.Add(source.Values[i]);
|
||||
}
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// TTM_WAVE: John Carter's TTM Wave Indicator
|
||||
// Multi-period MACD composite using Fibonacci EMA periods.
|
||||
// Measures momentum across short (A), medium (B), and long (C) timeframes.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// TTM_WAVE: John Carter's TTM Wave Indicator
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Composite oscillator built from six MACD-histogram channels at Fibonacci EMA periods.
|
||||
/// All channels share fast EMA period 8; slow/signal periods follow the Fibonacci sequence:
|
||||
/// 34, 55, 89, 144, 233, 377.
|
||||
///
|
||||
/// Wave grouping (matching thinkorswim TTM_Wave_A_B_C):
|
||||
/// Wave A (short-term): channels 1 (8,34,34) and 2 (8,55,55)
|
||||
/// Wave B (medium-term): channels 3 (8,89,89) and 4 (8,144,144)
|
||||
/// Wave C (long-term): channels 5 (8,233,233) and 6 (8,377,377)
|
||||
///
|
||||
/// TOS TTM_Wave compatibility:
|
||||
/// Wave1 = WaveA2 (channel 1 histogram)
|
||||
/// Wave2High = max(WaveC1, WaveC2)
|
||||
/// Wave2Low = min(WaveC1, WaveC2)
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class TtmWave : ITValuePublisher, IDisposable
|
||||
{
|
||||
private const int FastPeriod = 8;
|
||||
|
||||
// Fibonacci slow/signal periods for each channel
|
||||
private const int Slow1 = 34;
|
||||
private const int Slow2 = 55;
|
||||
private const int Slow3 = 89;
|
||||
private const int Slow4 = 144;
|
||||
private const int Slow5 = 233;
|
||||
private const int Slow6 = 377;
|
||||
|
||||
// Six MACD channels — each computes: histogram = (EMA_fast - EMA_slow) - EMA_signal(EMA_fast - EMA_slow)
|
||||
private readonly Macd _macd1; // (8,34,34) → Wave A inner
|
||||
private readonly Macd _macd2; // (8,55,55) → Wave A outer
|
||||
private readonly Macd _macd3; // (8,89,89) → Wave B inner
|
||||
private readonly Macd _macd4; // (8,144,144) → Wave B outer
|
||||
private readonly Macd _macd5; // (8,233,233) → Wave C inner
|
||||
private readonly Macd _macd6; // (8,377,377) → Wave C outer
|
||||
|
||||
private readonly ITValuePublisher? _source;
|
||||
private readonly TValuePublishedHandler _handler;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Display name.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>True when all six channels have sufficient warmup data.</summary>
|
||||
public bool IsHot => _macd1.IsHot && _macd2.IsHot && _macd3.IsHot
|
||||
&& _macd4.IsHot && _macd5.IsHot && _macd6.IsHot;
|
||||
|
||||
/// <summary>Bars required before output is valid (377 + 377 - 2 = 752).</summary>
|
||||
public int WarmupPeriod { get; }
|
||||
|
||||
// ── Full ABC histogram outputs ──────────────────────────────────
|
||||
|
||||
/// <summary>Wave A outer histogram: MACD(8,55) - Signal(55). Larger A envelope.</summary>
|
||||
public TValue WaveA1 { get; private set; }
|
||||
|
||||
/// <summary>Wave A inner histogram: MACD(8,34) - Signal(34). Smaller A envelope.</summary>
|
||||
public TValue WaveA2 { get; private set; }
|
||||
|
||||
/// <summary>Wave B outer histogram: MACD(8,144) - Signal(144). Larger B envelope.</summary>
|
||||
public TValue WaveB1 { get; private set; }
|
||||
|
||||
/// <summary>Wave B inner histogram: MACD(8,89) - Signal(89). Smaller B envelope.</summary>
|
||||
public TValue WaveB2 { get; private set; }
|
||||
|
||||
/// <summary>Wave C outer histogram: MACD(8,377) - Signal(377). Larger C envelope.</summary>
|
||||
public TValue WaveC1 { get; private set; }
|
||||
|
||||
/// <summary>Wave C inner histogram: MACD(8,233) - Signal(233). Smaller C envelope.</summary>
|
||||
public TValue WaveC2 { get; private set; }
|
||||
|
||||
// ── TOS-compatible convenience properties ───────────────────────
|
||||
|
||||
/// <summary>TOS Wave1 plot: short-term A wave (= WaveA2, channel 1 histogram).</summary>
|
||||
public TValue Wave1 => WaveA2;
|
||||
|
||||
/// <summary>TOS Wave2High: max of long-term C wave histograms.</summary>
|
||||
public double Wave2High => Math.Max(WaveC1.Value, WaveC2.Value);
|
||||
|
||||
/// <summary>TOS Wave2Low: min of long-term C wave histograms.</summary>
|
||||
public double Wave2Low => Math.Min(WaveC1.Value, WaveC2.Value);
|
||||
|
||||
/// <summary>Primary output = Wave1 (A wave inner, matching TOS default).</summary>
|
||||
public TValue Last => Wave1;
|
||||
|
||||
/// <summary>Reactive event publisher.</summary>
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TTM Wave indicator with canonical Fibonacci periods.
|
||||
/// </summary>
|
||||
public TtmWave()
|
||||
{
|
||||
_macd1 = new Macd(FastPeriod, Slow1, Slow1);
|
||||
_macd2 = new Macd(FastPeriod, Slow2, Slow2);
|
||||
_macd3 = new Macd(FastPeriod, Slow3, Slow3);
|
||||
_macd4 = new Macd(FastPeriod, Slow4, Slow4);
|
||||
_macd5 = new Macd(FastPeriod, Slow5, Slow5);
|
||||
_macd6 = new Macd(FastPeriod, Slow6, Slow6);
|
||||
_handler = Handle;
|
||||
|
||||
Name = "TtmWave";
|
||||
// Warmup = max channel warmup = max(8, 377) + 377 - 2 = 752
|
||||
WarmupPeriod = Math.Max(FastPeriod, Slow6) + Slow6 - 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TTM Wave indicator chained to a source publisher.
|
||||
/// </summary>
|
||||
public TtmWave(ITValuePublisher source) : this()
|
||||
{
|
||||
_source = source;
|
||||
_source.Pub += _handler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_source != null)
|
||||
{
|
||||
_source.Pub -= _handler;
|
||||
}
|
||||
_macd1.Dispose();
|
||||
_macd2.Dispose();
|
||||
_macd3.Dispose();
|
||||
_macd4.Dispose();
|
||||
_macd5.Dispose();
|
||||
_macd6.Dispose();
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resets all internal state.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_macd1.Reset();
|
||||
_macd2.Reset();
|
||||
_macd3.Reset();
|
||||
_macd4.Reset();
|
||||
_macd5.Reset();
|
||||
_macd6.Reset();
|
||||
WaveA1 = default;
|
||||
WaveA2 = default;
|
||||
WaveB1 = default;
|
||||
WaveB2 = default;
|
||||
WaveC1 = default;
|
||||
WaveC2 = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new value.
|
||||
/// </summary>
|
||||
/// <param name="input">Price value (typically close).</param>
|
||||
/// <param name="isNew">True for new bar; false for current bar update.</param>
|
||||
/// <returns>Primary output (Wave1 = A wave inner histogram).</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
// Feed all six MACD channels — each handles isNew rollback internally
|
||||
_macd1.Update(input, isNew);
|
||||
_macd2.Update(input, isNew);
|
||||
_macd3.Update(input, isNew);
|
||||
_macd4.Update(input, isNew);
|
||||
_macd5.Update(input, isNew);
|
||||
_macd6.Update(input, isNew);
|
||||
|
||||
// Extract histogram values and compose wave outputs
|
||||
// thinkScript mapping: WaveA1 = hist2 (outer), WaveA2 = hist1 (inner)
|
||||
WaveA1 = new TValue(input.Time, _macd2.Histogram.Value);
|
||||
WaveA2 = new TValue(input.Time, _macd1.Histogram.Value);
|
||||
WaveB1 = new TValue(input.Time, _macd4.Histogram.Value);
|
||||
WaveB2 = new TValue(input.Time, _macd3.Histogram.Value);
|
||||
WaveC1 = new TValue(input.Time, _macd6.Histogram.Value);
|
||||
WaveC2 = new TValue(input.Time, _macd5.Histogram.Value);
|
||||
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch-processes an entire series.
|
||||
/// </summary>
|
||||
public TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
tSpan[i] = source[i].Time;
|
||||
vSpan[i] = Last.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primes the indicator with historical data without producing output.
|
||||
/// </summary>
|
||||
public void Prime(TSeries source)
|
||||
{
|
||||
Reset();
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static batch calculation with default parameters.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source)
|
||||
{
|
||||
var indicator = new TtmWave();
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static calculation returning both results and the warm indicator.
|
||||
/// </summary>
|
||||
public static (TSeries Results, TtmWave Indicator) Calculate(TSeries source)
|
||||
{
|
||||
var indicator = new TtmWave();
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs args)
|
||||
{
|
||||
Update(args.Value, args.IsNew);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,170 @@
|
||||
# TTM_WAVE: TTM Wave
|
||||
|
||||
> **Pending Implementation** - Placeholder for John Carter's TTM Wave indicator
|
||||
> "The market speaks in waves. Most traders only hear the ripples." -- John Carter
|
||||
|
||||
## Introduction
|
||||
|
||||
TTM Wave is a multi-period MACD composite oscillator built from six histogram channels at Fibonacci EMA periods. Each channel computes a standard MACD histogram (fast EMA minus slow EMA, then subtract the signal EMA of that difference). The six channels group into three wave bands -- A (short-term), B (medium-term), C (long-term) -- giving traders a single-pane view of momentum alignment across cycle lengths. When all three bands share the same sign, momentum is unanimous. When they diverge, the market is arguing with itself.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Carter developed TTM Wave as a multi-period MACD composite indicator using Fibonacci-based periods. The indicator displays three "waves" (A, B, C) that help traders identify the alignment of multiple timeframes and the strength of momentum across different cycle lengths.
|
||||
John Carter introduced TTM Wave in *Mastering the Trade* (2005, revised 2012) as part of his "Trade The Markets" (TTM) suite alongside TTM Squeeze and TTM Trend. The indicator descends from Gerald Appel's MACD (1979) but extends it by running six parallel MACD channels whose periods follow the Fibonacci sequence: 8, 34, 55, 89, 144, 233, 377.
|
||||
|
||||
## Algorithm
|
||||
The design philosophy is straightforward: a single MACD channel captures momentum at one timescale. Stack six of them and you get a momentum spectrum. When short-term waves (A) fire first and medium/long-term waves (B, C) follow suit, the trend has legs. When A waves reverse while C waves persist, you are looking at a pullback, not a reversal.
|
||||
|
||||
### Wave A (Short-term momentum)
|
||||
```
|
||||
Wave_A1 = EMA(close, 8) - EMA(close, 34)
|
||||
Wave_A2 = EMA(Wave_A1, 34)
|
||||
```
|
||||
Carter's original implementation appeared as thinkScript studies on the thinkorswim platform. The `TTM_Wave_A`, `TTM_Wave_B`, and `TTM_Wave_C` studies each contribute two histogram plots. Our implementation unifies all six channels into a single class with named outputs matching the TOS convention.
|
||||
|
||||
### Wave B (Medium-term momentum)
|
||||
```
|
||||
Wave_B1 = EMA(close, 8) - EMA(close, 55)
|
||||
Wave_B2 = EMA(Wave_B1, 55)
|
||||
```
|
||||
No external TA libraries (Skender, TA-Lib, Tulip, OoplesFinance) implement TTM Wave, making this a first-principles implementation validated through self-consistency tests.
|
||||
|
||||
### Wave C (Long-term momentum using Fibonacci periods)
|
||||
```
|
||||
e1 = EMA(close, 34)
|
||||
e2 = EMA(close, 55)
|
||||
e3 = EMA(close, 89)
|
||||
e4 = EMA(close, 144)
|
||||
e5 = EMA(close, 233)
|
||||
e6 = EMA(close, 377)
|
||||
## Architecture and Physics
|
||||
|
||||
Wave_C = e1 + e2 + e3 + e4 + e5 + e6 - 6 * EMA(close, some_avg_period)
|
||||
```
|
||||
### 1. MACD Channel Structure
|
||||
|
||||
## Fibonacci Periods
|
||||
Each channel *k* computes:
|
||||
|
||||
| Period | Fibonacci |
|
||||
|:-------|:----------|
|
||||
| 8 | F(6) |
|
||||
| 34 | F(9) |
|
||||
| 55 | F(10) |
|
||||
| 89 | F(11) |
|
||||
| 144 | F(12) |
|
||||
| 233 | F(13) |
|
||||
| 377 | F(14) |
|
||||
$$\text{MACD}_k = \text{EMA}(\text{close}, 8) - \text{EMA}(\text{close}, S_k)$$
|
||||
|
||||
## Outputs
|
||||
$$\text{Signal}_k = \text{EMA}(\text{MACD}_k, S_k)$$
|
||||
|
||||
| Output | Type | Description |
|
||||
|:-------|:-----|:------------|
|
||||
| WaveA | double | Fast momentum oscillator (red/magenta histogram) |
|
||||
| WaveB | double | Medium momentum oscillator (dark red/magenta histogram) |
|
||||
| WaveC | double | Slow momentum composite (blue histogram) |
|
||||
$$\text{Histogram}_k = \text{MACD}_k - \text{Signal}_k$$
|
||||
|
||||
## Trading Interpretation
|
||||
where the slow/signal period $S_k$ takes Fibonacci values:
|
||||
|
||||
1. **All waves aligned:** Strong trend - ride the move
|
||||
2. **Wave A diverges from C:** Early warning of potential reversal
|
||||
3. **Waves crossing zero:** Momentum shift in progress
|
||||
4. **Wave C color change:** Major cycle direction changing
|
||||
| Channel | $S_k$ | Wave Group |
|
||||
| :------ | :----- | :--------- |
|
||||
| 1 | 34 | A (inner) |
|
||||
| 2 | 55 | A (outer) |
|
||||
| 3 | 89 | B (inner) |
|
||||
| 4 | 144 | B (outer) |
|
||||
| 5 | 233 | C (inner) |
|
||||
| 6 | 377 | C (outer) |
|
||||
|
||||
## Category
|
||||
All channels share fast period $F = 8$ (Fibonacci $F_6$).
|
||||
|
||||
**Oscillators** - Multi-period momentum composite oscillating around zero line.
|
||||
### 2. Wave Grouping
|
||||
|
||||
The six histograms map to three wave bands, each containing an inner (smaller period) and outer (larger period) envelope:
|
||||
|
||||
- **Wave A** (short-term momentum): channels 1 and 2
|
||||
- **Wave B** (medium-term momentum): channels 3 and 4
|
||||
- **Wave C** (long-term momentum): channels 5 and 6
|
||||
|
||||
Within each group, the inner channel reacts faster, the outer channel slower. When the inner crosses zero before the outer, momentum is accelerating at that timescale.
|
||||
|
||||
### 3. TOS Compatibility Mapping
|
||||
|
||||
The thinkorswim platform labels outputs differently:
|
||||
|
||||
| TOS Name | QuanTAlib Property | Definition |
|
||||
| :------- | :----------------- | :--------- |
|
||||
| Wave1 | `Wave1` / `WaveA2` | Channel 1 histogram (8,34,34) |
|
||||
| Wave2High | `Wave2High` | max(WaveC1, WaveC2) |
|
||||
| Wave2Low | `Wave2Low` | min(WaveC1, WaveC2) |
|
||||
|
||||
### 4. Composition Architecture
|
||||
|
||||
`TtmWave` is implemented as a composition of six internal `Macd` instances rather than manual EMA management. This delegates bar correction (`isNew` rollback), NaN handling, and state management to the battle-tested `Macd` class. The tradeoff: six redundant fast EMA computations (all share period 8). The benefit: zero additional state synchronization bugs and trivial maintenance.
|
||||
|
||||
### 5. Warmup Period
|
||||
|
||||
The slowest channel uses periods (8, 377, 377). The MACD warmup for that channel is:
|
||||
|
||||
$$W = \max(8, 377) + 377 - 2 = 752$$
|
||||
|
||||
All channels are hot once the slowest is hot. `IsHot` is the conjunction of all six MACD `IsHot` flags.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### EMA Recursion
|
||||
|
||||
Each EMA with period $P$ uses smoothing factor $\alpha = 2/(P+1)$:
|
||||
|
||||
$$\text{EMA}_t = \alpha \cdot x_t + (1 - \alpha) \cdot \text{EMA}_{t-1}$$
|
||||
|
||||
### MACD Line
|
||||
|
||||
$$M_t = \text{EMA}(x, 8)_t - \text{EMA}(x, S_k)_t$$
|
||||
|
||||
### Signal Line
|
||||
|
||||
$$\text{Sig}_t = \text{EMA}(M, S_k)_t$$
|
||||
|
||||
### Histogram
|
||||
|
||||
$$H_t = M_t - \text{Sig}_t$$
|
||||
|
||||
The histogram is a second-order momentum measure: it tracks the rate of change of the MACD line relative to its own smoothed average. Positive histogram means MACD is above its signal (bullish acceleration); negative means below (bearish acceleration).
|
||||
|
||||
### Z-Domain Transfer Function
|
||||
|
||||
For a single channel with fast period $F$ and slow period $S$:
|
||||
|
||||
$$H(z) = \left[\frac{\alpha_F}{1-(1-\alpha_F)z^{-1}} - \frac{\alpha_S}{1-(1-\alpha_S)z^{-1}}\right] \cdot \left[1 - \frac{\alpha_S}{1-(1-\alpha_S)z^{-1}}\right]$$
|
||||
|
||||
where $\alpha_F = 2/(F+1)$ and $\alpha_S = 2/(S+1)$.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Value |
|
||||
| :----- | :---- |
|
||||
| Operations per update | 6 x MACD update (18 EMA updates total) |
|
||||
| Memory | 6 Macd instances with internal state |
|
||||
| Streaming complexity | O(1) per bar |
|
||||
| SIMD applicability | Not applicable (recursive IIR filter) |
|
||||
| Warmup bars | 752 |
|
||||
| Allocations in Update | Zero (struct TValue returns) |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Quality | Score (1-10) | Notes |
|
||||
| :------ | :----------- | :---- |
|
||||
| Trend detection | 8 | Multi-timeframe alignment is powerful |
|
||||
| Noise rejection | 7 | Longer-period channels naturally filter |
|
||||
| Responsiveness | 6 | C waves lag substantially (377 period) |
|
||||
| Divergence signals | 8 | A vs C divergence is the primary signal |
|
||||
| False signal rate | 5 | A waves generate frequent zero crosses |
|
||||
| Computational cost | 4 | Six MACD instances is nontrivial |
|
||||
|
||||
## Validation
|
||||
|
||||
No external libraries implement TTM Wave. Validation relies on self-consistency:
|
||||
|
||||
| Test Category | Method | Result |
|
||||
| :------------ | :----- | :----- |
|
||||
| Streaming vs Batch | All values match to 1e-10 | Pass |
|
||||
| Primed vs Cold | Last value matches to 1e-8 | Pass |
|
||||
| Deterministic replay | Same seed produces identical output | Pass |
|
||||
| Reset + replay | Matches fresh computation to 1e-15 | Pass |
|
||||
| TOS property mapping | Wave1=WaveA2, Wave2High/Low correct | Pass |
|
||||
| Large dataset (5000 bars) | All outputs finite, no overflow | Pass |
|
||||
| Warmup period | IsHot engages at or before bar 752 | Pass |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Confusing wave numbering with channel numbering.** WaveA1 is the *outer* A wave (channel 2, period 55), not channel 1. WaveA2 is the *inner* (channel 1, period 34). This matches the TOS naming where larger envelope gets the "1" suffix. Swapping them reverses your interpretation of momentum acceleration.
|
||||
|
||||
2. **Expecting C waves to react to short-term moves.** Channel 6 (period 377) needs roughly 752 bars to warm up and responds glacially to price changes. A sudden $5\%$ move barely registers on Wave C. Use Wave A for timing, Wave C for bias.
|
||||
|
||||
3. **Trading A wave zero-crosses in isolation.** Wave A zero-crosses fire frequently in choppy markets. Without confirming B/C wave direction, you are trading noise. The indicator's value lies in multi-wave alignment, not single-wave signals.
|
||||
|
||||
4. **Ignoring the warmup period.** With 752 bars needed for full warmup, daily charts require three years of history. On 1-minute charts that is 12.5 hours. Insufficient warmup produces misleading histogram values that can invert actual momentum direction.
|
||||
|
||||
5. **Assuming histogram magnitude implies trend strength.** Longer-period channels naturally produce larger absolute histogram values because the fast-slow EMA spread grows with period. Comparing Wave A magnitude to Wave C magnitude directly is comparing apples to watermelons. Normalize by channel period if you need cross-wave magnitude comparison.
|
||||
|
||||
6. **Not accounting for bar correction.** When the current bar updates (same timestamp), all six channels must roll back to their previous state. The composition architecture handles this via `isNew=false` propagation to each internal Macd, but custom implementations that skip bar correction will accumulate state errors.
|
||||
|
||||
7. **Over-optimizing by sharing the fast EMA.** All six channels use fast period 8, so sharing one EMA(8) instance seems logical. However, the MACD class manages internal state atomically (previous state rollback). Sharing the fast EMA across channels breaks independent bar correction. The redundant computation costs microseconds; the correctness cost of sharing would be debugging hours.
|
||||
|
||||
## References
|
||||
|
||||
- Carter, J. (2012). *Mastering the Trade: Proven Techniques for Profiting from Intraday and Swing Trading Setups* (2nd ed.). McGraw-Hill.
|
||||
- Appel, G. (1979). *The Moving Average Convergence-Divergence Trading Method*. Signalert Corporation.
|
||||
- thinkorswim TTM_Wave_A, TTM_Wave_B, TTM_Wave_C thinkScript studies.
|
||||
- useThinkScript community analysis of TTM Wave internals.
|
||||
|
||||
## See Also
|
||||
|
||||
- [MACD: Moving Average Convergence Divergence](../../momentum/macd/Macd.md)
|
||||
- [AO: Awesome Oscillator](../ao/Ao.md)
|
||||
- [TTM_SQUEEZE: TTM Squeeze](../../dynamics/ttm_squeeze/TtmSqueeze.md)
|
||||
- [TTM_TREND: TTM Trend](../../dynamics/ttm_trend/TtmTrend.md)
|
||||
- [AO: Awesome Oscillator](../ao/Ao.md)
|
||||
|
||||
Reference in New Issue
Block a user