more volatilty

This commit is contained in:
Miha Kralj
2026-02-02 13:42:47 -08:00
parent dde19f2226
commit a03d7aa0ce
89 changed files with 21551 additions and 438 deletions
@@ -0,0 +1,209 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class MassiIndicatorTests
{
[Fact]
public void MassiIndicator_Constructor_SetsDefaults()
{
var indicator = new MassiIndicator();
Assert.Equal(9, indicator.EmaLength);
Assert.Equal(25, indicator.SumLength);
Assert.True(indicator.ShowColdValues);
Assert.Equal("MASSI - Mass Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void MassiIndicator_ShortName_IncludesParameters()
{
var indicator = new MassiIndicator { EmaLength = 10, SumLength = 30 };
Assert.Contains("MASSI", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void MassiIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new MassiIndicator();
Assert.Equal(0, MassiIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void MassiIndicator_Initialize_CreatesInternalMassi()
{
var indicator = new MassiIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void MassiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MassiIndicator { EmaLength = 5, SumLength = 10 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void MassiIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new MassiIndicator { EmaLength = 5, SumLength = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; 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(50), 160, 168, 155, 165, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void MassiIndicator_DifferentParameters_Work()
{
int[] emaLengths = { 5, 9, 14 };
int[] sumLengths = { 10, 25, 50 };
foreach (var emaLen in emaLengths)
{
foreach (var sumLen in sumLengths)
{
var indicator = new MassiIndicator { EmaLength = emaLen, SumLength = sumLen };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 80; 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 val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"MASSI({emaLen},{sumLen}) should produce finite value");
}
}
}
[Fact]
public void MassiIndicator_Parameters_CanBeChanged()
{
var indicator = new MassiIndicator();
Assert.Equal(9, indicator.EmaLength);
Assert.Equal(25, indicator.SumLength);
indicator.EmaLength = 12;
indicator.SumLength = 30;
Assert.Equal(12, indicator.EmaLength);
Assert.Equal(30, indicator.SumLength);
}
[Fact]
public void MassiIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new MassiIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void MassiIndicator_SourceCodeLink_IsValid()
{
var indicator = new MassiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Massi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void MassiIndicator_TypicalRange_AroundSumLength()
{
// With sumLength=25, MASSI typically hovers around 25 (sum of ratios ~1.0 each)
var indicator = new MassiIndicator { EmaLength = 9, SumLength = 25 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Use consistent range data
for (int i = 0; i < 100; i++)
{
double basePrice = 100;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 10, basePrice - 10, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
// With stable range, ratios approach 1.0, so sum approaches sumLength (25)
Assert.True(val > 20 && val < 30, $"MASSI value {val} should be near 25 for stable data");
}
[Fact]
public void MassiIndicator_UsesHighLowRange()
{
var indicator = new MassiIndicator { EmaLength = 5, SumLength = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Small range bars
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double smallRangeVal = indicator.LinesSeries[0].GetValue(0);
// Reset and use large range bars
var indicator2 = new MassiIndicator { EmaLength = 5, SumLength = 10 };
indicator2.Initialize();
for (int i = 0; i < 30; i++)
{
indicator2.HistoricalData.AddBar(now.AddMinutes(i), 100, 120, 80, 100, 1000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double largeRangeVal = indicator2.LinesSeries[0].GetValue(0);
// Both should produce valid values (MASSI is about ratio patterns, not absolute range)
Assert.True(double.IsFinite(smallRangeVal));
Assert.True(double.IsFinite(largeRangeVal));
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// Quantower adapter for MASSI (Mass Index) indicator.
/// </summary>
[SkipLocalsInit]
public sealed class MassiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("EMA Length", sortIndex: 1, 1, 100, 1, 0)]
public int EmaLength { get; set; } = 9;
[InputParameter("Sum Length", sortIndex: 2, 1, 100, 1, 0)]
public int SumLength { get; set; } = 25;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Massi _massi = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"MASSI {EmaLength},{SumLength}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/massi/Massi.Quantower.cs";
public MassiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "MASSI - Mass Index";
Description = "Mass Index identifies trend reversals by measuring the narrowing and widening of the range between high and low prices. A 'reversal bulge' occurs when MASSI rises above 27 and then drops below 26.5.";
_series = new LineSeries(name: "MASSI", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_massi = new Massi(EmaLength, SumLength);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
var bar = new TBar(
item.TimeLeft.Ticks,
item[PriceType.Open],
item[PriceType.High],
item[PriceType.Low],
item[PriceType.Close],
item[PriceType.Volume]
);
TValue result = _massi.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _massi.IsHot, ShowColdValues);
}
}
+727
View File
@@ -0,0 +1,727 @@
namespace QuanTAlib.Tests;
public class MassiTests
{
private const double Tolerance = 1e-9;
private static TBarSeries GenerateTestBars(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
private static TSeries GenerateRangeData(int count = 100)
{
var bars = GenerateTestBars(count);
var series = new TSeries(count);
for (int i = 0; i < bars.Count; i++)
{
series.Add(new TValue(bars[i].Time, bars[i].High - bars[i].Low));
}
return series;
}
// ============== Constructor & Parameter Validation ==============
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Massi(0, 25));
Assert.Throws<ArgumentOutOfRangeException>(() => new Massi(-1, 25));
Assert.Throws<ArgumentOutOfRangeException>(() => new Massi(9, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Massi(9, -1));
var massi = new Massi(9, 25);
Assert.NotNull(massi);
}
[Fact]
public void Constructor_SetsCorrectName()
{
var massi = new Massi(9, 25);
Assert.Equal("Massi(9,25)", massi.Name);
Assert.True(massi.WarmupPeriod > 0);
var massi2 = new Massi(5, 10);
Assert.Equal("Massi(5,10)", massi2.Name);
}
[Fact]
public void Constructor_SetsCorrectWarmup()
{
var massi = new Massi(9, 25);
// Warmup = emaLength + sumLength
Assert.Equal(34, massi.WarmupPeriod);
}
// ============== Basic Functionality ==============
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(100);
foreach (var bar in bars)
{
massi.Update(bar);
}
Assert.True(double.IsFinite(massi.Last.Value));
}
[Fact]
public void Calc_ReturnsValidValue()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
foreach (var bar in bars)
{
var result = massi.Update(bar);
Assert.True(double.IsFinite(result.Value) || double.IsNaN(result.Value));
}
}
[Fact]
public void Properties_Accessible()
{
var massi = new Massi(9, 25);
Assert.False(massi.IsHot);
Assert.Contains("Massi", massi.Name, StringComparison.Ordinal);
Assert.Equal(34, massi.WarmupPeriod);
var bars = GenerateTestBars(50);
foreach (var bar in bars)
{
massi.Update(bar);
}
// After warmup, properties should be valid
Assert.True(double.IsFinite(massi.Ema1));
Assert.True(double.IsFinite(massi.Ema2));
Assert.True(double.IsFinite(massi.Ratio));
}
[Fact]
public void EmaProperties_Accessible()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
foreach (var bar in bars)
{
massi.Update(bar);
}
// Ema1 and Ema2 should be positive (ranges are positive)
Assert.True(massi.Ema1 >= 0);
Assert.True(massi.Ema2 >= 0);
// Ratio should be close to 1 normally
Assert.True(massi.Ratio >= 0);
}
[Fact]
public void Ratio_IsCloseToOne()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(100);
foreach (var bar in bars)
{
massi.Update(bar);
}
// EMA1/EMA2 ratio typically oscillates around 1
// For stable conditions, should be between 0.5 and 2.0
Assert.True(massi.Ratio > 0.5 && massi.Ratio < 2.0,
$"Ratio {massi.Ratio} outside expected range");
}
// ============== State Management & Bar Correction ==============
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
// Feed enough values to build up state
for (int i = 0; i < 49; i++)
{
massi.Update(bars[i], isNew: true);
}
double valueBefore = massi.Last.Value;
// Add one more value with isNew=true
massi.Update(bars[49], isNew: true);
double valueAfter = massi.Last.Value;
// Both should be valid
Assert.True(double.IsFinite(valueBefore));
Assert.True(double.IsFinite(valueAfter));
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
// Feed enough bars
for (int i = 0; i < 49; i++)
{
massi.Update(bars[i], isNew: true);
}
// Add one more value with isNew=true
massi.Update(bars[49], isNew: true);
double beforeUpdate = massi.Last.Value;
// Update same bar with different value (isNew=false)
var modifiedBar = new TBar(bars[49].Time, bars[49].Open, bars[49].High + 5,
bars[49].Low - 5, bars[49].Close, bars[49].Volume);
massi.Update(modifiedBar, isNew: false);
double afterUpdate = massi.Last.Value;
// Values should be different after the correction (wider range)
Assert.True(Math.Abs(beforeUpdate - afterUpdate) > Tolerance);
}
[Fact]
public void IsNew_Consistency()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(100);
// Feed first 99
for (int i = 0; i < 99; i++)
{
massi.Update(bars[i]);
}
// Update with 100th bar (isNew=true)
massi.Update(bars[99], true);
// Update with modified 100th bar (isNew=false)
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 5,
bars[99].Low - 5, bars[99].Close, bars[99].Volume);
double val2 = massi.Update(modifiedBar, false).Value;
// Create new instance and feed up to modified
var massi2 = new Massi(9, 25);
for (int i = 0; i < 99; i++)
{
massi2.Update(bars[i]);
}
double val3 = massi2.Update(modifiedBar, true).Value;
Assert.Equal(val3, val2, Tolerance);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var massi = new Massi(5, 10);
var bars = GenerateTestBars(20);
// Feed 10 new values
TBar tenthBar = default;
for (int i = 0; i < 10; i++)
{
tenthBar = bars[i];
massi.Update(tenthBar, isNew: true);
}
// Remember state after 10 values
double stateAfterTen = massi.Last.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 10; i < 19; i++)
{
massi.Update(bars[i], isNew: false);
}
// Feed the remembered 10th bar again with isNew=false
TValue finalResult = massi.Update(tenthBar, isNew: false);
// State should match the original state after 10 values
Assert.Equal(stateAfterTen, finalResult.Value, Tolerance);
}
[Fact]
public void Reset_Works()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
foreach (var bar in bars)
{
massi.Update(bar);
}
massi.Reset();
Assert.False(massi.IsHot);
Assert.Equal(0.0, massi.Ema1, Tolerance);
Assert.Equal(0.0, massi.Ema2, Tolerance);
Assert.Equal(0.0, massi.Ratio, Tolerance);
}
// ============== Warmup & Convergence ==============
[Fact]
public void IsHot_BecomesTrueAfterWarmup()
{
var massi = new Massi(9, 25);
Assert.False(massi.IsHot);
var bars = GenerateTestBars(50);
int steps = 0;
while (!massi.IsHot && steps < bars.Count)
{
massi.Update(bars[steps]);
steps++;
}
Assert.True(massi.IsHot);
Assert.Equal(34, steps); // emaLength + sumLength
}
[Fact]
public void WarmupPeriod_IsPositive()
{
var massi = new Massi(9, 25);
Assert.Equal(34, massi.WarmupPeriod);
var massi2 = new Massi(5, 10);
Assert.Equal(15, massi2.WarmupPeriod);
}
// ============== NaN/Infinity Handling ==============
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var massi = new Massi(5, 10);
var bars = GenerateTestBars(20);
// Feed some valid bars
for (int i = 0; i < 15; i++)
{
massi.Update(bars[i]);
}
// Feed NaN value via TValue (range input)
var inputWithNaN = new TValue(DateTime.UtcNow.AddMinutes(20), double.NaN);
var resultAfterNaN = massi.Update(inputWithNaN);
// Result should be finite
Assert.True(double.IsFinite(resultAfterNaN.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var massi = new Massi(5, 10);
var bars = GenerateTestBars(20);
// Feed some valid bars
for (int i = 0; i < 15; i++)
{
massi.Update(bars[i]);
}
// Feed Infinity value
var inputWithInf = new TValue(DateTime.UtcNow.AddMinutes(20), double.PositiveInfinity);
var resultAfterInf = massi.Update(inputWithInf);
// Result should be finite
Assert.True(double.IsFinite(resultAfterInf.Value));
}
[Fact]
public void NegativeRange_UsesLastValidValue()
{
var massi = new Massi(5, 10);
var bars = GenerateTestBars(20);
// Feed some valid bars
for (int i = 0; i < 15; i++)
{
massi.Update(bars[i]);
}
// Feed negative range value (invalid)
var inputWithNeg = new TValue(DateTime.UtcNow.AddMinutes(20), -5.0);
var resultAfterNeg = massi.Update(inputWithNeg);
// Result should be finite
Assert.True(double.IsFinite(resultAfterNeg.Value));
}
[Fact]
public void BatchNaN_Safe()
{
var massi = new Massi(5, 10);
var bars = GenerateTestBars(20);
// Feed some values
for (int i = 0; i < 15; i++)
{
massi.Update(bars[i]);
}
// Feed multiple NaN values
for (int i = 0; i < 5; i++)
{
var nanInput = new TValue(DateTime.UtcNow.AddMinutes(15 + i), double.NaN);
var result = massi.Update(nanInput);
Assert.True(double.IsFinite(result.Value));
}
}
// ============== Consistency Tests ==============
[Fact]
public void TBarSeries_MatchesIterativeCalc()
{
var massiIterative = new Massi(9, 25);
var bars = GenerateTestBars(100);
// Calculate iteratively
foreach (var bar in bars)
{
massiIterative.Update(bar);
}
// Calculate batch
var massiBatch = new Massi(9, 25);
_ = massiBatch.Update(bars);
// Compare last values
Assert.Equal(massiIterative.Last.Value, massiBatch.Last.Value, Tolerance);
}
[Fact]
public void TSeries_Update_MatchesStreaming()
{
var massi1 = new Massi(9, 25);
var massi2 = new Massi(9, 25);
var series = GenerateRangeData(100);
// Streaming
foreach (var value in series)
{
massi1.Update(value);
}
// Batch
massi2.Update(series);
Assert.Equal(massi1.Last.Value, massi2.Last.Value, Tolerance);
}
[Fact]
public void SpanCalc_MatchesStreaming()
{
var massi = new Massi(9, 25);
var series = GenerateRangeData(100);
// Stream all values first
foreach (var value in series)
{
massi.Update(value);
}
double streamingLast = massi.Last.Value;
// Span calculation
var output = new double[series.Count];
Massi.Calculate(series.Values, output, 9, 25);
// Compare last value
Assert.Equal(streamingLast, output[series.Count - 1], 1e-6);
}
[Fact]
public void Chainability_Works()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(50);
var result = massi.Update(bars);
Assert.Equal(50, result.Count);
Assert.Equal(massi.Last.Value, result.Last.Value);
}
// ============== Mass Index Specific Tests ==============
[Fact]
public void Massi_TypicalRange()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(200);
foreach (var bar in bars)
{
massi.Update(bar);
}
// Mass Index sum typically ranges around 25 (sumLength * 1.0 ratio)
// During normal conditions, expect 20-30 range
Assert.True(massi.Last.Value > 10, $"MASSI {massi.Last.Value} unexpectedly low");
Assert.True(massi.Last.Value < 40, $"MASSI {massi.Last.Value} unexpectedly high");
}
[Fact]
public void Massi_SumLengthAffectsOutput()
{
var massi10 = new Massi(9, 10);
var massi25 = new Massi(9, 25);
var massi50 = new Massi(9, 50);
var bars = GenerateTestBars(100);
foreach (var bar in bars)
{
massi10.Update(bar);
massi25.Update(bar);
massi50.Update(bar);
}
// Longer sumLength should produce larger output (more terms summed)
Assert.True(massi25.Last.Value > massi10.Last.Value,
$"MASSI(9,25)={massi25.Last.Value} should be > MASSI(9,10)={massi10.Last.Value}");
Assert.True(massi50.Last.Value > massi25.Last.Value,
$"MASSI(9,50)={massi50.Last.Value} should be > MASSI(9,25)={massi25.Last.Value}");
}
// ============== Static Batch Methods ==============
[Fact]
public void StaticBatch_TBarSeries_Works()
{
var bars = GenerateTestBars(50);
var results = Massi.Batch(bars, 9, 25);
Assert.Equal(50, results.Count);
Assert.True(double.IsFinite(results.Last.Value));
}
[Fact]
public void StaticBatch_TSeries_Works()
{
var series = GenerateRangeData(50);
var results = Massi.Batch(series, 9, 25);
Assert.Equal(50, results.Count);
Assert.True(double.IsFinite(results.Last.Value));
}
// ============== Span API Tests ==============
[Fact]
public void Calculate_ValidatesLengths()
{
var source = new double[10];
var output = new double[5]; // Wrong size
var ex = Assert.Throws<ArgumentException>(() => Massi.Calculate(source, output, 9, 25));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Calculate_EmptySource_NoException()
{
var source = Array.Empty<double>();
var output = Array.Empty<double>();
var exception = Record.Exception(() => Massi.Calculate(source, output, 9, 25));
Assert.Null(exception);
}
[Fact]
public void Calculate_InvalidParams_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
Assert.Throws<ArgumentOutOfRangeException>(() => Massi.Calculate(source, output, 0, 25));
Assert.Throws<ArgumentOutOfRangeException>(() => Massi.Calculate(source, output, 9, 0));
}
// ============== Edge Cases ==============
[Fact]
public void SingleValue_ReturnsValue()
{
var massi = new Massi(9, 25);
var bar = GenerateTestBars(1)[0];
var result = massi.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Period1_Works()
{
var massi = new Massi(1, 1);
var bars = GenerateTestBars(10);
foreach (var bar in bars)
{
var result = massi.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void FlatRange_ProducesStableOutput()
{
var massi = new Massi(9, 25);
// All bars have same range
for (int i = 0; i < 50; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 101.0, 99.0, 100.5, 1000.0);
massi.Update(bar);
}
// Output should be approximately sumLength (25) since ratio ≈ 1
Assert.True(Math.Abs(massi.Last.Value - 25.0) < 1.0,
$"MASSI {massi.Last.Value} should be close to 25 for flat range");
}
// ============== Event Publishing ==============
[Fact]
public void PubEvent_Fires()
{
var massi = new Massi(9, 25);
bool eventFired = false;
massi.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
var bar = GenerateTestBars(1)[0];
massi.Update(bar);
Assert.True(eventFired);
}
[Fact]
public void EventChaining_Works()
{
var bars = GenerateTestBars(50);
var massi = new Massi(9, 25);
var sma = new Sma(massi, 5); // Chain SMA to MASSI output
foreach (var bar in bars)
{
massi.Update(bar);
}
Assert.True(double.IsFinite(sma.Last.Value));
}
// ============== Additional Tests ==============
[Fact]
public void LargeDataset_Completes()
{
var massi = new Massi(9, 25);
var bars = GenerateTestBars(5000);
foreach (var bar in bars)
{
massi.Update(bar);
}
Assert.True(massi.IsHot);
Assert.True(double.IsFinite(massi.Last.Value));
}
[Fact]
public void DifferentParameters_ProduceValidValues()
{
var bars = GenerateTestBars(200);
var massi1 = new Massi(5, 10);
var massi2 = new Massi(9, 25);
var massi3 = new Massi(15, 50);
foreach (var bar in bars)
{
massi1.Update(bar);
massi2.Update(bar);
massi3.Update(bar);
}
Assert.True(double.IsFinite(massi1.Last.Value));
Assert.True(double.IsFinite(massi2.Last.Value));
Assert.True(double.IsFinite(massi3.Last.Value));
}
[Fact]
public void SourceChaining_Works()
{
var bars = GenerateTestBars(200);
// Create source TSeries that publishes events
var sourceSeries = new TSeries();
var massi = new Massi(sourceSeries, 9, 25);
// Feed data through the source (as range values)
foreach (var bar in bars)
{
sourceSeries.Add(new TValue(bar.Time, bar.High - bar.Low));
}
// Should have valid output
Assert.True(double.IsFinite(massi.Last.Value));
}
#pragma warning disable S2699 // Test contains Assert.True and Assert.InRange - analyzer false positive
[Fact]
public void Prime_Works()
{
var massi = new Massi(5, 10);
var values = new double[] { 1.0, 1.1, 0.9, 1.2, 0.8, 1.3, 1.0, 1.1, 0.95, 1.05 };
massi.Prime(values);
double lastValue = massi.Last.Value;
Assert.True(double.IsFinite(lastValue), "Last value should be finite after Prime");
}
#pragma warning restore S2699
[Fact]
public void TValueUpdate_TreatsValueAsRange()
{
var massi1 = new Massi(9, 25);
var massi2 = new Massi(9, 25);
var bars = GenerateTestBars(50);
// Update with TBar
foreach (var bar in bars)
{
massi1.Update(bar);
}
// Update with TValue (pre-calculated range)
foreach (var bar in bars)
{
var range = bar.High - bar.Low;
massi2.Update(new TValue(bar.Time, range));
}
// Both should produce same result
Assert.Equal(massi1.Last.Value, massi2.Last.Value, Tolerance);
}
}
+385
View File
@@ -0,0 +1,385 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MASSI: Mass Index
/// </summary>
/// <remarks>
/// Developed by Donald Dorsey to identify trend reversals by measuring the narrowing
/// and widening of the range between high and low prices. A "reversal bulge" occurs
/// when Mass Index rises above 27 and then drops below 26.5.
///
/// Calculation:
/// 1. EMA1 = EMA(High - Low, emaLength)
/// 2. EMA2 = EMA(EMA1, emaLength) (double-smoothed)
/// 3. Ratio = EMA1 / EMA2
/// 4. MASSI = Sum(Ratio, sumLength)
///
/// Default: emaLength=9, sumLength=25 → typical MASSI(9,25).
/// </remarks>
/// <seealso href="Massi.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Massi : AbstractBase
{
private const double COMPENSATOR_THRESHOLD = 1e-10;
private readonly double _alpha;
private readonly double _decay;
private readonly RingBuffer _sumBuffer;
private readonly TValuePublishedHandler _handler;
private readonly ITValuePublisher? _source;
private State _s;
private State _ps;
[StructLayout(LayoutKind.Auto)]
private record struct State
{
// EMA states (raw, uncompensated accumulators)
public double Ema1Raw;
public double Ema2Raw;
public double E; // compensation factor (decays to 0)
public bool IsCompensated; // true when E <= threshold
// Last valid price range (for NaN handling)
public double LastRange;
// Bar counter
public int Bars;
}
/// <summary>
/// Gets the current EMA1 value (smoothed range).
/// </summary>
public double Ema1 { get; private set; }
/// <summary>
/// Gets the current EMA2 value (double-smoothed range).
/// </summary>
public double Ema2 { get; private set; }
/// <summary>
/// Gets the current ratio (EMA1/EMA2).
/// </summary>
public double Ratio { get; private set; }
public override bool IsHot => _s.Bars >= WarmupPeriod;
/// <summary>
/// Creates MASSI with specified parameters.
/// </summary>
/// <param name="emaLength">Period for EMA smoothing of High-Low range (default: 9)</param>
/// <param name="sumLength">Period for summing the EMA ratio (default: 25)</param>
public Massi(int emaLength = 9, int sumLength = 25)
{
if (emaLength < 1)
{
throw new ArgumentOutOfRangeException(nameof(emaLength), "EMA length must be >= 1.");
}
if (sumLength < 1)
{
throw new ArgumentOutOfRangeException(nameof(sumLength), "Sum length must be >= 1.");
}
_alpha = 2.0 / (emaLength + 1);
_decay = 1.0 - _alpha;
_sumBuffer = new RingBuffer(sumLength);
_handler = Handle;
// Warmup: need enough bars to fill the sum buffer + EMA stabilization
WarmupPeriod = emaLength + sumLength;
Name = $"Massi({emaLength},{sumLength})";
Reset();
}
/// <summary>
/// Creates MASSI with specified source and parameters.
/// </summary>
public Massi(ITValuePublisher source, int emaLength = 9, int sumLength = 25)
: this(emaLength, sumLength)
{
_source = source;
source.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override void Reset()
{
_s = new State { E = 1.0 };
_ps = _s;
_sumBuffer.Clear();
Ema1 = 0;
Ema2 = 0;
Ratio = 0;
Last = default;
}
/// <summary>
/// Updates MASSI with a TBar (OHLCV) input.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
double range = input.High - input.Low;
return UpdateCore(input.Time, range, isNew);
}
/// <summary>
/// Updates MASSI with a TValue input (treats value as pre-calculated range).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return UpdateCore(input.Time, input.Value, isNew);
}
/// <summary>
/// Updates MASSI with a TBarSeries.
/// </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);
Reset();
for (int i = 0; i < len; i++)
{
var bar = source[i];
double range = bar.High - bar.Low;
tSpan[i] = bar.Time;
vSpan[i] = CalculateMassiStep(range);
}
// Sync state
_ps = _s;
_sumBuffer.Snapshot();
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/// <summary>
/// Updates MASSI with a TSeries (assumes values are pre-calculated ranges).
/// </summary>
public override TSeries Update(TSeries 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);
source.Times.CopyTo(tSpan);
Reset();
for (int i = 0; i < len; i++)
{
vSpan[i] = CalculateMassiStep(source.Values[i]);
}
_ps = _s;
_sumBuffer.Snapshot();
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateCore(long time, double range, bool isNew)
{
HandleStateSnapshot(isNew);
// Handle non-finite values
if (!double.IsFinite(range) || range < 0)
{
if (_s.Bars == 0)
{
Last = new TValue(time, double.NaN); // time is already long (ticks)
PubEvent(Last, isNew);
return Last;
}
range = _s.LastRange;
}
else
{
_s.LastRange = range;
}
_s.Bars++;
double massi = CalculateMassi(range);
Last = new TValue(time, massi);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleStateSnapshot(bool isNew)
{
if (isNew)
{
_ps = _s;
_sumBuffer.Snapshot();
}
else
{
_s = _ps;
_sumBuffer.Restore();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateMassi(double range)
{
// Update EMA1 (smoothed range)
_s.Ema1Raw = Math.FusedMultiplyAdd(_s.Ema1Raw, _decay, _alpha * range);
// Update EMA2 (double-smoothed, uses EMA1 raw for continuity)
_s.Ema2Raw = Math.FusedMultiplyAdd(_s.Ema2Raw, _decay, _alpha * _s.Ema1Raw);
// Compensation handling
double ema1, ema2;
if (!_s.IsCompensated)
{
_s.E *= _decay;
if (_s.E <= COMPENSATOR_THRESHOLD)
{
_s.IsCompensated = true;
ema1 = _s.Ema1Raw;
ema2 = _s.Ema2Raw;
}
else
{
double c = 1.0 / (1.0 - _s.E);
ema1 = _s.Ema1Raw * c;
ema2 = _s.Ema2Raw * c;
}
}
else
{
ema1 = _s.Ema1Raw;
ema2 = _s.Ema2Raw;
}
// Store for property access
Ema1 = ema1;
Ema2 = ema2;
// Calculate ratio (avoid division by zero)
double ratio = ema2 > 1e-10 ? ema1 / ema2 : 0.0;
Ratio = ratio;
// Add to rolling sum buffer
_sumBuffer.Add(ratio);
// Return sum of ratios
return _sumBuffer.Sum;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateMassiStep(double range)
{
// Handle non-finite values
if (!double.IsFinite(range) || range < 0)
{
if (_s.Bars == 0)
{
return double.NaN;
}
range = _s.LastRange;
}
else
{
_s.LastRange = range;
}
_s.Bars++;
return CalculateMassi(range);
}
private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew);
protected override void Dispose(bool disposing)
{
if (disposing && _source != null)
{
_source.Pub -= _handler;
}
base.Dispose(disposing);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
Reset();
foreach (var value in source)
{
_ = CalculateMassiStep(value);
}
_ps = _s;
_sumBuffer.Snapshot();
}
/// <summary>
/// Calculates MASSI for the entire TBarSeries using a new instance.
/// </summary>
public static TSeries Batch(TBarSeries source, int emaLength = 9, int sumLength = 25)
{
var massi = new Massi(emaLength, sumLength);
return massi.Update(source);
}
/// <summary>
/// Calculates MASSI for the entire TSeries (ranges) using a new instance.
/// </summary>
public static TSeries Batch(TSeries source, int emaLength = 9, int sumLength = 25)
{
var massi = new Massi(emaLength, sumLength);
return massi.Update(source);
}
/// <summary>
/// Static helper for span-based calculation (assumes input is H-L range).
/// </summary>
public static void Calculate(ReadOnlySpan<double> source, Span<double> output,
int emaLength = 9, int sumLength = 25)
{
if (output.Length != source.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (source.Length == 0)
{
return;
}
var massi = new Massi(emaLength, sumLength);
for (int i = 0; i < source.Length; i++)
{
output[i] = massi.CalculateMassiStep(source[i]);
}
}
}
+208
View File
@@ -0,0 +1,208 @@
# MASSI: Mass Index
> "The Mass Index doesn't predict direction—it predicts the moment of maximum uncertainty before clarity emerges."
The Mass Index, developed by Donald Dorsey and introduced in the June 1992 issue of *Technical Analysis of Stocks & Commodities*, identifies potential trend reversals by measuring the narrowing and widening of the range between high and low prices. Unlike directional indicators, MASSI focuses on the *pattern* of range expansion and contraction, particularly the characteristic "reversal bulge" that often precedes significant market turns.
## Historical Context
Donald Dorsey designed the Mass Index to detect trend reversals without predicting direction. His key insight was that range patterns—specifically, a sequence of widening followed by narrowing—often precede major trend changes. The classic signal occurs when MASSI rises above 27 (indicating expanding volatility) and then drops below 26.5 (indicating consolidation), forming what Dorsey called a "reversal bulge."
The indicator gained popularity because it provides advance warning of potential reversals regardless of whether the subsequent move is up or down. This makes it valuable for traders who want to tighten stops or prepare for volatility shifts.
## Architecture & Physics
### 1. Range Input
The Mass Index uses the High-Low range as its primary input:
$$
\text{Range}_t = \text{High}_t - \text{Low}_t
$$
This measures the bar's trading range—the battlefield between buyers and sellers.
### 2. Double EMA Smoothing
The range undergoes two levels of exponential smoothing:
$$
\text{EMA1}_t = \alpha \cdot \text{Range}_t + (1 - \alpha) \cdot \text{EMA1}_{t-1}
$$
$$
\text{EMA2}_t = \alpha \cdot \text{EMA1}_t + (1 - \alpha) \cdot \text{EMA2}_{t-1}
$$
where $\alpha = \frac{2}{\text{emaLength} + 1}$ (default emaLength = 9).
The double smoothing creates a lagged reference. EMA2 always lags EMA1, so their ratio reveals whether range is currently expanding or contracting relative to its recent average.
### 3. Warmup Compensation
This implementation uses proper warmup compensation to eliminate initialization bias:
$$
e_t = (1 - \alpha) \cdot e_{t-1}, \quad e_0 = 1
$$
When $e_t > 10^{-10}$, apply compensation factor $c = \frac{1}{1 - e_t}$ to both EMAs. This ensures accurate values from the first bar rather than gradual convergence.
### 4. EMA Ratio
The ratio captures the relationship between current and smoothed range:
$$
\text{Ratio}_t = \frac{\text{EMA1}_t}{\text{EMA2}_t}
$$
- Ratio > 1.0: Range is expanding (EMA1 leads EMA2 upward)
- Ratio < 1.0: Range is contracting (EMA1 leads EMA2 downward)
- Ratio ≈ 1.0: Range is stable
### 5. Rolling Sum
The final Mass Index sums the ratios over `sumLength` periods (default 25):
$$
\text{MASSI}_t = \sum_{i=0}^{\text{sumLength}-1} \text{Ratio}_{t-i}
$$
With stable ranges, ratios hover near 1.0, so MASSI hovers near sumLength (25). Deviations indicate systematic expansion or contraction patterns.
## Mathematical Foundation
### EMA Smoothing Coefficient
For emaLength = 9:
$$
\alpha = \frac{2}{9 + 1} = 0.2
$$
$$
\text{decay} = 1 - \alpha = 0.8
$$
### Reversal Bulge Threshold
The classic signal uses fixed thresholds:
- **Setup**: MASSI rises above 27.0
- **Trigger**: MASSI falls below 26.5
With sumLength = 25, this means:
- Above 27: Average ratio > 1.08 (sustained range expansion)
- Below 26.5: Average ratio < 1.06 (range contraction beginning)
### State Variables
The implementation maintains:
- `Ema1Raw`: Uncompensated EMA of range
- `Ema2Raw`: Uncompensated EMA of EMA1
- `E`: Compensation factor (decays toward 0)
- `IsCompensated`: Flag for when E <= 1e-10
- `LastRange`: Last valid range (for NaN handling)
- `Bars`: Bar counter for warmup tracking
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
| :--- | :---: | :--- |
| FMA (EMA1 update) | 1 | `decay * ema1Raw + alpha * range` |
| FMA (EMA2 update) | 1 | `decay * ema2Raw + alpha * ema1Raw` |
| MUL (compensation) | 2 | Only during warmup |
| DIV (ratio) | 1 | EMA1 / EMA2 |
| ADD (rolling sum) | 1 | Buffer manages incremental sum |
| **Total** | **~6** | Per bar after warmup |
### Memory Footprint
- State struct: ~48 bytes
- RingBuffer: sumLength × 8 bytes (200 bytes for default 25)
- **Total per instance**: ~250 bytes
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact algorithm with warmup compensation |
| **Timeliness** | 6/10 | Inherent lag from double EMA + sum window |
| **False Signals** | 7/10 | Reversal bulge is specific but not infallible |
| **Simplicity** | 8/10 | Conceptually straightforward |
| **Actionability** | 7/10 | Clear threshold-based signals |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | ✅ | `MASS` function, matches with proper warmup |
| **Skender** | ✅ | `GetMassIndex`, matches within tolerance |
| **Tulip** | ✅ | `mass` function available |
| **Pine Script** | ✅ | Reference implementation in massi.pine |
## Common Pitfalls
1. **Threshold Rigidity**: The 27/26.5 thresholds were calibrated for Dorsey's original markets. Modern markets may require adjustment. Some practitioners use 26.5/25 or 27.5/27.
2. **No Direction Signal**: MASSI only signals that a reversal may occur, not which direction. Always combine with trend analysis or other directional indicators.
3. **Warmup Period**: Need emaLength + sumLength bars (default: 34) for stable readings. The implementation tracks `IsHot` status.
4. **False Bulges**: Not every bulge above 27 leads to a reversal. The signal works best in conjunction with support/resistance levels or other confirmation.
5. **Range-Only Focus**: MASSI ignores price direction entirely. A stock trending strongly upward with consistent ranges will show stable MASSI readings despite significant price movement.
6. **Parameter Sensitivity**: Shorter emaLength makes the indicator more responsive but noisier. Longer sumLength smooths the output but delays signals.
## Usage Patterns
### Classic Reversal Bulge
```csharp
var massi = new Massi(9, 25);
bool setupTriggered = false;
foreach (var bar in bars)
{
var result = massi.Update(bar);
if (result.Value > 27.0)
setupTriggered = true;
if (setupTriggered && result.Value < 26.5)
{
// Reversal bulge complete - prepare for trend change
setupTriggered = false;
}
}
```
### Batch Processing
```csharp
// From TBarSeries
var massiSeries = Massi.Batch(barSeries, emaLength: 9, sumLength: 25);
// From pre-calculated ranges
var rangeSeries = /* High - Low values */;
var massiSeries = Massi.Batch(rangeSeries);
```
### Span-Based Calculation
```csharp
Span<double> ranges = stackalloc double[500];
Span<double> output = stackalloc double[500];
// Fill ranges with High - Low values
Massi.Calculate(ranges, output, emaLength: 9, sumLength: 25);
```
## References
- Dorsey, Donald. (1992). "The Mass Index." *Technical Analysis of Stocks & Commodities*, June 1992.
- Achelis, Steven B. (2000). *Technical Analysis from A to Z*. McGraw-Hill.
- Pring, Martin J. (2002). *Technical Analysis Explained*. McGraw-Hill.