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
+61
View File
@@ -0,0 +1,61 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class BwMfiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private BwMfi _bwMfi = null!;
private readonly LineSeries _mfiLine;
private readonly LineSeries _zoneLine;
public static int MinHistoryDepths => 1;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => "BW_MFI";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/bw_mfi/BwMfi.Quantower.cs";
public BwMfiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "BW_MFI - Bill Williams Market Facilitation Index";
Description = "Bill Williams' MFI with 4-zone classification. Zone 1 (Green): trend continuation. Zone 2 (Fade): fading. Zone 3 (Fake): unsupported. Zone 4 (Squat): breakout imminent.";
_mfiLine = new LineSeries("BW_MFI", Color.Cyan, 2, LineStyle.Histogramm);
_zoneLine = new LineSeries("Zone", Color.Gray, 1, LineStyle.Solid) { Visible = false };
AddLineSeries(_mfiLine);
AddLineSeries(_zoneLine);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_bwMfi = new BwMfi();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _bwMfi.Update(this.GetInputBar(args), args.IsNewBar());
// Zone-based coloring
Color barColor = _bwMfi.Zone switch
{
1 => Color.Green, // Green zone
2 => Color.SaddleBrown, // Fade zone
3 => Color.Blue, // Fake zone
4 => Color.Fuchsia, // Squat zone
_ => Color.Gray // First bar
};
_mfiLine.SetValue(_bwMfi.Last.Value, _bwMfi.IsHot, ShowColdValues);
_mfiLine.SetMarker(0, barColor);
_zoneLine.SetValue(_bwMfi.Zone, _bwMfi.IsHot, ShowColdValues);
}
}
+309
View File
@@ -0,0 +1,309 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Computes the Bill Williams Market Facilitation Index (BW_MFI) with 4-zone classification,
/// measuring price movement efficiency per unit of volume and categorizing each bar into
/// one of four market states based on MFI and volume direction changes.
/// </summary>
/// <remarks>
/// BW_MFI Formula:
/// <c>MFI = (High Low) / Volume</c>,
/// Zone classification by comparing current vs previous bar:
/// <c>Zone 1 (Green): MFI↑ + Volume↑ → trend continuation</c>,
/// <c>Zone 2 (Fade): MFI↓ + Volume↓ → fading momentum</c>,
/// <c>Zone 3 (Fake): MFI↑ + Volume↓ → fake breakout</c>,
/// <c>Zone 4 (Squat): MFI↓ + Volume↑ → accumulation/distribution</c>.
///
/// Zone 4 (Squat) is the most significant: large volume with small range indicates a
/// battle between bulls and bears, often preceding a breakout. Zone 1 (Green) confirms
/// trend strength. Zone 3 (Fake) warns of unsupported price moves.
/// This implementation is optimized for streaming updates with O(1) per bar.
/// Non-finite inputs (NaN/±Inf) are sanitized by substituting the last finite value observed.
///
/// For the authoritative algorithm reference, full rationale, and behavioral contracts, see the
/// companion files in the same directory.
/// </remarks>
/// <seealso href="BwMfi.md">Detailed documentation</seealso>
/// <seealso href="bw_mfi.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class BwMfi : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double LastValid,
double PrevMfi,
double PrevVolume,
int Count);
private State _s;
private State _ps;
private readonly TBarPublishedHandler _barHandler;
/// <summary>Display name for the indicator.</summary>
public string Name { get; }
/// <summary>Bars required for the first valid zone output (2 — need previous bar for comparison).</summary>
public static int WarmupPeriod => 2;
/// <summary>True when at least two bars have been processed (zone classification requires comparison).</summary>
public bool IsHot => _s.Count >= 2;
/// <summary>Current BW_MFI value (price range per unit of volume).</summary>
public TValue Last { get; private set; }
/// <summary>Current zone classification (1=Green, 2=Fade, 3=Fake, 4=Squat, 0=insufficient data).</summary>
public int Zone { get; private set; }
public event TValuePublishedHandler? Pub;
/// <summary>Creates a BW_MFI indicator.</summary>
public BwMfi()
{
_s = new State(0.0, 0.0, 0.0, 0);
_ps = _s;
Name = "BwMfi";
_barHandler = HandleBar;
}
/// <summary>Creates BW_MFI chained to a TBarSeries source.</summary>
public BwMfi(TBarSeries source) : this()
{
Prime(source);
source.Pub += _barHandler;
}
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
/// <summary>Resets all state to initial conditions.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = new State(0.0, 0.0, 0.0, 0);
_ps = _s;
Last = default;
Zone = 0;
}
/// <summary>
/// Updates BW_MFI with a new OHLCV bar.
/// </summary>
/// <param name="input">OHLCV bar data</param>
/// <param name="isNew">True to advance state; false to rewrite the latest bar</param>
/// <returns>Current BW_MFI value as TValue</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
var s = _s;
if (isNew)
{
_ps = s;
s.Count++;
}
else
{
int count = s.Count;
s = _ps;
s.Count = count;
}
// Sanitize OHLCV inputs — use last-valid on NaN/Infinity
double high = double.IsFinite(input.High) ? input.High : s.LastValid;
double low = double.IsFinite(input.Low) ? input.Low : s.LastValid;
double volume = double.IsFinite(input.Volume) ? input.Volume : 0.0;
// Core formula: price range per unit of volume
double mfi = volume != 0.0 ? (high - low) / volume : 0.0;
if (double.IsFinite(mfi))
{
s.LastValid = mfi;
}
else
{
mfi = s.LastValid;
}
// Zone classification: requires previous bar comparison
int zone;
if (s.Count < 2)
{
zone = 0; // insufficient data
}
else
{
bool mfiUp = mfi > s.PrevMfi;
bool volUp = volume > s.PrevVolume;
if (mfiUp && volUp)
{
zone = 1; // Green: trend continuation
}
else if (!mfiUp && !volUp)
{
zone = 2; // Fade: fading momentum
}
else if (mfiUp && !volUp)
{
zone = 3; // Fake: unsupported price move
}
else
{
zone = 4; // Squat: accumulation/distribution
}
}
// Store current values for next comparison
s.PrevMfi = mfi;
s.PrevVolume = volume;
_s = s;
Zone = zone;
Last = new TValue(input.Time, mfi);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Updates BW_MFI from a scalar TValue (uses Val as proxy; High=Low=Val, Volume=1).
/// Primarily for ITValuePublisher compatibility — TBar is the natural input for BW_MFI.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
double v = double.IsFinite(input.Value) ? input.Value : _s.LastValid;
return Update(new TBar(input.Time, v, v, v, v, 1.0), isNew);
}
/// <summary>
/// Batch-computes BW_MFI and zones over raw High/Low/Volume spans. Zero-allocation path.
/// </summary>
/// <param name="high">Source high prices</param>
/// <param name="low">Source low prices</param>
/// <param name="volume">Source volume</param>
/// <param name="mfiOutput">Destination span for MFI values</param>
/// <param name="zoneOutput">Destination span for zone classifications (1-4, 0 for first bar)</param>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> volume,
Span<double> mfiOutput,
Span<int> zoneOutput)
{
int len = high.Length;
if (low.Length != len)
{
throw new ArgumentException("Low length must match high length", nameof(low));
}
if (volume.Length != len)
{
throw new ArgumentException("Volume length must match high length", nameof(volume));
}
if (mfiOutput.Length != len)
{
throw new ArgumentException("MFI output length must match input length", nameof(mfiOutput));
}
if (zoneOutput.Length != len)
{
throw new ArgumentException("Zone output length must match input length", nameof(zoneOutput));
}
if (len == 0)
{
return;
}
// First bar: compute MFI, zone = 0 (no previous to compare)
double v0 = double.IsFinite(volume[0]) ? volume[0] : 0.0;
double mfi0 = v0 != 0.0 ? (high[0] - low[0]) / v0 : 0.0;
mfiOutput[0] = mfi0;
zoneOutput[0] = 0;
double prevMfi = mfi0;
double prevVol = v0;
for (int i = 1; i < len; i++)
{
double h = high[i];
double l = low[i];
double vol = double.IsFinite(volume[i]) ? volume[i] : 0.0;
double mfi = vol != 0.0 ? (h - l) / vol : 0.0;
mfiOutput[i] = mfi;
bool mfiUp = mfi > prevMfi;
bool volUp = vol > prevVol;
if (mfiUp && volUp)
{
zoneOutput[i] = 1;
}
else if (!mfiUp && !volUp)
{
zoneOutput[i] = 2;
}
else if (mfiUp && !volUp)
{
zoneOutput[i] = 3;
}
else
{
zoneOutput[i] = 4;
}
prevMfi = mfi;
prevVol = vol;
}
}
/// <summary>
/// Batch-computes BW_MFI values only (without zones) over raw spans. Zero-allocation path.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> volume,
Span<double> output)
{
int len = high.Length;
if (low.Length != len)
{
throw new ArgumentException("Low length must match high length", nameof(low));
}
if (volume.Length != len)
{
throw new ArgumentException("Volume length must match high length", nameof(volume));
}
if (output.Length != len)
{
throw new ArgumentException("Output length must match input length", nameof(output));
}
for (int i = 0; i < len; i++)
{
double h = high[i];
double l = low[i];
double v = double.IsFinite(volume[i]) ? volume[i] : 0.0;
output[i] = v != 0.0 ? (h - l) / v : 0.0;
}
}
/// <summary>Primes the indicator by replaying historical data without firing events.</summary>
public void Prime(TBarSeries source)
{
foreach (var bar in source)
{
Update(bar, isNew: true);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
# BW_MFI: Bill Williams Market Facilitation Index
> *The market facilitates price movement when it wants to — volume tells you how hard it tried.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Oscillators |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | None |
| **Outputs** | Dual series (Mfi, Zone) |
| **Output range** | MFI: $\geq 0$; Zone: {0,1,2,3,4} |
| **Warmup** | 2 bars |
| **PineScript** | [bw_mfi.pine](bw_mfi.pine) |
- Bill Williams' Market Facilitation Index measures price movement efficiency per unit of volume, then classifies each bar into one of four zones based on MFI and volume direction changes.
- **Similar:** [MARKETFI](../marketfi/Marketfi.md) (MFI value only, no zones) | **Complementary:** [OBV](../../volume/obv/Obv.md), [FI](../fi/Fi.md) | **Trading note:** Zone 4 (Squat) often precedes breakouts; Zone 1 (Green) confirms trend strength.
- Self-validated against direct formula computation. MARKETFI provides the same MFI value; zones are the distinguishing feature.
The Bill Williams Market Facilitation Index extends the basic MFI calculation $\text{MFI} = (H - L) / V$ with a four-zone classification system that compares current MFI and volume to previous bar values. This classification transforms a simple efficiency measure into an actionable market state detector. Zone 4 (Squat) — high volume with compressed range — is Williams' most important signal, indicating a battle between bulls and bears that typically resolves with a breakout. The dual-output design (continuous MFI value plus discrete zone) enables both quantitative analysis and visual bar coloring.
## Historical Context
Bill Williams introduced the Market Facilitation Index in *Trading Chaos* (1995), as part of his broader "Profitunity" trading system. Williams argued that traditional volume analysis was incomplete: knowing that volume increased tells you nothing without understanding whether the market *used* that volume to move price. The MFI answers this question directly — it measures how many price points the market moved per unit of volume traded.
The four-zone classification system was Williams' key innovation over raw MFI. By cross-referencing MFI direction with volume direction, he created a 2×2 matrix that categorizes every bar into one of four market states. This framework appears in both *Trading Chaos* (1995) and *New Trading Dimensions* (1998). The zone names (Green, Fade, Fake, Squat) became part of the standard Williams lexicon and are implemented in most professional trading platforms including MetaTrader, TradingView, and Bloomberg Terminal.
## Architecture & Physics
### 1. MFI Calculation
$$
\text{MFI}_t = \frac{H_t - L_t}{V_t}
$$
where $H_t$, $L_t$, $V_t$ are the high, low, and volume of bar $t$. Zero-volume guard returns 0.0 (no facilitation when no trades occurred). The MFI value is unbounded above and represents price range per unit of volume — higher values indicate more efficient price movement.
### 2. Zone Classification Matrix
The zone is determined by comparing current MFI and volume to the previous bar:
$$
\text{Zone}_t = \begin{cases}
1 \text{ (Green)} & \text{if } \text{MFI}_t > \text{MFI}_{t-1} \text{ and } V_t > V_{t-1} \\
2 \text{ (Fade)} & \text{if } \text{MFI}_t \leq \text{MFI}_{t-1} \text{ and } V_t \leq V_{t-1} \\
3 \text{ (Fake)} & \text{if } \text{MFI}_t > \text{MFI}_{t-1} \text{ and } V_t \leq V_{t-1} \\
4 \text{ (Squat)} & \text{if } \text{MFI}_t \leq \text{MFI}_{t-1} \text{ and } V_t > V_{t-1}
\end{cases}
$$
### 3. Zone Interpretation
| Zone | Name | MFI | Volume | Market State |
| :--: | :---- | :-: | :----: | :----------- |
| 1 | Green | ↑ | ↑ | Trend continuation — market moves efficiently with increasing participation |
| 2 | Fade | ↓ | ↓ | Fading momentum — traders losing interest, trend exhaustion |
| 3 | Fake | ↑ | ↓ | Fake breakout — price moves on declining volume, unsupported |
| 4 | Squat | ↓ | ↑ | Accumulation — high volume absorbed by range compression, breakout imminent |
### 4. Complexity
O(1) per bar — single division plus two comparisons. No buffers, no period parameter. The zone classification adds only two boolean comparisons to the base MFI calculation.
## Mathematical Foundation
### Parameters
No configurable parameters. MFI is a pure bar-level computation.
### Output Interpretation
| Output | Type | Range | Description |
| :----- | :--- | :---- | :---------- |
| MFI | double | $\geq 0$ | Price range per unit of volume |
| Zone | int | {0,1,2,3,4} | Market state classification (0 = first bar, insufficient data) |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
| :-------- | ----: | ------------: | -------: |
| SUB | 1 | 1 | 1 |
| DIV | 1 | 15 | 15 |
| CMP | 2 | 1 | 2 |
| **Total** | | | **18** |
### SIMD Analysis
| Operation | Vectorizable? | Notes |
| :-------- | :-----------: | :---- |
| MFI = (H-L)/V | Yes | Element-wise arithmetic |
| Zone comparison | Limited | Sequential dependency on previous bar |
| Batch MFI only | Full SIMD | No inter-element dependency |
### Quality Metrics
| Metric | Score | Notes |
| :----- | :---: | :---- |
| Accuracy | 10/10 | Exact formula, no approximation |
| Timeliness | 10/10 | Zero lag — current bar only |
| Smoothness | 3/10 | No smoothing — raw bar-level measure |
| Signal clarity | 7/10 | Discrete zones are unambiguous |
| Memory | 10/10 | O(1) — four scalar values |
## Common Pitfalls
1. **Zero volume bars:** Holiday/pre-market bars with zero volume produce MFI = 0 and can skew zone classification on the next bar. Filter these bars or use minimum volume thresholds.
2. **MFI scale varies by instrument:** Raw MFI values are not comparable across instruments with different price levels or volume scales. Use percentage-based normalization for cross-instrument comparison.
3. **Equal values edge case:** When MFI or volume exactly equals the previous bar, the implementation treats this as "not up" — resulting in Zone 2 (Fade) when both are equal, Zone 4 (Squat) when only volume increases, or Zone 3 (Fake) when only MFI increases.
4. **First bar has no zone:** Zone 0 indicates insufficient data (first bar). Ensure downstream logic handles this sentinel value.
## Resources
- **Williams, B.** *Trading Chaos*. Wiley, 1995. Chapter on Market Facilitation Index.
- **Williams, B.** *New Trading Dimensions*. Wiley, 1998. Extended MFI zone analysis.
- **Williams, B.** *Trading Chaos: Second Edition*. Wiley, 2004. Updated zone interpretations.
+43
View File
@@ -0,0 +1,43 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Bill Williams Market Facilitation Index (BW_MFI)", "BW_MFI", overlay=false)
//@function Bill Williams MFI with 4-zone classification
//@returns [mfi, zone] where zone: 1=Green, 2=Fade, 3=Fake, 4=Squat
//@optimized O(1) per bar — division + two comparisons
bw_mfi() =>
float mfi = volume != 0 ? (high - low) / volume : 0.0
float prev_mfi = nz(mfi[1])
float prev_vol = nz(volume[1])
int zone = na
if bar_index < 1
zone := 0
else
bool mfi_up = mfi > prev_mfi
bool vol_up = volume > prev_vol
if mfi_up and vol_up
zone := 1 // Green: trend continuation
else if not mfi_up and not vol_up
zone := 2 // Fade: fading momentum
else if mfi_up and not vol_up
zone := 3 // Fake: unsupported price move
else
zone := 4 // Squat: accumulation/distribution
[mfi, zone]
// ---------- Main loop ----------
[mfi_val, zone_val] = bw_mfi()
// Zone-based bar coloring
zone_color = switch zone_val
1 => color.green // Green zone
2 => color.new(#8B4513, 0) // Fade (brown)
3 => color.blue // Fake zone
4 => color.fuchsia // Squat zone
=> color.gray // First bar / unknown
plot(mfi_val, title="BW_MFI", color=zone_color, style=plot.style_columns, linewidth=3)
hline(0, "Zero", color=color.gray, linestyle=hline.style_dashed)
@@ -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
}
}