mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 12:38:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user