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