feat: add 8 new indicators with full integration

New indicators:
- HWC (Holt-Winters Channel) — channels, 27 tests
- VWMACD (Volume-Weighted MACD) — momentum, 38 tests
- Squeeze Pro — oscillators, 69 tests
- BW_MFI (Bill Williams MFI) — oscillators
- DSTOCH (Double Stochastic) — oscillators
- ATRSTOP (ATR Trailing Stop) — reversals
- VSTOP (Volatility Stop) — reversals
- Convexity (Beta Convexity) — statistics, 23 tests

Integration:
- Python bridge: Exports.cs, _bridge.py, wrapper modules
- Documentation: _sidebar.md, _index.md pages, SPEC.md
- All analyzer warnings fixed (MA0074, xUnit2013, S2699)

Build: 0 warnings, 0 errors | Tests: 15,933 passed, 0 failed
This commit is contained in:
Miha Kralj
2026-03-17 08:35:29 -07:00
parent 6f0a339c9b
commit 15f4bb90f3
71 changed files with 10194 additions and 44 deletions
@@ -0,0 +1,122 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class BwMfiIndicatorTests
{
[Fact]
public void BwMfiIndicator_Constructor_SetsDefaults()
{
var indicator = new BwMfiIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("BW_MFI - Bill Williams Market Facilitation Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BwMfiIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new BwMfiIndicator();
Assert.Equal(1, BwMfiIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(1, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void BwMfiIndicator_ShortName_IsCorrect()
{
var indicator = new BwMfiIndicator();
indicator.Initialize();
Assert.Equal("BW_MFI", indicator.ShortName);
}
[Fact]
public void BwMfiIndicator_SourceCodeLink_IsValid()
{
var indicator = new BwMfiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("BwMfi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void BwMfiIndicator_Initialize_CreatesTwoLineSeries()
{
var indicator = new BwMfiIndicator();
indicator.Initialize();
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void BwMfiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BwMfiIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double basePrice = 100.0 + i;
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
open: basePrice,
high: basePrice + 5.0,
low: basePrice - 5.0,
close: basePrice + 1.0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double mfiValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(mfiValue));
}
[Fact]
public void BwMfiIndicator_ProcessUpdate_NewBar_UpdatesValue()
{
var indicator = new BwMfiIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 120, 100, 115);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.True(indicator.LinesSeries[0].Count >= 2);
}
[Fact]
public void BwMfiIndicator_ZoneLine_HasValues()
{
var indicator = new BwMfiIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
double basePrice = 100.0 + i;
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
open: basePrice,
high: basePrice + 5.0 + i,
low: basePrice - 5.0,
close: basePrice + 1.0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double zoneValue = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(zoneValue));
}
}
+446
View File
@@ -0,0 +1,446 @@
using System.Runtime.CompilerServices;
using Xunit;
namespace QuanTAlib.Tests;
public sealed class BwMfiTests
{
private readonly GBM _gbm = new(100.0, 0.05, 0.2, seed: 42);
private const double Tolerance = 1e-10;
// ── A) Constructor validation ─────────────────────────────────────────────
[Fact]
public void Constructor_Default_SetsName()
{
var m = new BwMfi();
Assert.Equal("BwMfi", m.Name);
}
[Fact]
public void Constructor_Default_WarmupPeriodIsTwo()
{
Assert.Equal(2, BwMfi.WarmupPeriod);
}
[Fact]
public void Constructor_Default_NotHotBeforeFirstBar()
{
var m = new BwMfi();
Assert.False(m.IsHot);
}
[Fact]
public void Constructor_Default_ZoneIsZero()
{
var m = new BwMfi();
Assert.Equal(0, m.Zone);
}
// ── B) Basic MFI calculation ──────────────────────────────────────────────
[Fact]
public void Update_BasicBar_CorrectMfi()
{
var m = new BwMfi();
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 95.0, 102.0, 1000.0);
var result = m.Update(bar);
// MFI = (105 - 95) / 1000 = 0.01
Assert.Equal(0.01, result.Value, Tolerance);
}
[Fact]
public void Update_ZeroVolume_ReturnsZero()
{
var m = new BwMfi();
var bar = new TBar(DateTime.UtcNow, 100.0, 110.0, 90.0, 100.0, 0.0);
var result = m.Update(bar);
Assert.Equal(0.0, result.Value, Tolerance);
}
[Fact]
public void Update_ZeroRange_ReturnsZero()
{
var m = new BwMfi();
var bar = new TBar(DateTime.UtcNow, 100.0, 100.0, 100.0, 100.0, 1000.0);
var result = m.Update(bar);
Assert.Equal(0.0, result.Value, Tolerance);
}
[Fact]
public void Update_LastMatchesReturnValue()
{
var m = new BwMfi();
var bar = new TBar(DateTime.UtcNow, 100.0, 120.0, 80.0, 100.0, 200.0);
var result = m.Update(bar);
Assert.Equal(result.Value, m.Last.Value, Tolerance);
}
// ── C) Zone classification ────────────────────────────────────────────────
[Fact]
public void Zone_FirstBar_IsZero()
{
var m = new BwMfi();
m.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.Equal(0, m.Zone);
}
[Fact]
public void Zone_Green_MfiUpVolumeUp()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Bar 1: MFI = (110-90)/1000 = 0.02, Vol = 1000
m.Update(new TBar(t, 100, 110, 90, 100, 1000));
// Bar 2: MFI = (120-80)/2000 = 0.02... need MFI up too
// Bar 2: MFI = (130-70)/1500 = 0.04, Vol = 1500 (both up)
m.Update(new TBar(t.AddMinutes(1), 100, 130, 70, 100, 1500));
Assert.Equal(1, m.Zone); // Green
}
[Fact]
public void Zone_Fade_MfiDownVolumeDown()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Bar 1: MFI = (120-80)/1000 = 0.04, Vol = 1000
m.Update(new TBar(t, 100, 120, 80, 100, 1000));
// Bar 2: MFI = (105-95)/500 = 0.02, Vol = 500 (both down)
m.Update(new TBar(t.AddMinutes(1), 100, 105, 95, 100, 500));
Assert.Equal(2, m.Zone); // Fade
}
[Fact]
public void Zone_Fake_MfiUpVolumeDown()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Bar 1: MFI = (110-90)/1000 = 0.02, Vol = 1000
m.Update(new TBar(t, 100, 110, 90, 100, 1000));
// Bar 2: MFI = (130-70)/500 = 0.12, Vol = 500 (MFI up, Vol down)
m.Update(new TBar(t.AddMinutes(1), 100, 130, 70, 100, 500));
Assert.Equal(3, m.Zone); // Fake
}
[Fact]
public void Zone_Squat_MfiDownVolumeUp()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Bar 1: MFI = (120-80)/500 = 0.08, Vol = 500
m.Update(new TBar(t, 100, 120, 80, 100, 500));
// Bar 2: MFI = (105-95)/2000 = 0.005, Vol = 2000 (MFI down, Vol up)
m.Update(new TBar(t.AddMinutes(1), 100, 105, 95, 100, 2000));
Assert.Equal(4, m.Zone); // Squat
}
[Fact]
public void Zone_Range_IsValid()
{
var m = new BwMfi();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 77);
for (int i = 0; i < 200; i++)
{
m.Update(gbm.Next(isNew: true));
Assert.InRange(m.Zone, 0, 4);
}
}
// ── D) State + bar correction ─────────────────────────────────────────────
[Fact]
public void Update_IsNewFalse_RewritesLastBar()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
m.Update(new TBar(t, 100, 110, 90, 100, 1000), isNew: true);
m.Update(new TBar(t.AddMinutes(1), 100, 112, 88, 100, 800), isNew: true);
m.Update(new TBar(t.AddMinutes(1), 100, 120, 80, 100, 400), isNew: false);
Assert.Equal(0.1, m.Last.Value, Tolerance); // (120-80)/400
}
[Fact]
public void Update_BarCorrection_ZoneUpdates()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Bar 1: MFI = 0.02, Vol = 1000
m.Update(new TBar(t, 100, 110, 90, 100, 1000), isNew: true);
// Bar 2: MFI = 0.04, Vol = 1500 → Green (both up)
m.Update(new TBar(t.AddMinutes(1), 100, 130, 70, 100, 1500), isNew: true);
Assert.Equal(1, m.Zone);
// Correct Bar 2: MFI = 0.005, Vol = 2000 → Squat (MFI down, Vol up)
m.Update(new TBar(t.AddMinutes(1), 100, 105, 95, 100, 2000), isNew: false);
Assert.Equal(4, m.Zone);
}
// ── E) Warmup / convergence ───────────────────────────────────────────────
[Fact]
public void IsHot_FalseForFirstBar_TrueForSecond()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
m.Update(new TBar(t, 100, 110, 90, 100, 500));
Assert.False(m.IsHot);
m.Update(new TBar(t.AddMinutes(1), 100, 115, 85, 100, 600));
Assert.True(m.IsHot);
}
[Fact]
public void Reset_ClearsState()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
m.Update(new TBar(t, 100, 110, 90, 100, 1000));
m.Update(new TBar(t.AddMinutes(1), 100, 115, 85, 100, 1200));
Assert.True(m.IsHot);
Assert.NotEqual(0, m.Zone);
m.Reset();
Assert.False(m.IsHot);
Assert.Equal(0, m.Zone);
Assert.Equal(default, m.Last);
}
// ── F) Robustness — NaN / Infinity ────────────────────────────────────────
[Fact]
public void Update_NaNVolume_ReturnsZero()
{
var m = new BwMfi();
var r = m.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, double.NaN));
Assert.Equal(0.0, r.Value, Tolerance);
}
[Fact]
public void Update_InfinityVolume_ReturnsZero()
{
var m = new BwMfi();
var r = m.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, double.PositiveInfinity));
Assert.Equal(0.0, r.Value, Tolerance);
}
[Fact]
public void Update_NaNHigh_ResultIsFinite()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
m.Update(new TBar(t, 100, 110, 90, 100, 1000));
var r = m.Update(new TBar(t.AddMinutes(1), 100, double.NaN, 90, 100, 500), isNew: true);
Assert.True(double.IsFinite(r.Value));
}
[Fact]
public void Update_BatchNaN_NoPropagation()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
m.Update(new TBar(t.AddMinutes(i), 100, 110, 90, 100, 1000));
}
m.Update(new TBar(t.AddMinutes(5), 100, double.NaN, double.NaN, 100, 500));
Assert.True(double.IsFinite(m.Last.Value));
}
// ── G) Consistency — streaming matches batch ──────────────────────────────
[Fact]
public void Consistency_StreamingMatchesBatch_MfiValues()
{
const int N = 100;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
double[] hi = new double[N], lo = new double[N], vol = new double[N];
double streamResult;
var mStream = new BwMfi();
for (int i = 0; i < N; i++)
{
var bar = gbm.Next(isNew: true);
hi[i] = bar.High;
lo[i] = bar.Low;
vol[i] = bar.Volume;
mStream.Update(bar, isNew: true);
}
streamResult = mStream.Last.Value;
var output = new double[N];
BwMfi.Batch(hi, lo, vol, output);
double batchResult = output[N - 1];
Assert.Equal(streamResult, batchResult, Tolerance);
}
[Fact]
public void Consistency_StreamingMatchesBatch_Zones()
{
const int N = 100;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
double[] hi = new double[N], lo = new double[N], vol = new double[N];
int[] streamZones = new int[N];
var mStream = new BwMfi();
for (int i = 0; i < N; i++)
{
var bar = gbm.Next(isNew: true);
hi[i] = bar.High;
lo[i] = bar.Low;
vol[i] = bar.Volume;
mStream.Update(bar, isNew: true);
streamZones[i] = mStream.Zone;
}
var mfiOutput = new double[N];
var zoneOutput = new int[N];
BwMfi.Batch(hi, lo, vol, mfiOutput, zoneOutput);
for (int i = 0; i < N; i++)
{
Assert.Equal(streamZones[i], zoneOutput[i]);
}
}
[Fact]
public void Consistency_EventBasedMatchesStreaming()
{
const int N = 50;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 7);
var sourceStream = new TBarSeries();
var mStream = new BwMfi();
for (int i = 0; i < N; i++)
{
var bar = gbm.Next(isNew: true);
sourceStream.Add(bar);
mStream.Update(bar, isNew: true);
}
var mEvent = new BwMfi(sourceStream);
Assert.Equal(mStream.Last.Value, mEvent.Last.Value, Tolerance);
Assert.Equal(mStream.Zone, mEvent.Zone);
}
// ── H) Span / Batch API ───────────────────────────────────────────────────
[Fact]
public void Batch_MismatchedLowLength_Throws()
{
double[] hi = [100, 110], lo = [90], vol = [1000, 800];
var mfiOut = new double[2];
var zoneOut = new int[2];
var ex = Assert.Throws<ArgumentException>(() => BwMfi.Batch(hi, lo, vol, mfiOut, zoneOut));
Assert.Equal("low", ex.ParamName);
}
[Fact]
public void Batch_MismatchedVolumeLength_Throws()
{
double[] hi = [100, 110], lo = [90, 85], vol = [1000];
var mfiOut = new double[2];
var zoneOut = new int[2];
var ex = Assert.Throws<ArgumentException>(() => BwMfi.Batch(hi, lo, vol, mfiOut, zoneOut));
Assert.Equal("volume", ex.ParamName);
}
[Fact]
public void Batch_MismatchedMfiOutputLength_Throws()
{
double[] hi = [100, 110], lo = [90, 85], vol = [1000, 800];
var mfiOut = new double[3];
var zoneOut = new int[2];
var ex = Assert.Throws<ArgumentException>(() => BwMfi.Batch(hi, lo, vol, mfiOut, zoneOut));
Assert.Equal("mfiOutput", ex.ParamName);
}
[Fact]
public void Batch_MismatchedZoneOutputLength_Throws()
{
double[] hi = [100, 110], lo = [90, 85], vol = [1000, 800];
var mfiOut = new double[2];
var zoneOut = new int[3];
var ex = Assert.Throws<ArgumentException>(() => BwMfi.Batch(hi, lo, vol, mfiOut, zoneOut));
Assert.Equal("zoneOutput", ex.ParamName);
}
[Fact]
public void Batch_EmptySpans_NoThrow()
{
double[] hi = [], lo = [], vol = [];
var mfiOut = Array.Empty<double>();
var zoneOut = Array.Empty<int>();
BwMfi.Batch(hi, lo, vol, mfiOut, zoneOut);
Assert.Empty(mfiOut);
}
[Fact]
public void Batch_KnownValues_Correct()
{
double[] hi = [110, 120, 115];
double[] lo = [90, 80, 95];
double[] vol = [1000, 500, 200];
var mfiOutput = new double[3];
var zoneOutput = new int[3];
BwMfi.Batch(hi, lo, vol, mfiOutput, zoneOutput);
Assert.Equal(0.02, mfiOutput[0], Tolerance); // 20/1000
Assert.Equal(0.08, mfiOutput[1], Tolerance); // 40/500
Assert.Equal(0.10, mfiOutput[2], Tolerance); // 20/200
Assert.Equal(0, zoneOutput[0]); // first bar
Assert.Equal(3, zoneOutput[1]); // MFI up (0.02→0.08), Vol down (1000→500) = Fake
Assert.Equal(3, zoneOutput[2]); // MFI up (0.08→0.10), Vol down (500→200) = Fake
}
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
const int N = 100_000;
var hi = new double[N];
var lo = new double[N];
var vol = new double[N];
var mfiOutput = new double[N];
var zoneOutput = new int[N];
for (int i = 0; i < N; i++) { hi[i] = 110; lo[i] = 90; vol[i] = 1000; }
BwMfi.Batch(hi, lo, vol, mfiOutput, zoneOutput);
Assert.Equal(0.02, mfiOutput[N - 1], Tolerance);
}
// ── I) Chainability ──────────────────────────────────────────────────────
[Fact]
public void PubEvent_Fires_OnUpdate()
{
var m = new BwMfi();
int count = 0;
m.Pub += (object? _, in TValueEventArgs e) => count++;
for (int i = 0; i < 10; i++)
{
m.Update(_gbm.Next(isNew: true), isNew: true);
}
Assert.Equal(10, count);
}
[Fact]
public void TBarSeries_Chaining_Works()
{
var source = new TBarSeries();
var m = new BwMfi(source);
var gbm = new GBM(100.0, 0.05, 0.2, seed: 55);
for (int i = 0; i < 20; i++)
{
source.Add(gbm.Next(isNew: true));
}
Assert.True(double.IsFinite(m.Last.Value));
Assert.True(m.IsHot);
}
}
@@ -0,0 +1,214 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Self-consistency validation for BW_MFI.
/// No direct Tulip cross-validation available (Tulip has marketfi but not zone classification).
/// MFI value validation delegates to MARKETFI Tulip tests; zones are self-validated.
/// </summary>
public sealed class BwMfiValidationTests
{
private const double Tolerance = 1e-10;
// ── Identity: MFI = Range / Volume ───────────────────────────────────────
[Theory]
[InlineData(110, 90, 1000, 0.02)]
[InlineData(115, 85, 500, 0.06)]
[InlineData(100, 80, 200, 0.10)]
[InlineData(105, 100, 50, 0.10)]
[InlineData(100, 100, 1000, 0.0)] // zero range
[InlineData(110, 90, 0, 0.0)] // zero volume guard
public void Identity_Formula_MatchesDirectComputation(
double high, double low, double volume, double expected)
{
var m = new BwMfi();
var result = m.Update(new TBar(DateTime.UtcNow, 100, high, low, 100, volume));
Assert.Equal(expected, result.Value, Tolerance);
}
// ── Zone classification exhaustive ────────────────────────────────────────
[Theory]
[InlineData(0.02, 1000, 0.04, 1500, 1)] // MFI↑ Vol↑ = Green
[InlineData(0.04, 1000, 0.02, 500, 2)] // MFI↓ Vol↓ = Fade
[InlineData(0.02, 1000, 0.04, 500, 3)] // MFI↑ Vol↓ = Fake
[InlineData(0.04, 500, 0.02, 1000, 4)] // MFI↓ Vol↑ = Squat
public void Zone_ClassificationMatrix(
double mfi1, double vol1, double mfi2, double vol2, int expectedZone)
{
var m = new BwMfi();
var t = DateTime.UtcNow;
// Construct bars to produce desired MFI values
// MFI = (H-L)/V → H-L = MFI * V
double range1 = mfi1 * vol1;
double range2 = mfi2 * vol2;
m.Update(new TBar(t, 100, 100 + range1 / 2, 100 - range1 / 2, 100, vol1));
m.Update(new TBar(t.AddMinutes(1), 100, 100 + range2 / 2, 100 - range2 / 2, 100, vol2));
Assert.Equal(expectedZone, m.Zone);
}
// ── MFI matches MARKETFI ─────────────────────────────────────────────────
[Fact]
public void MfiValue_MatchesMarketfi()
{
const int N = 200;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 17);
var bwMfi = new BwMfi();
var marketfi = new Marketfi();
for (int i = 0; i < N; i++)
{
var bar = gbm.Next(isNew: true);
bwMfi.Update(bar, isNew: true);
marketfi.Update(bar, isNew: true);
Assert.Equal(marketfi.Last.Value, bwMfi.Last.Value, Tolerance);
}
}
// ── Batch == Streaming ───────────────────────────────────────────────────
[Fact]
public void BatchStreaming_AgreeOnAllBars_MfiAndZones()
{
const int N = 200;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 17);
double[] hi = new double[N], lo = new double[N], vol = new double[N];
double[] streamMfi = new double[N];
int[] streamZones = new int[N];
var m = new BwMfi();
for (int i = 0; i < N; i++)
{
var bar = gbm.Next(isNew: true);
hi[i] = bar.High;
lo[i] = bar.Low;
vol[i] = bar.Volume;
m.Update(bar, isNew: true);
streamMfi[i] = m.Last.Value;
streamZones[i] = m.Zone;
}
var batchMfi = new double[N];
var batchZones = new int[N];
BwMfi.Batch(hi, lo, vol, batchMfi, batchZones);
for (int i = 0; i < N; i++)
{
Assert.Equal(streamMfi[i], batchMfi[i], Tolerance);
Assert.Equal(streamZones[i], batchZones[i]);
}
}
// ── Determinism ──────────────────────────────────────────────────────────
[Fact]
public void Determinism_SameInputSameOutput()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 99);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 99);
var m1 = new BwMfi();
var m2 = new BwMfi();
for (int i = 0; i < 100; i++)
{
var bar1 = gbm1.Next(isNew: true);
var bar2 = gbm2.Next(isNew: true);
m1.Update(bar1, isNew: true);
m2.Update(bar2, isNew: true);
Assert.Equal(m1.Last.Value, m2.Last.Value, Tolerance);
Assert.Equal(m1.Zone, m2.Zone);
}
}
// ── Non-negativity ───────────────────────────────────────────────────────
[Fact]
public void Output_AlwaysNonNegative()
{
var gbm = new GBM(100.0, 0.05, 0.3, seed: 123);
var m = new BwMfi();
for (int i = 0; i < 500; i++)
{
var result = m.Update(gbm.Next(isNew: true));
Assert.True(result.Value >= 0.0, $"MFI negative at bar {i}: {result.Value}");
}
}
// ── Zero volume → zero output ─────────────────────────────────────────────
[Fact]
public void ZeroVolume_AlwaysZero()
{
var m = new BwMfi();
var t = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var result = m.Update(new TBar(t.AddMinutes(i), 100, 110 + i, 90 - i, 100, 0.0));
Assert.Equal(0.0, result.Value, Tolerance);
}
}
// ── Scaling: double volume halves MFI ────────────────────────────────────
[Fact]
public void Scaling_DoubleVolume_HalvesMfi()
{
var m1 = new BwMfi();
var m2 = new BwMfi();
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000.0);
var bar2 = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 2000.0);
double mfi1 = m1.Update(bar1).Value;
double mfi2 = m2.Update(bar2).Value;
Assert.Equal(mfi1 / 2.0, mfi2, Tolerance);
}
// ── NaN safety ───────────────────────────────────────────────────────────
[Fact]
public void NaN_InputDoesNotProduceNaN()
{
var m = new BwMfi();
m.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(1), 100, double.NaN, double.NaN, 100, double.NaN);
var result = m.Update(nanBar);
Assert.True(double.IsFinite(result.Value));
Assert.InRange(m.Zone, 0, 4);
}
// ── Zone coverage: all 4 zones reachable ─────────────────────────────────
[Fact]
public void AllFourZones_Reachable()
{
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
var m = new BwMfi();
var zonesHit = new HashSet<int>();
for (int i = 0; i < 1000 && zonesHit.Count < 4; i++)
{
m.Update(gbm.Next(isNew: true));
if (m.Zone >= 1 && m.Zone <= 4)
{
zonesHit.Add(m.Zone);
}
}
Assert.Contains(1, zonesHit); // Green
Assert.Contains(2, zonesHit); // Fade
Assert.Contains(3, zonesHit); // Fake
Assert.Contains(4, zonesHit); // Squat
}
}