mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 11:08:05 +00:00
more volatilty
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user