mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 18:48:05 +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:
@@ -11,6 +11,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use
|
||||
| [BBI](bbi/Bbi.md) | Bulls Bears Index | Multi-period SMA composite. Measures aggregate trend strength. |
|
||||
| [BBS](bbs/Bbs.md) | Bollinger Band Squeeze | BB width < KC width indicates consolidation. Breakout imminent. |
|
||||
| [BRAR](brar/Brar.md) | BRAR | Bull-Bear power ratio from open-relative ranges. Japanese origin. |
|
||||
| [BW_MFI](bw_mfi/BwMfi.md) | Bill Williams MFI | MFI with 4-zone classification: Green (trend), Fade (fading), Fake (unsupported), Squat (breakout imminent). |
|
||||
| [CFO](cfo/Cfo.md) | Chande Forecast Oscillator | Percentage difference between price and linear regression forecast. Also known as FOSC. |
|
||||
| [COPPOCK](coppock/Coppock.md) | Coppock Curve | Long-term momentum via weighted sum of ROC periods. Buy signals only. |
|
||||
| [CRSI](crsi/Crsi.md) | Connors RSI | Composite of RSI, streak RSI, and percentile rank. Mean-reversion. |
|
||||
@@ -19,6 +20,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use
|
||||
| [DEM](dem/Dem.md) | DeMarker Oscillator | Bounded 0-1 oscillator comparing sequential highs and lows. |
|
||||
| [DOSC](dosc/Dosc.md) | Derivative Oscillator | Double-smoothed RSI minus signal line. Momentum acceleration. |
|
||||
| [DPO](dpo/Dpo.md) | Detrended Price Oscillator | Removes trend via displaced SMA. Reveals cycles. |
|
||||
| [DSTOCH](dstoch/Dstoch.md) | Double Stochastic (Bressert) | Stochastic applied to Stochastic with EMA smoothing. Bounded 0-100. |
|
||||
| [DYMI](dymi/Dymi.md) | Dynamic Momentum Index | RSI with volatility-adaptive period. Shorter in volatile markets. |
|
||||
| [ER](er/Er.md) | Efficiency Ratio | Measures directional efficiency. Net movement / total path length. |
|
||||
| [ERI](eri/Eri.md) | Elder Ray Index | Separates bull and bear power relative to EMA. |
|
||||
@@ -42,6 +44,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use
|
||||
| [RVGI](rvgi/Rvgi.md) | Relative Vigor Index | Open-close vs high-low ratio with SMA smoothing. Measures conviction. |
|
||||
| [SMI](smi/Smi.md) | Stochastic Momentum Index | Distance from range midpoint. More sensitive than classic Stochastic. |
|
||||
| [SQUEEZE](squeeze/Squeeze.md) | Squeeze | BB width < KC width indicates consolidation. Breakout imminent. |
|
||||
| [SQUEEZE_PRO](squeeze_pro/squeeze_pro.md) | Squeeze Pro | Multi-level BB vs KC squeeze (wide/normal/narrow) with MOM-smoothed momentum. LazyBear. |
|
||||
| [STC](stc/Stc.md) | Schaff Trend Cycle | MACD + double Stochastic smoothing. Fast momentum oscillator (0-100). |
|
||||
| [STOCH](stoch/Stoch.md) | Stochastic Oscillator | Close position within N-period high-low range. Classic overbought/oversold. |
|
||||
| [STOCHF](stochf/Stochf.md) | Stochastic Fast | Unsmoothed Stochastic. Faster but noisier. |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class DstochIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 500, 1, 0)]
|
||||
public int Period { get; set; } = 21;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Dstoch _dstoch = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"DSTOCH {Period}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/dstoch/Dstoch.cs";
|
||||
|
||||
public DstochIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "DSTOCH";
|
||||
Description = "Double Stochastic (Bressert DSS) — Stochastic applied to Stochastic with EMA smoothing";
|
||||
|
||||
_series = new LineSeries(name: "DSS", color: Color.Blue, width: 2, style: LineStyle.Solid);
|
||||
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_dstoch = new Dstoch(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
_ = _dstoch.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_series.SetValue(_dstoch.Last.Value, _dstoch.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// DSTOCH: Double Stochastic (Bressert DSS).
|
||||
/// Applies the Stochastic formula twice with EMA smoothing between stages.
|
||||
/// Stage 1: rawK = 100 * (close - LL) / (HH - LL) → smoothK = EMA(rawK, period)
|
||||
/// Stage 2: dsRaw = 100 * (smoothK - min(smoothK)) / (max(smoothK) - min(smoothK)) → output = EMA(dsRaw, period)
|
||||
/// Bounded [0, 100]. Uses MonotonicDeque for O(1) amortized min/max in both stages.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Dstoch : ITValuePublisher
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
|
||||
// Stage 1: HLC stochastic
|
||||
private readonly double[] _hBuf;
|
||||
private readonly double[] _lBuf;
|
||||
private readonly MonotonicDeque _maxDeque;
|
||||
private readonly MonotonicDeque _minDeque;
|
||||
|
||||
// Stage 2: smoothK stochastic
|
||||
private readonly double[] _skBuf;
|
||||
private readonly MonotonicDeque _skMaxDeque;
|
||||
private readonly MonotonicDeque _skMinDeque;
|
||||
|
||||
private int _count;
|
||||
private long _index;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double SmK, double Dss,
|
||||
double LastValidHigh, double LastValidLow, double LastValidClose);
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
private readonly TBarPublishedHandler _barHandler;
|
||||
|
||||
public string Name { get; }
|
||||
public int WarmupPeriod { get; }
|
||||
public TValue Last { get; private set; }
|
||||
public bool IsHot => _count >= _period;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public Dstoch(int period = 21)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_alpha = 2.0 / (period + 1);
|
||||
_decay = 1.0 - _alpha;
|
||||
|
||||
_hBuf = new double[_period];
|
||||
_lBuf = new double[_period];
|
||||
_maxDeque = new MonotonicDeque(_period);
|
||||
_minDeque = new MonotonicDeque(_period);
|
||||
|
||||
_skBuf = new double[_period];
|
||||
_skMaxDeque = new MonotonicDeque(_period);
|
||||
_skMinDeque = new MonotonicDeque(_period);
|
||||
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
|
||||
Name = $"Dstoch({period})";
|
||||
WarmupPeriod = period;
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
public Dstoch(TBarSeries source, int period = 21) : this(period)
|
||||
{
|
||||
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 = true) =>
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_index++;
|
||||
if (_count < _period)
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Validate inputs — substitute last-valid on NaN/Infinity
|
||||
double high = input.High;
|
||||
double low = input.Low;
|
||||
double close = input.Close;
|
||||
|
||||
if (double.IsFinite(high)) { s.LastValidHigh = high; }
|
||||
else { high = s.LastValidHigh; }
|
||||
|
||||
if (double.IsFinite(low)) { s.LastValidLow = low; }
|
||||
else { low = s.LastValidLow; }
|
||||
|
||||
if (double.IsFinite(close)) { s.LastValidClose = close; }
|
||||
else { close = s.LastValidClose; }
|
||||
|
||||
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
|
||||
{
|
||||
_s = s;
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// Stage 1: Raw stochastic %K
|
||||
int bufIdx = _index < 0 ? 0 : (int)(_index % _period);
|
||||
_hBuf[bufIdx] = high;
|
||||
_lBuf[bufIdx] = low;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_maxDeque.PushMax(_index, high, _hBuf);
|
||||
_minDeque.PushMin(_index, low, _lBuf);
|
||||
}
|
||||
else
|
||||
{
|
||||
_maxDeque.RebuildMax(_hBuf, _index, _count);
|
||||
_minDeque.RebuildMin(_lBuf, _index, _count);
|
||||
}
|
||||
|
||||
double highest = _maxDeque.GetExtremum(_hBuf);
|
||||
double lowest = _minDeque.GetExtremum(_lBuf);
|
||||
double range1 = highest - lowest;
|
||||
double rawK = range1 > 0.0 ? 100.0 * (close - lowest) / range1 : 0.0;
|
||||
|
||||
// Stage 1 EMA: smooth rawK
|
||||
double smoothK = double.IsNaN(s.SmK)
|
||||
? rawK
|
||||
: Math.FusedMultiplyAdd(s.SmK, _decay, _alpha * rawK);
|
||||
s.SmK = smoothK;
|
||||
|
||||
// Stage 2: Stochastic of smoothK
|
||||
_skBuf[bufIdx] = smoothK;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_skMaxDeque.PushMax(_index, smoothK, _skBuf);
|
||||
_skMinDeque.PushMin(_index, smoothK, _skBuf);
|
||||
}
|
||||
else
|
||||
{
|
||||
_skMaxDeque.RebuildMax(_skBuf, _index, _count);
|
||||
_skMinDeque.RebuildMin(_skBuf, _index, _count);
|
||||
}
|
||||
|
||||
double skMax = _skMaxDeque.GetExtremum(_skBuf);
|
||||
double skMin = _skMinDeque.GetExtremum(_skBuf);
|
||||
double range2 = skMax - skMin;
|
||||
double dsRaw = range2 > 0.0 ? 100.0 * (smoothK - skMin) / range2 : 0.0;
|
||||
|
||||
// Stage 2 EMA: smooth dsRaw
|
||||
double dss = double.IsNaN(s.Dss)
|
||||
? dsRaw
|
||||
: Math.FusedMultiplyAdd(s.Dss, _decay, _alpha * dsRaw);
|
||||
s.Dss = dss;
|
||||
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(input.Time, dss);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true) =>
|
||||
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
|
||||
|
||||
public TSeries Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var times = new List<long>(len);
|
||||
var vals = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(times, len);
|
||||
CollectionsMarshal.SetCount(vals, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vals), _period);
|
||||
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(times));
|
||||
|
||||
Prime(source);
|
||||
|
||||
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
|
||||
Last = new TValue(lastTime, CollectionsMarshal.AsSpan(vals)[^1]);
|
||||
|
||||
return new TSeries(times, vals);
|
||||
}
|
||||
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Array.Clear(_hBuf);
|
||||
Array.Clear(_lBuf);
|
||||
Array.Clear(_skBuf);
|
||||
_maxDeque.Reset();
|
||||
_minDeque.Reset();
|
||||
_skMaxDeque.Reset();
|
||||
_skMinDeque.Reset();
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> output,
|
||||
int period = 21)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
if (high.Length != low.Length || high.Length != close.Length)
|
||||
{
|
||||
throw new ArgumentException("Input spans must have the same length", nameof(high));
|
||||
}
|
||||
if (output.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as input", nameof(output));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int StackallocThreshold = 256;
|
||||
|
||||
// Temporary buffers for Highest/Lowest results
|
||||
double[]? rentedUpper = null;
|
||||
double[]? rentedLower = null;
|
||||
double[]? rentedRawK = null;
|
||||
double[]? rentedSmK = null;
|
||||
double[]? rentedSmkUpper = null;
|
||||
double[]? rentedSmkLower = null;
|
||||
|
||||
scoped Span<double> upperBuf;
|
||||
scoped Span<double> lowerBuf;
|
||||
scoped Span<double> rawKBuf;
|
||||
scoped Span<double> smKBuf;
|
||||
scoped Span<double> smkUpperBuf;
|
||||
scoped Span<double> smkLowerBuf;
|
||||
|
||||
if (len <= StackallocThreshold)
|
||||
{
|
||||
upperBuf = stackalloc double[len];
|
||||
lowerBuf = stackalloc double[len];
|
||||
rawKBuf = stackalloc double[len];
|
||||
smKBuf = stackalloc double[len];
|
||||
smkUpperBuf = stackalloc double[len];
|
||||
smkLowerBuf = stackalloc double[len];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedUpper = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedLower = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedRawK = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedSmK = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedSmkUpper = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedSmkLower = ArrayPool<double>.Shared.Rent(len);
|
||||
upperBuf = rentedUpper.AsSpan(0, len);
|
||||
lowerBuf = rentedLower.AsSpan(0, len);
|
||||
rawKBuf = rentedRawK.AsSpan(0, len);
|
||||
smKBuf = rentedSmK.AsSpan(0, len);
|
||||
smkUpperBuf = rentedSmkUpper.AsSpan(0, len);
|
||||
smkLowerBuf = rentedSmkLower.AsSpan(0, len);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Stage 1: raw %K via Highest/Lowest
|
||||
Highest.Batch(high, upperBuf, period);
|
||||
Lowest.Batch(low, lowerBuf, period);
|
||||
|
||||
double alpha = 2.0 / (period + 1);
|
||||
double decay = 1.0 - alpha;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double range = upperBuf[i] - lowerBuf[i];
|
||||
rawKBuf[i] = range > 0.0 ? 100.0 * (close[i] - lowerBuf[i]) / range : 0.0;
|
||||
}
|
||||
|
||||
// Stage 1 EMA: smooth rawK → smoothK
|
||||
smKBuf[0] = rawKBuf[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
smKBuf[i] = Math.FusedMultiplyAdd(smKBuf[i - 1], decay, alpha * rawKBuf[i]);
|
||||
}
|
||||
|
||||
// Stage 2: Highest/Lowest of smoothK
|
||||
Highest.Batch(smKBuf.Slice(0, len), smkUpperBuf, period);
|
||||
Lowest.Batch(smKBuf.Slice(0, len), smkLowerBuf, period);
|
||||
|
||||
// Stage 2: raw DS
|
||||
// Reuse rawKBuf for dsRaw
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double skRange = smkUpperBuf[i] - smkLowerBuf[i];
|
||||
rawKBuf[i] = skRange > 0.0
|
||||
? 100.0 * (smKBuf[i] - smkLowerBuf[i]) / skRange
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
// Stage 2 EMA: smooth dsRaw → output
|
||||
output[0] = rawKBuf[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
output[i] = Math.FusedMultiplyAdd(output[i - 1], decay, alpha * rawKBuf[i]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedUpper != null) { ArrayPool<double>.Shared.Return(rentedUpper); }
|
||||
if (rentedLower != null) { ArrayPool<double>.Shared.Return(rentedLower); }
|
||||
if (rentedRawK != null) { ArrayPool<double>.Shared.Return(rentedRawK); }
|
||||
if (rentedSmK != null) { ArrayPool<double>.Shared.Return(rentedSmK); }
|
||||
if (rentedSmkUpper != null) { ArrayPool<double>.Shared.Return(rentedSmkUpper); }
|
||||
if (rentedSmkLower != null) { ArrayPool<double>.Shared.Return(rentedSmkLower); }
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TBarSeries source, int period = 21)
|
||||
{
|
||||
if (source == null || source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var times = new List<long>(len);
|
||||
var vals = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(times, len);
|
||||
CollectionsMarshal.SetCount(vals, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vals), period);
|
||||
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(times));
|
||||
|
||||
return new TSeries(times, vals);
|
||||
}
|
||||
|
||||
public static (TSeries Results, Dstoch Indicator) Calculate(
|
||||
TBarSeries source, int period = 21)
|
||||
{
|
||||
var indicator = new Dstoch(period);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# DSTOCH — Double Stochastic (Bressert DSS)
|
||||
|
||||
## Overview
|
||||
|
||||
**DSTOCH** (Double Stochastic / DSS Bressert) applies the Stochastic oscillator formula twice with EMA smoothing between stages, producing a momentum indicator bounded between 0 and 100. Developed by Walter Bressert, it is more responsive than standard Stochastic while remaining bounded.
|
||||
|
||||
| Property | Value |
|
||||
| :--------- | :-------------- |
|
||||
| Category | Oscillator |
|
||||
| Output | Single (DSS) |
|
||||
| Range | [0, 100] |
|
||||
| Default | period = 21 |
|
||||
| Input | TBar (HLC) |
|
||||
| Hot after | period bars |
|
||||
|
||||
**Source:** [Dstoch.cs](Dstoch.cs) · [PineScript](dstoch.pine)
|
||||
|
||||
---
|
||||
|
||||
## Formula
|
||||
|
||||
### Stage 1: Raw %K
|
||||
|
||||
$$
|
||||
\text{rawK}_t = \begin{cases}
|
||||
100 \cdot \frac{C_t - LL_t}{HH_t - LL_t} & \text{if } HH_t \neq LL_t \\
|
||||
0 & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
where $HH_t$ and $LL_t$ are the highest high and lowest low over the last $n$ bars.
|
||||
|
||||
### Stage 1: EMA Smoothing
|
||||
|
||||
$$
|
||||
\text{smoothK}_t = \alpha \cdot \text{rawK}_t + (1 - \alpha) \cdot \text{smoothK}_{t-1}
|
||||
$$
|
||||
|
||||
where $\alpha = \frac{2}{n + 1}$.
|
||||
|
||||
### Stage 2: Stochastic of smoothK
|
||||
|
||||
$$
|
||||
\text{dsRaw}_t = \begin{cases}
|
||||
100 \cdot \frac{\text{smoothK}_t - \min(\text{smoothK}, n)}{\max(\text{smoothK}, n) - \min(\text{smoothK}, n)} & \text{if range} > 0 \\
|
||||
0 & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
### Stage 2: EMA Smoothing (Final Output)
|
||||
|
||||
$$
|
||||
\text{DSS}_t = \alpha \cdot \text{dsRaw}_t + (1 - \alpha) \cdot \text{DSS}_{t-1}
|
||||
$$
|
||||
|
||||
---
|
||||
|
||||
## Interpretation
|
||||
|
||||
| Zone | Meaning |
|
||||
| :-------- | :------------------------------------- |
|
||||
| DSS > 80 | Overbought — potential bearish reversal|
|
||||
| DSS < 20 | Oversold — potential bullish reversal |
|
||||
| Cross 50↑ | Bullish momentum shift |
|
||||
| Cross 50↓ | Bearish momentum shift |
|
||||
|
||||
The double application of the Stochastic formula makes DSTOCH more sensitive to short-term price changes than the standard Stochastic oscillator.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. MonotonicDeque Streaming (Stage 1)
|
||||
|
||||
Two `MonotonicDeque` instances provide O(1) amortized min/max tracking for HH/LL:
|
||||
|
||||
- **Max deque**: decreasing order of highs; front is always the window maximum.
|
||||
- **Min deque**: increasing order of lows; front is always the window minimum.
|
||||
- **Circular buffers** (`_hBuf`, `_lBuf`): store raw H/L values for deque rebuild on bar correction.
|
||||
|
||||
### 2. MonotonicDeque Streaming (Stage 2)
|
||||
|
||||
A second pair of `MonotonicDeque` instances tracks `smoothK` values:
|
||||
|
||||
- **`_skMaxDeque`**: highest smoothK over the window.
|
||||
- **`_skMinDeque`**: lowest smoothK over the window.
|
||||
- **`_skBuf`**: circular buffer for smoothK values.
|
||||
|
||||
### 3. EMA Smoothing
|
||||
|
||||
Both EMA stages use `Math.FusedMultiplyAdd` for optimal precision:
|
||||
|
||||
```csharp
|
||||
smoothK = Math.FusedMultiplyAdd(prev_smoothK, decay, alpha * rawK);
|
||||
```
|
||||
|
||||
### 4. Bar Correction
|
||||
|
||||
On `isNew=false`, all four deques are rebuilt from their circular buffers via `RebuildMax`/`RebuildMin`, and the scalar state is restored from `_ps`.
|
||||
|
||||
### 5. Batch Path
|
||||
|
||||
The batch implementation uses `Highest.Batch` / `Lowest.Batch` for both stages, with `stackalloc` for ≤ 256 elements and `ArrayPool` beyond.
|
||||
|
||||
---
|
||||
|
||||
## Complexity Analysis
|
||||
|
||||
| Operation | Complexity |
|
||||
| :--------------------- | :------------- |
|
||||
| Per-update (amortized) | O(1) |
|
||||
| Per-update (worst) | O(n) |
|
||||
| Bar correction | O(n) × 4 deques|
|
||||
| Batch (N bars) | O(N) |
|
||||
| Memory (streaming) | O(n) × 3 buffers + 4 deques |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Bressert, W. (1998). *The Power of Oscillator/Cycle Combinations*
|
||||
- TradingView: DSS Bressert indicator
|
||||
- Investopedia: Double Smoothed Stochastic
|
||||
@@ -0,0 +1,27 @@
|
||||
// PineScript v6 reference for DSTOCH (Double Stochastic / Bressert DSS)
|
||||
// Apply Stochastic formula twice with EMA smoothing between stages.
|
||||
|
||||
//@version=6
|
||||
indicator("Double Stochastic (DSS Bressert)", shorttitle="DSTOCH", overlay=false)
|
||||
|
||||
period = input.int(21, "Period", minval=1)
|
||||
|
||||
// Stage 1: Raw %K
|
||||
rawK = ta.stoch(close, high, low, period)
|
||||
|
||||
// Stage 1: EMA smooth rawK → smoothK
|
||||
smoothK = ta.ema(rawK, period)
|
||||
|
||||
// Stage 2: Stochastic of smoothK
|
||||
skHigh = ta.highest(smoothK, period)
|
||||
skLow = ta.lowest(smoothK, period)
|
||||
skRange = skHigh - skLow
|
||||
dsRaw = skRange > 0 ? 100.0 * (smoothK - skLow) / skRange : 0.0
|
||||
|
||||
// Stage 2: EMA smooth dsRaw → DSS output
|
||||
dss = ta.ema(dsRaw, period)
|
||||
|
||||
plot(dss, "DSS", color=color.blue, linewidth=2)
|
||||
hline(80, "Overbought", color=color.red, linestyle=hline.style_dotted)
|
||||
hline(20, "Oversold", color=color.green, linestyle=hline.style_dotted)
|
||||
hline(50, "Midline", color=color.gray, linestyle=hline.style_dotted)
|
||||
@@ -0,0 +1,96 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class DstochIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DstochIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new DstochIndicator();
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("DSTOCH", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
Assert.Equal(0, DstochIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = new DstochIndicator();
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_ShortName_IsCorrect()
|
||||
{
|
||||
var indicator = new DstochIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal("DSTOCH 21", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new DstochIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Dstoch.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_Initialize_CreatesOneLineSeries()
|
||||
{
|
||||
var indicator = new DstochIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new DstochIndicator { Period = 5 };
|
||||
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 dssValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(dssValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstochIndicator_ProcessUpdate_NewBar_UpdatesValue()
|
||||
{
|
||||
var indicator = new DstochIndicator { Period = 5 };
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class DstochTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100.0, 0.05, 0.5, seed: 42);
|
||||
|
||||
// ── A. Constructor / defaults ──
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Default_SetsName()
|
||||
{
|
||||
var d = new Dstoch();
|
||||
Assert.Equal("Dstoch(21)", d.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Custom_SetsName()
|
||||
{
|
||||
var d = new Dstoch(10);
|
||||
Assert.Equal("Dstoch(10)", d.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Default_WarmupPeriodIsPeriod()
|
||||
{
|
||||
var d = new Dstoch(10);
|
||||
Assert.Equal(10, d.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Default_NotHotBeforeFirstBar()
|
||||
{
|
||||
var d = new Dstoch();
|
||||
Assert.False(d.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroPeriod_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Dstoch(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Dstoch(-5));
|
||||
}
|
||||
|
||||
// ── B. Core update behavior ──
|
||||
|
||||
[Fact]
|
||||
public void Update_BasicBar_ProducesFiniteResult()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
var bar = new TBar(DateTime.UtcNow, 105, 110, 100, 107, 1000);
|
||||
var result = d.Update(bar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LastMatchesReturnValue()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
var bar = new TBar(DateTime.UtcNow, 105, 110, 100, 107, 1000);
|
||||
var result = d.Update(bar);
|
||||
Assert.Equal(result.Value, d.Last.Value, 15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FalseForFirstBar_TrueAfterPeriod()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 7);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
d.Update(gbm.Next(isNew: true));
|
||||
if (i < 2) { Assert.False(d.IsHot); }
|
||||
else { Assert.True(d.IsHot); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── C. Boundedness [0, 100] ──
|
||||
|
||||
[Fact]
|
||||
public void Output_BoundedZeroToHundred()
|
||||
{
|
||||
var d = new Dstoch(10);
|
||||
var gbm = new GBM(100.0, 0.05, 0.3, seed: 11);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
d.Update(gbm.Next(isNew: true));
|
||||
if (d.IsHot)
|
||||
{
|
||||
Assert.InRange(d.Last.Value, -0.01, 100.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_ConstantBars_IsZero()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(i), 100, 100, 100, 100, 1000));
|
||||
}
|
||||
Assert.Equal(0.0, d.Last.Value, 10);
|
||||
}
|
||||
|
||||
// ── D. NaN / edge cases ──
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNHigh_ResultIsFinite()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 500));
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(1), 102, double.NaN, 92, 100, 500));
|
||||
Assert.True(double.IsFinite(d.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNVolume_NoImpact()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
var result = d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AllNaN_ReturnsNaN()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
var result = d.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0));
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
// ── E. isNew=false bar correction ──
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RewritesLastBar()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 99);
|
||||
for (int i = 0; i < 8; i++) { d.Update(gbm.Next(isNew: true)); }
|
||||
|
||||
d.Update(gbm.Next(isNew: true));
|
||||
double original = d.Last.Value;
|
||||
|
||||
// Correct with a different bar
|
||||
var corrected = new TBar(DateTime.UtcNow.AddDays(99), 200, 250, 150, 220, 5000);
|
||||
d.Update(corrected, isNew: false);
|
||||
double correctedVal = d.Last.Value;
|
||||
|
||||
Assert.NotEqual(original, correctedVal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BarCorrection_PreservesCount()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 33);
|
||||
for (int i = 0; i < 5; i++) { d.Update(gbm.Next(isNew: true)); }
|
||||
|
||||
bool hotBefore = d.IsHot;
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(99), 100, 110, 90, 105, 500), isNew: false);
|
||||
Assert.Equal(hotBefore, d.IsHot);
|
||||
}
|
||||
|
||||
// ── F. Reset ──
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 44);
|
||||
for (int i = 0; i < 20; i++) { d.Update(gbm.Next(isNew: true)); }
|
||||
Assert.True(d.IsHot);
|
||||
|
||||
d.Reset();
|
||||
Assert.False(d.IsHot);
|
||||
Assert.Equal(0.0, d.Last.Value);
|
||||
}
|
||||
|
||||
// ── G. Pub event ──
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_Fires_OnUpdate()
|
||||
{
|
||||
var d = new Dstoch(3);
|
||||
int count = 0;
|
||||
d.Pub += (object? _, in TValueEventArgs _) => count++;
|
||||
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 500));
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
// ── H. TBarSeries chaining ──
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Chaining_Works()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 55);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
source.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
var d = new Dstoch(source, 10);
|
||||
Assert.True(d.IsHot);
|
||||
Assert.True(double.IsFinite(d.Last.Value));
|
||||
}
|
||||
|
||||
// ── I. Batch methods ──
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptySpans_NoThrow()
|
||||
{
|
||||
Span<double> empty = [];
|
||||
Span<double> output = [];
|
||||
Dstoch.Batch(empty, empty, empty, output, 5);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MismatchedLength_Throws()
|
||||
{
|
||||
double[] h = [1, 2, 3];
|
||||
double[] l = [1, 2];
|
||||
double[] c = [1, 2, 3];
|
||||
double[] o = new double[3];
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Dstoch.Batch(h, l, c, o, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_OutputTooShort_Throws()
|
||||
{
|
||||
double[] h = [1, 2, 3];
|
||||
double[] l = [1, 2, 3];
|
||||
double[] c = [1, 2, 3];
|
||||
double[] o = new double[2];
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Dstoch.Batch(h, l, c, o, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_KnownValues_BoundedOutput()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 66);
|
||||
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var result = Dstoch.Batch(source, 10);
|
||||
for (int i = 10; i < result.Count; i++)
|
||||
{
|
||||
Assert.InRange(result[i].Value, -0.01, 100.01);
|
||||
}
|
||||
}
|
||||
|
||||
// ── J. Streaming ↔ Batch consistency ──
|
||||
|
||||
[Fact]
|
||||
public void Consistency_StreamingMatchesBatch()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 77);
|
||||
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var batch = Dstoch.Batch(source, period);
|
||||
|
||||
var streaming = new Dstoch(period);
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
Assert.Equal(batch[i].Value, streaming.Last.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consistency_EventBasedMatchesStreaming()
|
||||
{
|
||||
const int period = 7;
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 88);
|
||||
|
||||
var d1 = new Dstoch(period);
|
||||
var d2 = new Dstoch(period);
|
||||
var eventValues = new List<double>();
|
||||
d2.Pub += (object? _, in TValueEventArgs e) => eventValues.Add(e.Value.Value);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
d1.Update(bar);
|
||||
d2.Update(bar);
|
||||
}
|
||||
|
||||
Assert.Equal(50, eventValues.Count);
|
||||
}
|
||||
|
||||
// ── K. Large dataset stability ──
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargeDataset_NoStackOverflow()
|
||||
{
|
||||
const int N = 5000;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.3, seed: 123);
|
||||
for (int i = 0; i < N; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var result = Dstoch.Batch(source, 21);
|
||||
Assert.Equal(N, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ZeroPeriod_Throws()
|
||||
{
|
||||
double[] h = [1, 2, 3];
|
||||
double[] l = [1, 2, 3];
|
||||
double[] c = [1, 2, 3];
|
||||
double[] o = new double[3];
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Dstoch.Batch(h, l, c, o, 0));
|
||||
}
|
||||
|
||||
// ── L. Calculate factory ──
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsIndicatorAndResults()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 99);
|
||||
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var (results, indicator) = Dstoch.Calculate(source, 10);
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class DstochValidationTests
|
||||
{
|
||||
// ── Self-consistency: streaming == batch ──
|
||||
|
||||
[Fact]
|
||||
public void StreamingMatchesBatch()
|
||||
{
|
||||
const int period = 14;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.3, seed: 42);
|
||||
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var batch = Dstoch.Batch(source, period);
|
||||
|
||||
var streaming = new Dstoch(period);
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
Assert.Equal(batch[i].Value, streaming.Last.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Span matches TBarSeries batch ──
|
||||
|
||||
[Fact]
|
||||
public void SpanMatchesTBarSeries()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 55);
|
||||
for (int i = 0; i < 80; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var tbResult = Dstoch.Batch(source, period);
|
||||
|
||||
var spanOut = new double[source.Count];
|
||||
Dstoch.Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
spanOut.AsSpan(), period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(tbResult[i].Value, spanOut[i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Determinism ──
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_AcrossRuns()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 77);
|
||||
for (int i = 0; i < 60; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var r1 = Dstoch.Batch(source, period);
|
||||
var r2 = Dstoch.Batch(source, period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(r1[i].Value, r2[i].Value, 15);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Constant input ──
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_OutputIsZero()
|
||||
{
|
||||
const int period = 5;
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
bars.Add(new TBar(DateTime.UtcNow.AddDays(i), 50, 50, 50, 50, 100));
|
||||
}
|
||||
|
||||
var result = Dstoch.Batch(bars, period);
|
||||
for (int i = period; i < result.Count; i++)
|
||||
{
|
||||
Assert.Equal(0.0, result[i].Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boundedness ──
|
||||
|
||||
[Fact]
|
||||
public void Output_AlwaysBoundedZeroToHundred()
|
||||
{
|
||||
const int period = 14;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.3, seed: 88);
|
||||
for (int i = 0; i < 200; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var result = Dstoch.Batch(source, period);
|
||||
|
||||
for (int i = period; i < result.Count; i++)
|
||||
{
|
||||
Assert.InRange(result[i].Value, -0.01, 100.01);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Different periods produce different results ──
|
||||
|
||||
[Fact]
|
||||
public void DifferentPeriods_ProduceDifferentResults()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.3, seed: 99);
|
||||
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var r5 = Dstoch.Batch(source, 5);
|
||||
var r21 = Dstoch.Batch(source, 21);
|
||||
|
||||
bool anyDifferent = false;
|
||||
for (int i = 25; i < source.Count; i++)
|
||||
{
|
||||
if (Math.Abs(r5[i].Value - r21[i].Value) > 1e-6)
|
||||
{
|
||||
anyDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(anyDifferent);
|
||||
}
|
||||
|
||||
// ── Monotonic-up → high DSS ──
|
||||
|
||||
[Fact]
|
||||
public void MonotonicUp_ConvergesHighDSS()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(i), price, price + 1, price - 1, price, 1000));
|
||||
}
|
||||
Assert.True(d.Last.Value > 50.0);
|
||||
}
|
||||
|
||||
// ── Monotonic-down → low DSS ──
|
||||
|
||||
[Fact]
|
||||
public void MonotonicDown_ConvergesLowDSS()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 200 - i;
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(i), price, price + 1, price - 1, price, 1000));
|
||||
}
|
||||
Assert.True(d.Last.Value < 50.0);
|
||||
}
|
||||
|
||||
// ── Reset+replay matches fresh run ──
|
||||
|
||||
[Fact]
|
||||
public void ResetReplay_MatchesFreshRun()
|
||||
{
|
||||
const int period = 7;
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 111);
|
||||
var bars = new List<TBar>();
|
||||
for (int i = 0; i < 50; i++) { bars.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var d = new Dstoch(period);
|
||||
foreach (var bar in bars) { d.Update(bar); }
|
||||
double firstRun = d.Last.Value;
|
||||
|
||||
d.Reset();
|
||||
foreach (var bar in bars) { d.Update(bar); }
|
||||
Assert.Equal(firstRun, d.Last.Value, 12);
|
||||
}
|
||||
|
||||
// ── Primed indicator matches manual feed ──
|
||||
|
||||
[Fact]
|
||||
public void PrimedIndicator_MatchesManualFeed()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 222);
|
||||
for (int i = 0; i < 60; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var manual = new Dstoch(period);
|
||||
for (int i = 0; i < source.Count; i++) { manual.Update(source[i]); }
|
||||
|
||||
var primed = new Dstoch(period);
|
||||
primed.Prime(source);
|
||||
|
||||
Assert.Equal(manual.Last.Value, primed.Last.Value, 12);
|
||||
}
|
||||
|
||||
// ── Calculate factory consistency ──
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MatchesBatch()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = new TBarSeries();
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 333);
|
||||
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
|
||||
|
||||
var batch = Dstoch.Batch(source, period);
|
||||
var (calcResult, _) = Dstoch.Calculate(source, period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(batch[i].Value, calcResult[i].Value, 12);
|
||||
}
|
||||
}
|
||||
|
||||
// ── NaN propagation safety ──
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_NoPropagation()
|
||||
{
|
||||
var d = new Dstoch(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(i), 100 + i, 105 + i, 95 + i, 102 + i, 500));
|
||||
}
|
||||
|
||||
// Feed a NaN bar
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(10), double.NaN, double.NaN, double.NaN, double.NaN, 0));
|
||||
// Then valid data
|
||||
d.Update(new TBar(DateTime.UtcNow.AddDays(11), 112, 117, 107, 114, 500));
|
||||
Assert.True(double.IsFinite(d.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class SqueezeProIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 500, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("BB Multiplier", sortIndex: 2, 0.001, 10.0, 0.1, 1)]
|
||||
public double BbMult { get; set; } = 2.0;
|
||||
|
||||
[InputParameter("KC Wide Multiplier", sortIndex: 3, 0.001, 10.0, 0.1, 1)]
|
||||
public double KcMultWide { get; set; } = 2.0;
|
||||
|
||||
[InputParameter("KC Normal Multiplier", sortIndex: 4, 0.001, 10.0, 0.1, 1)]
|
||||
public double KcMultNormal { get; set; } = 1.5;
|
||||
|
||||
[InputParameter("KC Narrow Multiplier", sortIndex: 5, 0.001, 10.0, 0.1, 1)]
|
||||
public double KcMultNarrow { get; set; } = 1.0;
|
||||
|
||||
[InputParameter("Momentum Length", sortIndex: 6, 1, 500, 1, 0)]
|
||||
public int MomLength { get; set; } = 12;
|
||||
|
||||
[InputParameter("Momentum Smooth", sortIndex: 7, 1, 500, 1, 0)]
|
||||
public int MomSmooth { get; set; } = 6;
|
||||
|
||||
[InputParameter("Use SMA (unchecked = EMA)", sortIndex: 8)]
|
||||
public bool UseSma { get; set; } = true;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private SqueezePro _squeezePro = null!;
|
||||
private readonly LineSeries _momentumSeries;
|
||||
private readonly LineSeries _squeezeSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SQZ_PRO {Period},{BbMult},{KcMultWide},{KcMultNormal},{KcMultNarrow}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/squeeze_pro/SqueezePro.cs";
|
||||
|
||||
public SqueezeProIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "SQUEEZE_PRO";
|
||||
Description = "Squeeze Pro: Multi-level BB vs KC squeeze detection with MOM-smoothed momentum";
|
||||
|
||||
_momentumSeries = new LineSeries(name: "Momentum", color: Color.Lime, width: 2, style: LineStyle.Histogramm);
|
||||
_squeezeSeries = new LineSeries(name: "SqueezeLevel", color: Color.Red, width: 4, style: LineStyle.Dot);
|
||||
|
||||
AddLineSeries(_momentumSeries);
|
||||
AddLineSeries(_squeezeSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_squeezePro = new SqueezePro(Period, BbMult, KcMultWide, KcMultNormal, KcMultNarrow,
|
||||
MomLength, MomSmooth, UseSma);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
_ = _squeezePro.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_momentumSeries.SetValue(_squeezePro.Momentum, _squeezePro.IsHot, ShowColdValues);
|
||||
|
||||
// Plot squeeze level dot at 0 (colored by level), NaN when off
|
||||
double sqDot = _squeezePro.SqueezeLevel > 0 ? 0.0 : double.NaN;
|
||||
_squeezeSeries.SetValue(sqDot, _squeezePro.IsHot, ShowColdValues);
|
||||
|
||||
// Color squeeze dot: Red=narrow(3), Orange=normal(2), Yellow=wide(1)
|
||||
if (_squeezePro.SqueezeLevel == 3)
|
||||
{
|
||||
_squeezeSeries.Color = Color.Red;
|
||||
}
|
||||
else if (_squeezePro.SqueezeLevel == 2)
|
||||
{
|
||||
_squeezeSeries.Color = Color.Orange;
|
||||
}
|
||||
else if (_squeezePro.SqueezeLevel == 1)
|
||||
{
|
||||
_squeezeSeries.Color = Color.Yellow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// SQUEEZE_PRO: LazyBear's Squeeze Pro (enhanced TTM Squeeze)
|
||||
/// Detects multi-level volatility compressions using three Keltner Channel widths
|
||||
/// (wide, normal, narrow) against Bollinger Bands. Momentum is computed as
|
||||
/// MOM(close, momLength) smoothed by SMA or EMA.
|
||||
/// Outputs: Momentum (smoothed histogram) and SqueezeLevel (0=off, 1=wide, 2=normal, 3=narrow).
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class SqueezePro : ITValuePublisher
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _bbMult;
|
||||
private readonly double _kcMultWide;
|
||||
private readonly double _kcMultNormal;
|
||||
private readonly double _kcMultNarrow;
|
||||
private readonly int _momLength;
|
||||
private readonly int _momSmooth;
|
||||
private readonly bool _useSma;
|
||||
|
||||
// Circular buffers
|
||||
private readonly double[] _smaBuf; // close values for SMA + variance (period)
|
||||
private readonly double[] _closeBuf; // close values for MOM (momLength)
|
||||
private readonly double[] _smoothBuf; // MOM values for SMA smoothing (momSmooth)
|
||||
|
||||
// Snapshots for bar-correction rollback
|
||||
private readonly double[] _smaBufSnap;
|
||||
private readonly double[] _closeBufSnap;
|
||||
private readonly double[] _smoothBufSnap;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
// SMA + variance for Bollinger Bands
|
||||
double SmaSum, double SmaSumSq, int SmaHead, int SmaCount,
|
||||
// EMA for KC midline (bias-corrected)
|
||||
double RawEma, double EEma,
|
||||
// ATR via Wilder RMA (bias-corrected)
|
||||
double RawRma, double ERma, double PrevClose,
|
||||
// MOM close buffer tracking
|
||||
int MomHead, int MomCount,
|
||||
// SMA smoothing of MOM
|
||||
double SmoothSum, int SmoothHead, int SmoothCount,
|
||||
// EMA smoothing of MOM (for useSma=false mode)
|
||||
double RawSmoothEma, double ESmoothEma,
|
||||
// NaN substitution tracking
|
||||
double LastValidHigh, double LastValidLow, double LastValidClose);
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
private readonly TBarPublishedHandler _barHandler;
|
||||
|
||||
public string Name { get; }
|
||||
public int WarmupPeriod { get; }
|
||||
public TValue Last { get; private set; }
|
||||
|
||||
/// <summary>Smoothed momentum value (MOM smoothed by SMA or EMA).</summary>
|
||||
public double Momentum { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Squeeze level: 0=off/no squeeze, 1=wide squeeze, 2=normal squeeze, 3=narrow squeeze.
|
||||
/// Higher values indicate tighter compression.
|
||||
/// </summary>
|
||||
public int SqueezeLevel { get; private set; }
|
||||
|
||||
public bool IsHot => _s.SmoothCount >= _momSmooth && _s.MomCount >= _momLength;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public SqueezePro(int period = 20, double bbMult = 2.0,
|
||||
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
|
||||
int momLength = 12, int momSmooth = 6, bool useSma = true)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
if (bbMult <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("BB multiplier must be greater than 0", nameof(bbMult));
|
||||
}
|
||||
if (kcMultWide <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC wide multiplier must be greater than 0", nameof(kcMultWide));
|
||||
}
|
||||
if (kcMultNormal <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC normal multiplier must be greater than 0", nameof(kcMultNormal));
|
||||
}
|
||||
if (kcMultNarrow <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC narrow multiplier must be greater than 0", nameof(kcMultNarrow));
|
||||
}
|
||||
if (momLength <= 0)
|
||||
{
|
||||
throw new ArgumentException("Momentum length must be greater than 0", nameof(momLength));
|
||||
}
|
||||
if (momSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("Momentum smooth must be greater than 0", nameof(momSmooth));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_bbMult = bbMult;
|
||||
_kcMultWide = kcMultWide;
|
||||
_kcMultNormal = kcMultNormal;
|
||||
_kcMultNarrow = kcMultNarrow;
|
||||
_momLength = momLength;
|
||||
_momSmooth = momSmooth;
|
||||
_useSma = useSma;
|
||||
|
||||
_smaBuf = new double[period];
|
||||
_closeBuf = new double[momLength];
|
||||
_smoothBuf = new double[momSmooth];
|
||||
_smaBufSnap = new double[period];
|
||||
_closeBufSnap = new double[momLength];
|
||||
_smoothBufSnap = new double[momSmooth];
|
||||
|
||||
Array.Fill(_smaBuf, double.NaN);
|
||||
Array.Fill(_closeBuf, double.NaN);
|
||||
Array.Fill(_smoothBuf, double.NaN);
|
||||
|
||||
_s = MakeInitialState();
|
||||
_ps = _s;
|
||||
|
||||
Name = $"SqueezePro({period},{bbMult},{kcMultWide},{kcMultNormal},{kcMultNarrow})";
|
||||
WarmupPeriod = Math.Max(period, momLength + momSmooth);
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
public SqueezePro(TBarSeries source, int period = 20, double bbMult = 2.0,
|
||||
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
|
||||
int momLength = 12, int momSmooth = 6, bool useSma = true)
|
||||
: this(period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma)
|
||||
{
|
||||
Prime(source);
|
||||
source.Pub += _barHandler;
|
||||
}
|
||||
|
||||
private static State MakeInitialState() =>
|
||||
new(SmaSum: 0.0, SmaSumSq: 0.0, SmaHead: 0, SmaCount: 0,
|
||||
RawEma: 0.0, EEma: 1.0,
|
||||
RawRma: 0.0, ERma: 1.0, PrevClose: double.NaN,
|
||||
MomHead: 0, MomCount: 0,
|
||||
SmoothSum: 0.0, SmoothHead: 0, SmoothCount: 0,
|
||||
RawSmoothEma: 0.0, ESmoothEma: 1.0,
|
||||
LastValidHigh: double.NaN, LastValidLow: double.NaN, LastValidClose: double.NaN);
|
||||
|
||||
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void PubEvent(TValue value, bool isNew = true) =>
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void UpdateSmaBuf(ref State s, double close)
|
||||
{
|
||||
double oldVal = _smaBuf[s.SmaHead];
|
||||
if (double.IsNaN(oldVal))
|
||||
{
|
||||
s.SmaCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.SmaSum -= oldVal;
|
||||
s.SmaSumSq -= oldVal * oldVal;
|
||||
}
|
||||
s.SmaSum += close;
|
||||
s.SmaSumSq += close * close;
|
||||
_smaBuf[s.SmaHead] = close;
|
||||
s.SmaHead = (s.SmaHead + 1) % _period;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double UpdateMomBuf(ref State s, double close)
|
||||
{
|
||||
double laggedClose = _closeBuf[s.MomHead];
|
||||
_closeBuf[s.MomHead] = close;
|
||||
s.MomHead = (s.MomHead + 1) % _momLength;
|
||||
|
||||
if (s.MomCount < _momLength)
|
||||
{
|
||||
s.MomCount++;
|
||||
return double.NaN; // not enough data for MOM yet
|
||||
}
|
||||
|
||||
// MOM = close - close[momLength bars ago]
|
||||
return close - laggedClose;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double UpdateSmoothBuf(ref State s, double mom)
|
||||
{
|
||||
if (_useSma)
|
||||
{
|
||||
// SMA smoothing
|
||||
double oldVal = _smoothBuf[s.SmoothHead];
|
||||
if (double.IsNaN(oldVal))
|
||||
{
|
||||
s.SmoothCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.SmoothSum -= oldVal;
|
||||
}
|
||||
s.SmoothSum += mom;
|
||||
_smoothBuf[s.SmoothHead] = mom;
|
||||
s.SmoothHead = (s.SmoothHead + 1) % _momSmooth;
|
||||
|
||||
return s.SmoothSum / Math.Max(1, s.SmoothCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EMA smoothing (bias-corrected)
|
||||
const double EPSILON = 1e-10;
|
||||
double alpha = 2.0 / (_momSmooth + 1.0);
|
||||
double beta = 1.0 - alpha;
|
||||
|
||||
s.RawSmoothEma = Math.FusedMultiplyAdd(s.RawSmoothEma, beta, alpha * mom);
|
||||
s.ESmoothEma *= beta;
|
||||
double c = s.ESmoothEma > EPSILON ? 1.0 / (1.0 - s.ESmoothEma) : 1.0;
|
||||
s.SmoothCount = Math.Min(s.SmoothCount + 1, _momSmooth);
|
||||
return s.RawSmoothEma * c;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
Array.Copy(_smaBuf, _smaBufSnap, _period);
|
||||
Array.Copy(_closeBuf, _closeBufSnap, _momLength);
|
||||
Array.Copy(_smoothBuf, _smoothBufSnap, _momSmooth);
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
Array.Copy(_smaBufSnap, _smaBuf, _period);
|
||||
Array.Copy(_closeBufSnap, _closeBuf, _momLength);
|
||||
Array.Copy(_smoothBufSnap, _smoothBuf, _momSmooth);
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// === NaN/Infinity substitution (last-valid-value) ===
|
||||
double high = input.High;
|
||||
double low = input.Low;
|
||||
double close = input.Close;
|
||||
|
||||
if (double.IsFinite(high)) { s.LastValidHigh = high; }
|
||||
else { high = s.LastValidHigh; }
|
||||
|
||||
if (double.IsFinite(low)) { s.LastValidLow = low; }
|
||||
else { low = s.LastValidLow; }
|
||||
|
||||
if (double.IsFinite(close)) { s.LastValidClose = close; }
|
||||
else { close = s.LastValidClose; }
|
||||
|
||||
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
|
||||
{
|
||||
_s = s;
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
Momentum = double.NaN;
|
||||
SqueezeLevel = 0;
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// ===== STAGE 1: SMA + Variance → Bollinger Bands =====
|
||||
UpdateSmaBuf(ref s, close);
|
||||
|
||||
int n = Math.Max(1, s.SmaCount);
|
||||
double smaVal = s.SmaSum / n;
|
||||
double variance = Math.Max(0.0, (s.SmaSumSq / n) - (smaVal * smaVal));
|
||||
double stddev = Math.Sqrt(variance);
|
||||
double bbUpper = Math.FusedMultiplyAdd(_bbMult, stddev, smaVal);
|
||||
double bbLower = Math.FusedMultiplyAdd(-_bbMult, stddev, smaVal);
|
||||
|
||||
// ===== STAGE 2: EMA + ATR via RMA → Keltner Channels =====
|
||||
const double EPSILON = 1e-10;
|
||||
double emaAlpha = 2.0 / (_period + 1.0);
|
||||
double emaBeta = 1.0 - emaAlpha;
|
||||
double rmaAlpha = 1.0 / _period;
|
||||
double rmaBeta = 1.0 - rmaAlpha;
|
||||
|
||||
s.RawEma = Math.FusedMultiplyAdd(s.RawEma, emaBeta, emaAlpha * close);
|
||||
s.EEma *= emaBeta;
|
||||
double cEma = s.EEma > EPSILON ? 1.0 / (1.0 - s.EEma) : 1.0;
|
||||
double emaVal = s.RawEma * cEma;
|
||||
|
||||
// True Range
|
||||
double tr = high - low;
|
||||
if (double.IsFinite(s.PrevClose))
|
||||
{
|
||||
double hiPrev = Math.Abs(high - s.PrevClose);
|
||||
double loPrev = Math.Abs(low - s.PrevClose);
|
||||
if (hiPrev > tr) { tr = hiPrev; }
|
||||
if (loPrev > tr) { tr = loPrev; }
|
||||
}
|
||||
s.PrevClose = close;
|
||||
|
||||
s.RawRma = Math.FusedMultiplyAdd(s.RawRma, rmaBeta, rmaAlpha * tr);
|
||||
s.ERma *= rmaBeta;
|
||||
double cRma = s.ERma > EPSILON ? 1.0 / (1.0 - s.ERma) : 1.0;
|
||||
double atr = s.RawRma * cRma;
|
||||
|
||||
// Three KC widths
|
||||
double kcWideUpper = Math.FusedMultiplyAdd(_kcMultWide, atr, emaVal);
|
||||
double kcWideLower = Math.FusedMultiplyAdd(-_kcMultWide, atr, emaVal);
|
||||
double kcNormalUpper = Math.FusedMultiplyAdd(_kcMultNormal, atr, emaVal);
|
||||
double kcNormalLower = Math.FusedMultiplyAdd(-_kcMultNormal, atr, emaVal);
|
||||
double kcNarrowUpper = Math.FusedMultiplyAdd(_kcMultNarrow, atr, emaVal);
|
||||
double kcNarrowLower = Math.FusedMultiplyAdd(-_kcMultNarrow, atr, emaVal);
|
||||
|
||||
// ===== STAGE 3: Squeeze level classification =====
|
||||
// 3 = narrow (tightest): BB inside KC_narrow
|
||||
// 2 = normal: BB inside KC_normal but not KC_narrow
|
||||
// 1 = wide: BB inside KC_wide but not KC_normal
|
||||
// 0 = off: BB outside KC_wide (expansion)
|
||||
int sqLevel;
|
||||
bool insideNarrow = bbUpper < kcNarrowUpper && bbLower > kcNarrowLower;
|
||||
bool insideNormal = bbUpper < kcNormalUpper && bbLower > kcNormalLower;
|
||||
bool insideWide = bbUpper < kcWideUpper && bbLower > kcWideLower;
|
||||
|
||||
if (insideNarrow) { sqLevel = 3; }
|
||||
else if (insideNormal) { sqLevel = 2; }
|
||||
else if (insideWide) { sqLevel = 1; }
|
||||
else { sqLevel = 0; }
|
||||
|
||||
// ===== STAGE 4: MOM = close - close[momLength ago] =====
|
||||
double rawMom = UpdateMomBuf(ref s, close);
|
||||
|
||||
// ===== STAGE 5: Smooth MOM via SMA or EMA =====
|
||||
// Use 0.0 for insufficient MOM data (matches batch path)
|
||||
double momVal = double.IsNaN(rawMom) ? 0.0 : rawMom;
|
||||
double momentum = UpdateSmoothBuf(ref s, momVal);
|
||||
|
||||
_s = s;
|
||||
|
||||
Momentum = momentum;
|
||||
SqueezeLevel = sqLevel;
|
||||
Last = new TValue(input.Time, momentum);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true) =>
|
||||
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
|
||||
|
||||
public (TSeries Momentum, TSeries SqueezeLevel) Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var tMom = new List<long>(len);
|
||||
var vMom = new List<double>(len);
|
||||
var tSq = new List<long>(len);
|
||||
var vSq = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tMom, len);
|
||||
CollectionsMarshal.SetCount(vMom, len);
|
||||
CollectionsMarshal.SetCount(tSq, len);
|
||||
CollectionsMarshal.SetCount(vSq, len);
|
||||
|
||||
var vMomSpan = CollectionsMarshal.AsSpan(vMom);
|
||||
var vSqSpan = CollectionsMarshal.AsSpan(vSq);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
vMomSpan, vSqSpan, _period, _bbMult, _kcMultWide, _kcMultNormal, _kcMultNarrow,
|
||||
_momLength, _momSmooth, _useSma);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tMom);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tSq));
|
||||
|
||||
Prime(source);
|
||||
|
||||
if (len > 0)
|
||||
{
|
||||
Momentum = vMomSpan[^1];
|
||||
SqueezeLevel = (int)vSqSpan[^1];
|
||||
Last = new TValue(new DateTime(source.Times[^1], DateTimeKind.Utc), Momentum);
|
||||
}
|
||||
|
||||
return (new TSeries(tMom, vMom), new TSeries(tSq, vSq));
|
||||
}
|
||||
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Array.Fill(_smaBuf, double.NaN);
|
||||
Array.Fill(_closeBuf, double.NaN);
|
||||
Array.Fill(_smoothBuf, double.NaN);
|
||||
Array.Fill(_smaBufSnap, double.NaN);
|
||||
Array.Fill(_closeBufSnap, double.NaN);
|
||||
Array.Fill(_smoothBufSnap, double.NaN);
|
||||
_s = MakeInitialState();
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
Momentum = 0.0;
|
||||
SqueezeLevel = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Span-based batch Squeeze Pro calculation.
|
||||
/// </summary>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> momOut,
|
||||
Span<double> sqOut,
|
||||
int period = 20,
|
||||
double bbMult = 2.0,
|
||||
double kcMultWide = 2.0,
|
||||
double kcMultNormal = 1.5,
|
||||
double kcMultNarrow = 1.0,
|
||||
int momLength = 12,
|
||||
int momSmooth = 6,
|
||||
bool useSma = true)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
if (bbMult <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("BB multiplier must be greater than 0", nameof(bbMult));
|
||||
}
|
||||
if (kcMultWide <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC wide multiplier must be greater than 0", nameof(kcMultWide));
|
||||
}
|
||||
if (kcMultNormal <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC normal multiplier must be greater than 0", nameof(kcMultNormal));
|
||||
}
|
||||
if (kcMultNarrow <= 0.0)
|
||||
{
|
||||
throw new ArgumentException("KC narrow multiplier must be greater than 0", nameof(kcMultNarrow));
|
||||
}
|
||||
if (momLength <= 0)
|
||||
{
|
||||
throw new ArgumentException("Momentum length must be greater than 0", nameof(momLength));
|
||||
}
|
||||
if (momSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("Momentum smooth must be greater than 0", nameof(momSmooth));
|
||||
}
|
||||
if (high.Length != low.Length || high.Length != close.Length)
|
||||
{
|
||||
throw new ArgumentException("Input spans must have the same length", nameof(high));
|
||||
}
|
||||
if (momOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("Momentum output span must be at least as long as input", nameof(momOut));
|
||||
}
|
||||
if (sqOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("SqueezeLevel output span must be at least as long as input", nameof(sqOut));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0) { return; }
|
||||
|
||||
const int StackallocThreshold = 256;
|
||||
int totalBuf = period + momLength + momSmooth;
|
||||
|
||||
double[]? rented = null;
|
||||
scoped Span<double> smaBuf;
|
||||
scoped Span<double> closeBuf;
|
||||
scoped Span<double> smoothBuf;
|
||||
|
||||
if (totalBuf <= StackallocThreshold)
|
||||
{
|
||||
Span<double> allBuf = stackalloc double[totalBuf];
|
||||
smaBuf = allBuf.Slice(0, period);
|
||||
closeBuf = allBuf.Slice(period, momLength);
|
||||
smoothBuf = allBuf.Slice(period + momLength, momSmooth);
|
||||
}
|
||||
else
|
||||
{
|
||||
rented = ArrayPool<double>.Shared.Rent(totalBuf);
|
||||
smaBuf = rented.AsSpan(0, period);
|
||||
closeBuf = rented.AsSpan(period, momLength);
|
||||
smoothBuf = rented.AsSpan(period + momLength, momSmooth);
|
||||
}
|
||||
|
||||
smaBuf.Fill(double.NaN);
|
||||
closeBuf.Fill(double.NaN);
|
||||
smoothBuf.Fill(double.NaN);
|
||||
|
||||
try
|
||||
{
|
||||
BatchCore(high, low, close, momOut, sqOut, period, bbMult,
|
||||
kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma,
|
||||
smaBuf, closeBuf, smoothBuf);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rented != null) { ArrayPool<double>.Shared.Return(rented); }
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Momentum, TSeries SqueezeLevel) Batch(
|
||||
TBarSeries source, int period = 20, double bbMult = 2.0,
|
||||
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
|
||||
int momLength = 12, int momSmooth = 6, bool useSma = true)
|
||||
{
|
||||
if (source == null || source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var tMom = new List<long>(len);
|
||||
var vMom = new List<double>(len);
|
||||
var tSq = new List<long>(len);
|
||||
var vSq = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tMom, len);
|
||||
CollectionsMarshal.SetCount(vMom, len);
|
||||
CollectionsMarshal.SetCount(tSq, len);
|
||||
CollectionsMarshal.SetCount(vSq, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vMom),
|
||||
CollectionsMarshal.AsSpan(vSq),
|
||||
period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tMom);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tSq));
|
||||
|
||||
return (new TSeries(tMom, vMom), new TSeries(tSq, vSq));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ((TSeries Momentum, TSeries SqueezeLevel) Results, SqueezePro Indicator) Calculate(
|
||||
TBarSeries source, int period = 20, double bbMult = 2.0,
|
||||
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
|
||||
int momLength = 12, int momSmooth = 6, bool useSma = true)
|
||||
{
|
||||
var indicator = new SqueezePro(period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
private static void BatchCore(
|
||||
ReadOnlySpan<double> high, ReadOnlySpan<double> low, ReadOnlySpan<double> close,
|
||||
Span<double> momOut, Span<double> sqOut,
|
||||
int period, double bbMult,
|
||||
double kcMultWide, double kcMultNormal, double kcMultNarrow,
|
||||
int momLength, int momSmooth, bool useSma,
|
||||
Span<double> smaBuf, Span<double> closeBuf, Span<double> smoothBuf)
|
||||
{
|
||||
int len = high.Length;
|
||||
int smaHead = 0, smaCount = 0;
|
||||
double smaSum = 0.0, smaSumSq = 0.0;
|
||||
|
||||
double rawEma = 0.0, eEma = 1.0;
|
||||
double rawRma = 0.0, eRma = 1.0;
|
||||
double prevClose = double.NaN;
|
||||
|
||||
int momHead = 0, momCount = 0;
|
||||
double smoothSum = 0.0;
|
||||
int smoothHead = 0, smoothCount = 0;
|
||||
double rawSmoothEma = 0.0, eSmoothEma = 1.0;
|
||||
|
||||
double emaAlpha = 2.0 / (period + 1.0);
|
||||
double emaBeta = 1.0 - emaAlpha;
|
||||
double rmaAlpha = 1.0 / period;
|
||||
double rmaBeta = 1.0 - rmaAlpha;
|
||||
const double EPSILON = 1e-10;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double h = high[i];
|
||||
double l = low[i];
|
||||
double c = close[i];
|
||||
if (!double.IsFinite(h)) { h = 0.0; }
|
||||
if (!double.IsFinite(l)) { l = 0.0; }
|
||||
if (!double.IsFinite(c)) { c = 0.0; }
|
||||
|
||||
// Stage 1: SMA + StdDev for BB
|
||||
double oldSma = smaBuf[smaHead];
|
||||
if (double.IsNaN(oldSma))
|
||||
{
|
||||
smaCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
smaSum -= oldSma;
|
||||
smaSumSq -= oldSma * oldSma;
|
||||
}
|
||||
smaSum += c;
|
||||
smaSumSq += c * c;
|
||||
smaBuf[smaHead] = c;
|
||||
smaHead = (smaHead + 1) % period;
|
||||
|
||||
int n = Math.Max(1, smaCount);
|
||||
double smaVal = smaSum / n;
|
||||
double vari = Math.Max(0.0, (smaSumSq / n) - (smaVal * smaVal));
|
||||
double sd = Math.Sqrt(vari);
|
||||
double bbUpper = Math.FusedMultiplyAdd(bbMult, sd, smaVal);
|
||||
double bbLower = Math.FusedMultiplyAdd(-bbMult, sd, smaVal);
|
||||
|
||||
// Stage 2: EMA + ATR for KC
|
||||
rawEma = Math.FusedMultiplyAdd(rawEma, emaBeta, emaAlpha * c);
|
||||
eEma *= emaBeta;
|
||||
double cEma = eEma > EPSILON ? 1.0 / (1.0 - eEma) : 1.0;
|
||||
double emaVal = rawEma * cEma;
|
||||
|
||||
double tr = h - l;
|
||||
if (double.IsFinite(prevClose))
|
||||
{
|
||||
double hp = Math.Abs(h - prevClose);
|
||||
double lp = Math.Abs(l - prevClose);
|
||||
if (hp > tr) { tr = hp; }
|
||||
if (lp > tr) { tr = lp; }
|
||||
}
|
||||
prevClose = c;
|
||||
|
||||
rawRma = Math.FusedMultiplyAdd(rawRma, rmaBeta, rmaAlpha * tr);
|
||||
eRma *= rmaBeta;
|
||||
double cRma = eRma > EPSILON ? 1.0 / (1.0 - eRma) : 1.0;
|
||||
double atr = rawRma * cRma;
|
||||
|
||||
// Three KC widths
|
||||
double kcWU = Math.FusedMultiplyAdd(kcMultWide, atr, emaVal);
|
||||
double kcWL = Math.FusedMultiplyAdd(-kcMultWide, atr, emaVal);
|
||||
double kcNU = Math.FusedMultiplyAdd(kcMultNormal, atr, emaVal);
|
||||
double kcNL = Math.FusedMultiplyAdd(-kcMultNormal, atr, emaVal);
|
||||
double kcRU = Math.FusedMultiplyAdd(kcMultNarrow, atr, emaVal);
|
||||
double kcRL = Math.FusedMultiplyAdd(-kcMultNarrow, atr, emaVal);
|
||||
|
||||
// Stage 3: Squeeze classification
|
||||
bool insideNarrow = bbUpper < kcRU && bbLower > kcRL;
|
||||
bool insideNormal = bbUpper < kcNU && bbLower > kcNL;
|
||||
bool insideWide = bbUpper < kcWU && bbLower > kcWL;
|
||||
|
||||
double sqVal;
|
||||
if (insideNarrow) { sqVal = 3.0; }
|
||||
else if (insideNormal) { sqVal = 2.0; }
|
||||
else if (insideWide) { sqVal = 1.0; }
|
||||
else { sqVal = 0.0; }
|
||||
|
||||
// Stage 4: MOM = close - close[momLength ago]
|
||||
double laggedClose = closeBuf[momHead];
|
||||
closeBuf[momHead] = c;
|
||||
momHead = (momHead + 1) % momLength;
|
||||
double rawMom;
|
||||
if (momCount < momLength)
|
||||
{
|
||||
momCount++;
|
||||
rawMom = 0.0; // not enough data yet
|
||||
}
|
||||
else
|
||||
{
|
||||
rawMom = c - laggedClose;
|
||||
}
|
||||
|
||||
// Stage 5: Smooth MOM
|
||||
double momentum;
|
||||
if (useSma)
|
||||
{
|
||||
double oldSmooth = smoothBuf[smoothHead];
|
||||
if (double.IsNaN(oldSmooth))
|
||||
{
|
||||
smoothCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
smoothSum -= oldSmooth;
|
||||
}
|
||||
smoothSum += rawMom;
|
||||
smoothBuf[smoothHead] = rawMom;
|
||||
smoothHead = (smoothHead + 1) % momSmooth;
|
||||
momentum = smoothSum / Math.Max(1, smoothCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
double smAlpha = 2.0 / (momSmooth + 1.0);
|
||||
double smBeta = 1.0 - smAlpha;
|
||||
rawSmoothEma = Math.FusedMultiplyAdd(rawSmoothEma, smBeta, smAlpha * rawMom);
|
||||
eSmoothEma *= smBeta;
|
||||
double smC = eSmoothEma > EPSILON ? 1.0 / (1.0 - eSmoothEma) : 1.0;
|
||||
momentum = rawSmoothEma * smC;
|
||||
}
|
||||
|
||||
momOut[i] = momentum;
|
||||
sqOut[i] = sqVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# SQUEEZE_PRO: LazyBear's Squeeze Pro
|
||||
|
||||
> *Standard Squeeze uses one Keltner width. Squeeze Pro adds two more — because the market doesn't only compress one way.*
|
||||
|
||||
| Property | Value |
|
||||
| ---------------- | -------------------------------- |
|
||||
| **Category** | Oscillator |
|
||||
| **Inputs** | OHLCV bar (TBar) |
|
||||
| **Parameters** | `period` (20), `bbMult` (2.0), `kcMultWide` (2.0), `kcMultNormal` (1.5), `kcMultNarrow` (1.0), `momLength` (12), `momSmooth` (6), `useSma` (true) |
|
||||
| **Outputs** | Dual: Momentum (double) + SqueezeLevel (int 0–3) |
|
||||
| **Output range** | Momentum: unbounded; SqueezeLevel: {0, 1, 2, 3} |
|
||||
| **Warmup** | `max(period, momLength + momSmooth)` bars |
|
||||
| **PineScript** | [squeeze_pro.pine](squeeze_pro.pine) |
|
||||
|
||||
- LazyBear's Squeeze Pro enhances the standard TTM Squeeze by replacing the single Keltner Channel width with three graduated Keltner widths (wide, normal, narrow), and substituting MOM+SMA smoothing for linear regression momentum.
|
||||
- **Similar:** [SQUEEZE](../squeeze/Squeeze.md), [TTM_SQUEEZE](../../dynamics/ttm_squeeze/TtmSqueeze.md), [BBS](../bbs/Bbs.md) | **Complementary:** ATR, BB | **Trading note:** Level 3 (narrow) = tightest compression, expect explosive breakout. Level 0 = expansion phase.
|
||||
- Cross-validated streaming vs batch and across SMA/EMA smoothing modes.
|
||||
|
||||
## Historical Context
|
||||
|
||||
LazyBear's Squeeze Pro appeared on TradingView as an enhanced version of John Carter's TTM Squeeze, addressing a fundamental limitation: the original Squeeze only uses a single Keltner Channel width, providing a binary "squeeze on/off" signal. In practice, volatility compression exists on a spectrum — a market can be lightly compressed (BB barely inside KC) or severely compressed (BB well inside even a narrow KC). The three-level classification captures this gradient: wide squeeze (initial compression), normal squeeze (significant compression), and narrow squeeze (extreme compression that often precedes the largest moves). The momentum component was simplified from Carter's linear regression approach to a straightforward MOM(close, n) smoothed by SMA or EMA, making the indicator more responsive and easier to interpret.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### Computational Stages
|
||||
|
||||
1. **SMA + Standard Deviation** (Bollinger Bands): Circular buffer with running sum and sum-of-squares for O(1) variance computation. BB upper/lower = SMA $\pm$ bbMult $\times$ StdDev.
|
||||
|
||||
2. **EMA + ATR via RMA** (Keltner Channels): A single EMA and ATR computation shared across all three KC widths. Only the multiplier differs:
|
||||
- KC Wide: EMA $\pm$ kcMultWide $\times$ ATR
|
||||
- KC Normal: EMA $\pm$ kcMultNormal $\times$ ATR
|
||||
- KC Narrow: EMA $\pm$ kcMultNarrow $\times$ ATR
|
||||
|
||||
3. **Squeeze classification:** Hierarchical check from tightest to widest:
|
||||
- Level 3 (narrow): BB inside KC_narrow
|
||||
- Level 2 (normal): BB inside KC_normal but not KC_narrow
|
||||
- Level 1 (wide): BB inside KC_wide but not KC_normal
|
||||
- Level 0 (off): BB outside KC_wide
|
||||
|
||||
4. **Momentum (MOM):** Simple momentum = close $-$ close\[momLength bars ago\]. Requires a circular buffer of `momLength` close values.
|
||||
|
||||
5. **Smooth MOM:** SMA or EMA of the raw momentum values over `momSmooth` period.
|
||||
|
||||
### Warmup Compensation
|
||||
|
||||
EMA and RMA stages use the $e = \beta^n$ warmup tracking with correction factor $c = 1/(1-e)$ to eliminate initial bias.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
**Bollinger Bands** (SMA + StdDev via running sums):
|
||||
|
||||
$$\mu = \frac{\Sigma x}{n}, \quad \sigma = \sqrt{\frac{\Sigma x^2}{n} - \mu^2}$$
|
||||
|
||||
$$BB_{upper} = \mu + m_{bb} \cdot \sigma, \quad BB_{lower} = \mu - m_{bb} \cdot \sigma$$
|
||||
|
||||
**Keltner Channel** (EMA + ATR):
|
||||
|
||||
$$EMA_t = \frac{\hat{E}_t}{1 - \beta^t}, \quad ATR_t = \frac{\hat{R}_t}{1 - \beta_r^t}$$
|
||||
|
||||
$$KC_{upper}^{(w)} = EMA + m_w \cdot ATR, \quad KC_{lower}^{(w)} = EMA - m_w \cdot ATR$$
|
||||
|
||||
where $w \in \{wide, normal, narrow\}$.
|
||||
|
||||
**Squeeze level:**
|
||||
|
||||
$$SqueezeLevel = \begin{cases} 3 & \text{if } BB \subset KC_{narrow} \\ 2 & \text{if } BB \subset KC_{normal} \setminus KC_{narrow} \\ 1 & \text{if } BB \subset KC_{wide} \setminus KC_{normal} \\ 0 & \text{otherwise (expansion)} \end{cases}$$
|
||||
|
||||
**Momentum:**
|
||||
|
||||
$$MOM_t = close_t - close_{t - momLength}$$
|
||||
|
||||
$$Momentum_t = SMA(MOM, momSmooth) \text{ or } EMA(MOM, momSmooth)$$
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Operation | Count per bar |
|
||||
| --- | --- |
|
||||
| ADD/SUB | ~20 |
|
||||
| MUL | ~12 |
|
||||
| DIV | 4 |
|
||||
| CMP | 6 |
|
||||
| SQRT | 1 |
|
||||
| FMA | 8 |
|
||||
|
||||
Three circular buffers (`period` + `momLength` + `momSmooth`) with snapshot/rollback for bar correction. Memory: $O(period + momLength + momSmooth)$ per instance.
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| pandas-ta | Algorithm reference | Verified algorithm from source |
|
||||
| Self-consistency | ✅ Pass | Streaming = Batch = Eventing |
|
||||
| Determinism | ✅ Pass | Same seed → identical output |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **KC multiplier ordering:** Ensure kcMultWide > kcMultNormal > kcMultNarrow for meaningful level classification. The algorithm works with any positive values, but inverted ordering produces unintuitive results.
|
||||
2. **Momentum warmup:** First `momLength` bars produce MOM = 0 (no lagged close available). Full momentum accuracy requires `momLength + momSmooth` bars.
|
||||
3. **SMA vs EMA smoothing:** SMA produces equal-weight smoothing (more stable); EMA gives more weight to recent momentum (more responsive). Both produce valid signals but differ numerically.
|
||||
4. **Squeeze level vs squeeze state:** Level 0 doesn't mean "no squeeze ever happened" — it means BB is currently outside KC_wide (expansion phase). The transition from level 3→0 is the breakout signal.
|
||||
5. **Memory footprint:** Three circular buffers plus three snapshot arrays. For very large `period`, ArrayPool is used automatically in batch mode.
|
||||
|
||||
## References
|
||||
|
||||
- LazyBear, "Squeeze Momentum Indicator [LazyBear]" (TradingView)
|
||||
- pandas-ta `squeeze_pro` implementation (GitHub)
|
||||
- John Carter, *Mastering the Trade* (2005) — original TTM Squeeze concept
|
||||
@@ -0,0 +1,117 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SqueezeProIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Indicator_Can_Be_Constructed()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.NotNull(indicator);
|
||||
Assert.Equal("SQUEEZE_PRO", indicator.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_Period()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(20, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_BbMult()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(2.0, indicator.BbMult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultWide()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(2.0, indicator.KcMultWide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultNormal()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(1.5, indicator.KcMultNormal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultNarrow()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(1.0, indicator.KcMultNarrow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_MomLength()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(12, indicator.MomLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_MomSmooth()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(6, indicator.MomSmooth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_UseSma()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.True(indicator.UseSma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_ShortName_Format()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Contains("SQZ_PRO", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Properties_Can_Be_Set()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator
|
||||
{
|
||||
Period = 30,
|
||||
BbMult = 2.5,
|
||||
KcMultWide = 3.0,
|
||||
KcMultNormal = 2.0,
|
||||
KcMultNarrow = 1.5,
|
||||
MomLength = 15,
|
||||
MomSmooth = 8,
|
||||
UseSma = false
|
||||
};
|
||||
|
||||
Assert.Equal(30, indicator.Period);
|
||||
Assert.Equal(2.5, indicator.BbMult);
|
||||
Assert.Equal(3.0, indicator.KcMultWide);
|
||||
Assert.Equal(2.0, indicator.KcMultNormal);
|
||||
Assert.Equal(1.5, indicator.KcMultNarrow);
|
||||
Assert.Equal(15, indicator.MomLength);
|
||||
Assert.Equal(8, indicator.MomSmooth);
|
||||
Assert.False(indicator.UseSma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_SourceCodeLink_Valid()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Contains("SqueezePro.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_ShowColdValues_Default()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SqueezeProTests
|
||||
{
|
||||
private static TBarSeries GenerateBars(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
// === A) Constructor validation ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(period: -1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidBbMult_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(bbMult: 0.0));
|
||||
Assert.Equal("bbMult", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeBbMult_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(bbMult: -1.0));
|
||||
Assert.Equal("bbMult", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultWide_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultWide: 0.0));
|
||||
Assert.Equal("kcMultWide", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultNormal_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultNormal: 0.0));
|
||||
Assert.Equal("kcMultNormal", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultNarrow_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultNarrow: 0.0));
|
||||
Assert.Equal("kcMultNarrow", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidMomLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(momLength: 0));
|
||||
Assert.Equal("momLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidMomSmooth_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(momSmooth: 0));
|
||||
Assert.Equal("momSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParams()
|
||||
{
|
||||
var sq = new SqueezePro();
|
||||
Assert.Equal("SqueezePro(20,2,2,1.5,1)", sq.Name);
|
||||
Assert.Equal(20, sq.WarmupPeriod); // Max(20, 12+6=18) = 20
|
||||
}
|
||||
|
||||
// === B) Basic calculation ===
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 101, 1000);
|
||||
TValue result = sq.Update(bar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_Momentum_Accessible()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 105 + i, 95 + i, 101 + i, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(double.IsFinite(sq.Last.Value));
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
Assert.NotEmpty(sq.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SqueezeLevel_IsInRange()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 101, 99, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.InRange(sq.SqueezeLevel, 0, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_MomentumNearZero()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// With constant price, MOM = 0 at all times, smooth of zero = 0
|
||||
Assert.Equal(0.0, sq.Momentum, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_SqueezeLevel3_NarrowSqueeze()
|
||||
{
|
||||
// With constant price, BB width = 0, all KCs have width > 0 from ATR
|
||||
// Actually with constant price, ATR → 0 too, so both BB and KC collapse
|
||||
// BB upper < KC upper when stddev * bbMult < atr * kcMult
|
||||
// For constant bars: stddev=0, atr=0, so bbUpper = smaVal = kcUpper → not inside
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// Both collapse to same value, so bbUpper == kcUpper (not strictly less) → level 0
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RisingBars_PositiveMomentum_AfterWarmup()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
double price = 100.0 + i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(sq.Momentum > 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallingBars_NegativeMomentum_AfterWarmup()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
double price = 200.0 - i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(sq.Momentum < 0.0);
|
||||
}
|
||||
|
||||
// === C) Squeeze level detection ===
|
||||
|
||||
[Fact]
|
||||
public void HighVolatility_SqueezeLevelZero()
|
||||
{
|
||||
// Wide BB (high vol) with tight KC should push BB outside KC → squeeze off
|
||||
// Use very small KC multipliers so KC is narrow relative to BB
|
||||
var sq = new SqueezePro(period: 10, momLength: 3, momSmooth: 2,
|
||||
kcMultWide: 0.1, kcMultNormal: 0.05, kcMultNarrow: 0.01);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
// Alternating large swings to create wide BB
|
||||
double swing = (i % 2 == 0) ? 50.0 : -50.0;
|
||||
double price = 100.0 + swing;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 20, price - 20, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TightRange_SqueezeOn()
|
||||
{
|
||||
// Very tight range should create narrow BB inside KC
|
||||
var sq = new SqueezePro(period: 10, momLength: 3, momSmooth: 2);
|
||||
// First seed with some volatility to build ATR
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 100.0 + (i * 2);
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 5, price - 5, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// Then go very tight
|
||||
for (int i = 20; i < 50; i++)
|
||||
{
|
||||
double price = 140.0 + (i % 2 == 0 ? 0.01 : -0.01);
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.01, price - 0.01, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// After many tight bars, squeeze should be active (level > 0)
|
||||
Assert.True(sq.SqueezeLevel > 0);
|
||||
}
|
||||
|
||||
// === D) State + bar correction ===
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_Advances_State()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
double momBefore = sq.Momentum;
|
||||
|
||||
var nextBar = new TBar(DateTime.UtcNow.AddMinutes(100), 200, 210, 190, 205, 1000);
|
||||
sq.Update(nextBar, isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
_ = momBefore;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Update(bars[9], isNew: true);
|
||||
double momAfterNew = sq.Momentum;
|
||||
|
||||
var corrected = new TBar(bars[9].Time, 999, 1005, 990, 1000, 1000);
|
||||
sq.Update(corrected, isNew: false);
|
||||
double momAfterCorrect = sq.Momentum;
|
||||
|
||||
Assert.NotEqual(momAfterNew, momAfterCorrect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrection_Restores()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(15);
|
||||
for (int i = 0; i < 14; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Update(bars[14], isNew: true);
|
||||
double momAfterTrue = sq.Momentum;
|
||||
|
||||
for (int j = 0; j < 3; j++)
|
||||
{
|
||||
sq.Update(bars[14], isNew: false);
|
||||
}
|
||||
|
||||
Assert.Equal(momAfterTrue, sq.Momentum, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Reset();
|
||||
|
||||
Assert.False(sq.IsHot);
|
||||
Assert.Equal(0.0, sq.Momentum);
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
// === E) Warmup/convergence ===
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsCorrectly()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
// After enough bars (momLength + momSmooth worth), should be hot
|
||||
Assert.True(sq.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsMaxOfPeriodAndMomTotal()
|
||||
{
|
||||
var sq1 = new SqueezePro(period: 30, momLength: 5, momSmooth: 3);
|
||||
Assert.Equal(30, sq1.WarmupPeriod); // Max(30, 5+3=8) = 30
|
||||
|
||||
var sq2 = new SqueezePro(period: 5, momLength: 20, momSmooth: 10);
|
||||
Assert.Equal(30, sq2.WarmupPeriod); // Max(5, 20+10=30) = 30
|
||||
}
|
||||
|
||||
// === F) Robustness ===
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValid()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(100), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
sq.Update(nanBar, isNew: true);
|
||||
// Should not throw
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_Handled()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow.AddMinutes(100),
|
||||
double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, 0);
|
||||
sq.Update(infBar, isNew: true);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedNaN_NoThrow()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
TBar bar;
|
||||
if (i % 5 == 0)
|
||||
{
|
||||
bar = new TBar(DateTime.UtcNow.AddMinutes(i), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 105 + i, 95 + i, 101 + i, 1000);
|
||||
}
|
||||
sq.Update(bar, isNew: true);
|
||||
}
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
// === G) EMA smoothing mode ===
|
||||
|
||||
[Fact]
|
||||
public void EmaMode_ProducesFiniteValues()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: false);
|
||||
var bars = GenerateBars(40);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmaMode_DiffersFromSma()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
var sqSma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: true);
|
||||
var sqEma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: false);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sqSma.Update(bars[i], isNew: true);
|
||||
sqEma.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// SMA and EMA smoothing should produce different momentum values
|
||||
Assert.NotEqual(sqSma.Momentum, sqEma.Momentum);
|
||||
}
|
||||
|
||||
// === H) Consistency ===
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesStreaming()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
double streamMom = sq.Momentum;
|
||||
|
||||
var (batchMom, _) = SqueezePro.Batch(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
double batchLast = batchMom[^1].Value;
|
||||
|
||||
Assert.Equal(streamMom, batchLast, precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesStreaming()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
double streamMom = sq.Momentum;
|
||||
|
||||
double[] momOut = new double[50];
|
||||
double[] sqOut = new double[50];
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
momOut, sqOut, period: 10, momLength: 5, momSmooth: 3);
|
||||
double spanLast = momOut[49];
|
||||
|
||||
Assert.Equal(streamMom, spanLast, precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventingMode_MatchesStreaming()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var sqStream = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sqStream.Update(bars[i], isNew: true);
|
||||
}
|
||||
double streamMom = sqStream.Momentum;
|
||||
|
||||
var sqEvent = new SqueezePro(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
Assert.Equal(streamMom, sqEvent.Momentum, precision: 6);
|
||||
}
|
||||
|
||||
// === I) Span API tests ===
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidPeriod()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnMismatchedLengths()
|
||||
{
|
||||
double[] h = [100, 101];
|
||||
double[] l = [99];
|
||||
double[] c = [100, 101];
|
||||
double[] mom = new double[2];
|
||||
double[] sq = new double[2];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 5));
|
||||
Assert.Equal("high", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnShortMomOutput()
|
||||
{
|
||||
double[] h = [100, 101, 102, 103, 104];
|
||||
double[] l = [99, 100, 101, 102, 103];
|
||||
double[] c = [100, 101, 102, 103, 104];
|
||||
double[] mom = new double[2]; // too short
|
||||
double[] sq = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 3));
|
||||
Assert.Equal("momOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnShortSqOutput()
|
||||
{
|
||||
double[] h = [100, 101, 102, 103, 104];
|
||||
double[] l = [99, 100, 101, 102, 103];
|
||||
double[] c = [100, 101, 102, 103, 104];
|
||||
double[] mom = new double[5];
|
||||
double[] sq = new double[2]; // too short
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 3));
|
||||
Assert.Equal("sqOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidMomLength()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, momLength: 0));
|
||||
Assert.Equal("momLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidMomSmooth()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, momSmooth: 0));
|
||||
Assert.Equal("momSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_LargeData_NoStackOverflow()
|
||||
{
|
||||
const int size = 2000;
|
||||
var gbm = new GBM(100.0, 0.02, 0.15, seed: 1);
|
||||
var bars = gbm.Fetch(size, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] mom = new double[size];
|
||||
double[] sq = new double[size];
|
||||
// period=300 forces ArrayPool path
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, mom, sq, period: 300);
|
||||
Assert.True(double.IsFinite(mom[size - 1]));
|
||||
}
|
||||
|
||||
// === J) Chainability ===
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_Fires()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
int fireCount = 0;
|
||||
sq.Pub += (_, in e) => fireCount++;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 105, 95, 101, 1000);
|
||||
sq.Update(bar, isNew: true);
|
||||
}
|
||||
Assert.Equal(10, fireCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Constructor_Subscribes()
|
||||
{
|
||||
var bars = GenerateBars(30);
|
||||
var sq = new SqueezePro(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
}
|
||||
|
||||
// === K) Calculate factory ===
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var bars = GenerateBars(30);
|
||||
var ((momSeries, sqSeries), indicator) = SqueezePro.Calculate(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
Assert.Equal(30, momSeries.Count);
|
||||
Assert.Equal(30, sqSeries.Count);
|
||||
Assert.NotNull(indicator);
|
||||
Assert.True(double.IsFinite(indicator.Momentum));
|
||||
}
|
||||
|
||||
// === L) Squeeze level output values ===
|
||||
|
||||
[Fact]
|
||||
public void BatchSqueezeLevels_AreInRange()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
double[] mom = new double[100];
|
||||
double[] sq = new double[100];
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, mom, sq, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.InRange(sq[i], 0.0, 3.0);
|
||||
Assert.True(sq[i] == 0.0 || sq[i] == 1.0 || sq[i] == 2.0 || sq[i] == 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for SqueezePro indicator.
|
||||
/// Tests determinism, identity properties, and mathematical invariants.
|
||||
/// </summary>
|
||||
public sealed class SqueezeProValidationTests
|
||||
{
|
||||
private static TBarSeries GenerateBars(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
// === Determinism ===
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 2.0, 2.0, 1.5, 1.0, 5, 3, true)]
|
||||
[InlineData(20, 2.0, 2.0, 1.5, 1.0, 12, 6, true)]
|
||||
[InlineData(15, 1.5, 3.0, 2.0, 1.0, 8, 4, false)]
|
||||
public void DifferentParams_Deterministic(int period, double bbMult,
|
||||
double kcWide, double kcNormal, double kcNarrow, int momLen, int momSmooth, bool useSma)
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var sq1 = new SqueezePro(period, bbMult, kcWide, kcNormal, kcNarrow, momLen, momSmooth, useSma);
|
||||
var sq2 = new SqueezePro(period, bbMult, kcWide, kcNormal, kcNarrow, momLen, momSmooth, useSma);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sq1.Update(bars[i], isNew: true);
|
||||
sq2.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(sq1.Momentum, sq2.Momentum, precision: 12);
|
||||
Assert.Equal(sq1.SqueezeLevel, sq2.SqueezeLevel);
|
||||
}
|
||||
|
||||
// === Streaming vs Batch consistency ===
|
||||
|
||||
[Fact]
|
||||
public void Streaming_Equals_Batch_AllBars()
|
||||
{
|
||||
var bars = GenerateBars(80);
|
||||
const int period = 15;
|
||||
const int momLen = 8;
|
||||
const int momSmooth = 4;
|
||||
|
||||
// Streaming
|
||||
var sq = new SqueezePro(period, momLength: momLen, momSmooth: momSmooth);
|
||||
double[] streamMom = new double[80];
|
||||
int[] streamSq = new int[80];
|
||||
for (int i = 0; i < 80; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
streamMom[i] = sq.Momentum;
|
||||
streamSq[i] = sq.SqueezeLevel;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchMom = new double[80];
|
||||
double[] batchSq = new double[80];
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
batchMom, batchSq, period, momLength: momLen, momSmooth: momSmooth);
|
||||
|
||||
for (int i = 0; i < 80; i++)
|
||||
{
|
||||
Assert.Equal(streamMom[i], batchMom[i], precision: 6);
|
||||
Assert.Equal(streamSq[i], (int)batchSq[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// === Squeeze hierarchy: narrow ⊂ normal ⊂ wide ===
|
||||
|
||||
[Fact]
|
||||
public void SqueezeHierarchy_NarrowImpliesNormal()
|
||||
{
|
||||
var bars = GenerateBars(200, seed: 99);
|
||||
var sq = new SqueezePro(period: 20, momLength: 12, momSmooth: 6);
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
|
||||
// If narrow squeeze (3), then it must also satisfy normal squeeze
|
||||
// Since level is classified as max level, if level=3, it means insideNarrow was true
|
||||
// which implies insideNormal was also true
|
||||
if (sq.SqueezeLevel == 3)
|
||||
{
|
||||
// Narrow squeeze is only possible when also inside normal and wide
|
||||
Assert.True(sq.SqueezeLevel >= 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Momentum sign under trending conditions ===
|
||||
|
||||
[Fact]
|
||||
public void StrongUptrend_PersistentPositiveMomentum()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
int positiveCount = 0;
|
||||
int totalHot = 0;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + (i * 2.0); // strong uptrend
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
|
||||
if (sq.IsHot)
|
||||
{
|
||||
totalHot++;
|
||||
if (sq.Momentum > 0) { positiveCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
// In a strong uptrend, momentum should be positive most of the time
|
||||
Assert.True(totalHot > 0);
|
||||
double ratio = (double)positiveCount / totalHot;
|
||||
Assert.True(ratio > 0.9, $"Expected >90% positive momentum in uptrend, got {ratio:P1}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrongDowntrend_PersistentNegativeMomentum()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
int negativeCount = 0;
|
||||
int totalHot = 0;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 500.0 - (i * 2.0); // strong downtrend
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
|
||||
if (sq.IsHot)
|
||||
{
|
||||
totalHot++;
|
||||
if (sq.Momentum < 0) { negativeCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(totalHot > 0);
|
||||
double ratio = (double)negativeCount / totalHot;
|
||||
Assert.True(ratio > 0.9, $"Expected >90% negative momentum in downtrend, got {ratio:P1}");
|
||||
}
|
||||
|
||||
// === KC multiplier ordering ===
|
||||
|
||||
[Fact]
|
||||
public void LargerKcMult_MoreSqueeze()
|
||||
{
|
||||
// Larger KC multiplier = wider KC = easier for BB to be inside = more squeeze
|
||||
var bars = GenerateBars(100, seed: 77);
|
||||
|
||||
var sqTight = new SqueezePro(period: 20, kcMultWide: 1.0, kcMultNormal: 0.8, kcMultNarrow: 0.5);
|
||||
var sqWide = new SqueezePro(period: 20, kcMultWide: 3.0, kcMultNormal: 2.5, kcMultNarrow: 2.0);
|
||||
|
||||
int tightSqueezeCount = 0;
|
||||
int wideSqueezeCount = 0;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
sqTight.Update(bars[i], isNew: true);
|
||||
sqWide.Update(bars[i], isNew: true);
|
||||
|
||||
if (sqTight.SqueezeLevel > 0) { tightSqueezeCount++; }
|
||||
if (sqWide.SqueezeLevel > 0) { wideSqueezeCount++; }
|
||||
}
|
||||
|
||||
// Wider KC should detect more squeeze instances
|
||||
Assert.True(wideSqueezeCount >= tightSqueezeCount,
|
||||
$"Wide KC squeeze count ({wideSqueezeCount}) should be >= tight KC ({tightSqueezeCount})");
|
||||
}
|
||||
|
||||
// === Reset and replay ===
|
||||
|
||||
[Fact]
|
||||
public void ResetAndReplay_SameResults()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
double mom1 = sq.Momentum;
|
||||
int level1 = sq.SqueezeLevel;
|
||||
|
||||
sq.Reset();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(mom1, sq.Momentum, precision: 10);
|
||||
Assert.Equal(level1, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
// === Boundary: period=1 ===
|
||||
|
||||
[Fact]
|
||||
public void MinimalPeriod_NoThrow()
|
||||
{
|
||||
var sq = new SqueezePro(period: 1, momLength: 1, momSmooth: 1);
|
||||
var bars = GenerateBars(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
}
|
||||
|
||||
// === Large period — ArrayPool path ===
|
||||
|
||||
[Fact]
|
||||
public void LargePeriod_ArrayPoolPath()
|
||||
{
|
||||
var bars = GenerateBars(500, seed: 88);
|
||||
double[] mom = new double[500];
|
||||
double[] sq = new double[500];
|
||||
// total buffers = 300 + 50 + 20 = 370 > 256 → ArrayPool
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
mom, sq, period: 300, momLength: 50, momSmooth: 20);
|
||||
Assert.True(double.IsFinite(mom[499]));
|
||||
}
|
||||
|
||||
// === EMA vs SMA smoothing same seed ===
|
||||
|
||||
[Fact]
|
||||
public void EmaVsSma_SameSqueezeLevel()
|
||||
{
|
||||
// Smoothing mode only affects momentum, not squeeze detection
|
||||
var bars = GenerateBars(50);
|
||||
var sqSma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: true);
|
||||
var sqEma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: false);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sqSma.Update(bars[i], isNew: true);
|
||||
sqEma.Update(bars[i], isNew: true);
|
||||
|
||||
// Squeeze level should be identical regardless of smoothing mode
|
||||
Assert.Equal(sqSma.SqueezeLevel, sqEma.SqueezeLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user