fix: resolve build and test errors

- Sar.Quantower.Tests.cs: add missing opening quote on string literal (line 48)
- Exports.cs: rename Correlation.Batch → Correl.Batch (CS0103)
- Ad.Validation.Tests.cs: fix Ooples OutputValues key "Ad" → "Adl"
This commit is contained in:
Miha Kralj
2026-03-16 12:45:13 -07:00
parent 3b0cdca567
commit 6f0a339c9b
131 changed files with 1570 additions and 1571 deletions
+69
View File
@@ -0,0 +1,69 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// Dc: Donchian Channels - Quantower Indicator Adapter
/// Upper = rolling highest high; Lower = rolling lowest low; Middle = (Upper + Lower) / 2.
/// Uses streaming O(1) deques with bar-correction support.
/// </summary>
public sealed class DcIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1, decimalPlaces: 0)]
public int Period { get; set; } = 20;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Dc? _indicator;
public int MinHistoryDepths => Period;
public override string ShortName => $"Dc({Period})";
public DcIndicator()
{
Name = "Dc - Donchian Channels";
Description = "Price channel using rolling highest high / lowest low with midpoint average";
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
_indicator = new Dc(Period);
AddLineSeries(new LineSeries("Middle", Color.DodgerBlue, 2, LineStyle.Solid));
AddLineSeries(new LineSeries("Upper", Color.FromArgb(255, 180, 180), 1, LineStyle.Dash));
AddLineSeries(new LineSeries("Lower", Color.FromArgb(180, 180, 255), 1, LineStyle.Dash));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_indicator is null)
{
return;
}
var item = HistoricalData[0, SeekOriginHistory.End];
bool isNew = args.IsNewBar();
TBar input = new(
time: item.TimeLeft,
open: item[PriceType.Open],
high: item[PriceType.High],
low: item[PriceType.Low],
close: item[PriceType.Close],
volume: item[PriceType.Volume]
);
_indicator.Update(input, isNew);
bool isHot = _indicator.IsHot;
LinesSeries[0].SetValue(_indicator.Last.Value, isHot, ShowColdValues);
LinesSeries[1].SetValue(_indicator.Upper.Value, isHot, ShowColdValues);
LinesSeries[2].SetValue(_indicator.Lower.Value, isHot, ShowColdValues);
}
}
+327
View File
@@ -0,0 +1,327 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// DC: Donchian Channels
/// Upper = rolling highest high; Lower = rolling lowest low; Middle = (Upper + Lower) / 2.
/// Streaming path uses monotonic deques for O(1) amortized updates; corrections (isNew=false)
/// rebuild deques without allocations.
/// </summary>
[SkipLocalsInit]
public sealed class Dc : ITValuePublisher
{
private readonly int _period;
private readonly double[] _hBuf;
private readonly double[] _lBuf;
private readonly MonotonicDeque _maxDeque;
private readonly MonotonicDeque _minDeque;
// Rolling counters
private int _count;
private long _index;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidHigh, double LastValidLow, bool IsHot);
private State _state;
private State _p_state;
private readonly TBarPublishedHandler _barHandler;
public string Name { get; }
public int WarmupPeriod { get; }
public TValue Last { get; private set; }
public TValue Upper { get; private set; }
public TValue Lower { get; private set; }
public bool IsHot => _count >= _period;
public event TValuePublishedHandler? Pub;
public Dc(int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_hBuf = new double[_period];
_lBuf = new double[_period];
_maxDeque = new MonotonicDeque(_period);
_minDeque = new MonotonicDeque(_period);
_count = 0;
_index = -1;
_state = new State(double.NaN, double.NaN, false);
_p_state = _state;
Name = $"Dc({period})";
WarmupPeriod = period;
_barHandler = HandleBar;
}
public Dc(TBarSeries source, int period) : 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)]
private (double high, double low) GetValid(double high, double low)
{
if (double.IsFinite(high))
{
_state = _state with { LastValidHigh = high };
}
else
{
high = _state.LastValidHigh;
}
if (double.IsFinite(low))
{
_state = _state with { LastValidLow = low };
}
else
{
low = _state.LastValidLow;
}
return (high, low);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_index++;
if (_count < _period)
{
_count++;
}
}
else
{
_state = _p_state;
}
int bufIdx = (int)(_index % _period);
var (high, low) = GetValid(input.High, input.Low);
// If still no valid data, return NaN placeholders
if (double.IsNaN(high) || double.IsNaN(low))
{
Last = new TValue(input.Time, double.NaN);
Upper = new TValue(input.Time, double.NaN);
Lower = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
_hBuf[bufIdx] = high;
_lBuf[bufIdx] = low;
if (isNew)
{
_maxDeque.PushMax(_index, high, _hBuf);
_minDeque.PushMin(_index, low, _lBuf);
}
else
{
// Correcting current bar: rebuild deques to maintain consistency
_maxDeque.RebuildMax(_hBuf, _index, _count);
_minDeque.RebuildMin(_lBuf, _index, _count);
}
double top = _maxDeque.GetExtremum(_hBuf);
double bot = _minDeque.GetExtremum(_lBuf);
double mid = (top + bot) * 0.5;
if (!_state.IsHot && _count >= _period)
{
_state = _state with { IsHot = true };
}
Last = new TValue(input.Time, mid);
Upper = new TValue(input.Time, top);
Lower = new TValue(input.Time, bot);
PubEvent(Last, isNew);
return Last;
}
public (TSeries Middle, TSeries Upper, TSeries Lower) Update(TBarSeries source)
{
if (source.Count == 0)
{
return (new TSeries([], []), new TSeries([], []), new TSeries([], []));
}
int len = source.Count;
var tMiddle = new List<long>(len);
var vMiddle = new List<double>(len);
var tUpper = new List<long>(len);
var vUpper = new List<double>(len);
var tLower = new List<long>(len);
var vLower = new List<double>(len);
CollectionsMarshal.SetCount(tMiddle, len);
CollectionsMarshal.SetCount(vMiddle, len);
CollectionsMarshal.SetCount(tUpper, len);
CollectionsMarshal.SetCount(vUpper, len);
CollectionsMarshal.SetCount(tLower, len);
CollectionsMarshal.SetCount(vLower, len);
var tSpan = CollectionsMarshal.AsSpan(tMiddle);
var vMiddleSpan = CollectionsMarshal.AsSpan(vMiddle);
var vUpperSpan = CollectionsMarshal.AsSpan(vUpper);
var vLowerSpan = CollectionsMarshal.AsSpan(vLower);
Batch(source.HighValues, source.LowValues, vMiddleSpan, vUpperSpan, vLowerSpan, _period);
source.Times.CopyTo(tSpan);
tSpan.CopyTo(CollectionsMarshal.AsSpan(tUpper));
tSpan.CopyTo(CollectionsMarshal.AsSpan(tLower));
// Prime internal state for continued streaming
Prime(source);
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
Last = new TValue(lastTime, vMiddleSpan[^1]);
Upper = new TValue(lastTime, vUpperSpan[^1]);
Lower = new TValue(lastTime, vLowerSpan[^1]);
return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower));
}
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);
_maxDeque.Reset();
_minDeque.Reset();
_count = 0;
_index = -1;
_state = new State(double.NaN, double.NaN, false);
_p_state = _state;
Last = default;
Upper = default;
Lower = default;
}
/// <summary>
/// Batch calculation using spans (zero allocation).
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
Span<double> middle,
Span<double> upper,
Span<double> lower,
int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (high.Length != low.Length)
{
throw new ArgumentException("High and Low spans must have the same length", nameof(high));
}
if (middle.Length < high.Length || upper.Length < high.Length || lower.Length < high.Length)
{
throw new ArgumentException("Output spans must be at least as long as inputs", nameof(middle));
}
int len = high.Length;
if (len == 0)
{
return;
}
double[] top = ArrayPool<double>.Shared.Rent(len);
double[] bot = ArrayPool<double>.Shared.Rent(len);
try
{
Highest.Batch(high, top.AsSpan(0, len), period);
Lowest.Batch(low, bot.AsSpan(0, len), period);
for (int i = 0; i < len; i++)
{
double u = top[i];
double l = bot[i];
middle[i] = (u + l) * 0.5;
upper[i] = u;
lower[i] = l;
}
}
finally
{
ArrayPool<double>.Shared.Return(top);
ArrayPool<double>.Shared.Return(bot);
}
}
public static (TSeries Middle, TSeries Upper, TSeries Lower) Batch(TBarSeries source, int period)
{
int len = source.Count;
var tMiddle = new List<long>(len);
var vMiddle = new List<double>(len);
var tUpper = new List<long>(len);
var vUpper = new List<double>(len);
var tLower = new List<long>(len);
var vLower = new List<double>(len);
CollectionsMarshal.SetCount(tMiddle, len);
CollectionsMarshal.SetCount(vMiddle, len);
CollectionsMarshal.SetCount(tUpper, len);
CollectionsMarshal.SetCount(vUpper, len);
CollectionsMarshal.SetCount(tLower, len);
CollectionsMarshal.SetCount(vLower, len);
Batch(source.HighValues, source.LowValues,
CollectionsMarshal.AsSpan(vMiddle),
CollectionsMarshal.AsSpan(vUpper),
CollectionsMarshal.AsSpan(vLower),
period);
source.Times.CopyTo(CollectionsMarshal.AsSpan(tMiddle));
CollectionsMarshal.AsSpan(tMiddle).CopyTo(CollectionsMarshal.AsSpan(tUpper));
CollectionsMarshal.AsSpan(tMiddle).CopyTo(CollectionsMarshal.AsSpan(tLower));
return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower));
}
public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Dc Indicator) Calculate(TBarSeries source, int period)
{
var indicator = new Dc(source, period);
var results = indicator.Update(source);
return (results, indicator);
}
}
+107
View File
@@ -0,0 +1,107 @@
# DC: Donchian Channels
> *The highest high and lowest low over a window — Donchian's simplicity captures breakout potential in two lines.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` |
| **Outputs** | Multiple series (Upper, Lower) |
| **Output range** | Tracks input |
| **Warmup** | `period` bars |
| **PineScript** | [dc.pine](dc.pine) |
- Donchian Channels track the highest high and lowest low over a fixed lookback period, defining the absolute price boundaries within which an asset has traded.
- **Similar:** [PC](../pc/pc.md), [UChannel](../uchannel/uchannel.md) | **Complementary:** Volume on breakouts; ATR for position sizing | **Trading note:** Pure price-based highest high/lowest low; used in the original Turtle Trading system.
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
Donchian Channels track the highest high and lowest low over a fixed lookback period, defining the absolute price boundaries within which an asset has traded. Unlike volatility-based bands that compute statistical dispersion, Donchian Channels represent actual historical extremes — the literal "price box." The implementation uses monotonic deques for $O(1)$ amortized sliding-window max/min, ensuring that computing a 500-period channel costs no more than a 20-period one. The midpoint of the upper and lower bands serves as a simple trend bias indicator.
## Historical Context
Richard Donchian developed this channel in the 1960s while managing one of the first publicly held commodity funds. Known as the "father of trend following," Donchian pioneered systematic trading in an era dominated by discretionary methods. His "4-week rule" (buy on a 20-day high, sell on a 20-day low) became one of the earliest documented mechanical trading systems.
The indicator achieved legendary status through the Turtle Trading experiment in 1983. Richard Dennis and William Eckhardt recruited novice traders and taught them a mechanical system built on channel breakouts. The Turtles reportedly earned over $100 million. Curtis Faith's *Way of the Turtle* (2007) revealed the core system: enter on 20-day breakouts, exit on 10-day counter-breakouts. The simplicity is the feature: no predictions, no fitting, no optimization — just price breaking through defined boundaries.
## Architecture & Physics
### 1. Upper Band (Sliding Window Maximum)
$$\text{Upper}_t = \max_{i=0}^{n-1}(H_{t-i})$$
### 2. Lower Band (Sliding Window Minimum)
$$\text{Lower}_t = \min_{i=0}^{n-1}(L_{t-i})$$
### 3. Middle Band
$$\text{Middle}_t = \frac{\text{Upper}_t + \text{Lower}_t}{2}$$
### 4. Monotonic Deque Algorithm
The naive approach scans the entire window for each bar: $O(n)$ per update. The monotonic deque (also called a sliding window max/min queue) maintains candidates in sorted order:
**For the max deque (upper band):**
1. Remove indices outside the window from the front
2. Remove values $\leq$ current High from the back (they can never be the maximum again)
3. Push current index to the back
4. The front element is always the maximum
Each element enters exactly once and exits at most once, yielding $O(1)$ amortized per bar over any sequence of $N$ updates.
### 5. Stale Extremes
The bands stay flat until either a new extreme occurs or the old extreme exits the window. A band that hasn't moved in 15 bars is waiting for new information. This piecewise-constant behavior is the defining characteristic: unlike smoothed envelopes, Donchian Channels are discontinuous, stepping only at regime transitions.
## Mathematical Foundation
### Parameters
| Parameter | Description | Default | Constraint |
|-----------|-------------|---------|------------|
| `period` | Lookback window for high/low extremes ($n$) | 20 | $> 0$ |
### Output Interpretation
| Output | Description |
|--------|-------------|
| `upper` | Highest high over the lookback (resistance) |
| `lower` | Lowest low over the lookback (support) |
| `middle` | Midpoint of channel (trend bias) |
## Performance Profile
### Operation Count (Streaming Mode)
DC uses two monotonic deques for $O(1)$ amortized sliding-window max/min:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| CMP (expire stale front, max deque) | 1 | 1 | 1 |
| CMP (remove dominated back, max deque) | ~1 avg | 1 | 1 |
| CMP (expire stale front, min deque) | 1 | 1 | 1 |
| CMP (remove dominated back, min deque) | ~1 avg | 1 | 1 |
| ADD (upper + lower) | 1 | 1 | 1 |
| MUL (× 0.5 for middle) | 1 | 3 | 3 |
| **Total (amortized)** | **~6** | — | **~8 cycles** |
Each element enters and exits each deque exactly once over the full series, so worst-case per-bar is $O(n)$ but amortized cost is $O(1)$. Memory: two deques of up to $n$ index entries + two circular buffers of $n$ values.
### Batch Mode (SIMD Analysis)
Monotonic deques are inherently sequential (deque state depends on insertion order). No SIMD parallelization across bars is possible:
| Optimization | Benefit |
| :--- | :--- |
| Deque operations | Sequential; amortized O(1) already optimal |
| Midpoint computation | Vectorizable in a post-pass with `Vector<double>` |
| Memory layout | Circular buffers are cache-friendly for sequential access |
## Resources
- **Donchian, R.** "High Finance in Copper." *Financial Analysts Journal*, 16(6), 1960. (Original channel concept)
- **Faith, C.** *Way of the Turtle: The Secret Methods that Turned Ordinary People into Legendary Traders*. McGraw-Hill, 2007. (Turtle Trading system)
- **Covel, M.** *The Complete TurtleTrader*. HarperBusiness, 2007.
- **Cormen, T.H. et al.** *Introduction to Algorithms*. MIT Press, 2009. (Monotonic deque / sliding window algorithms)
+50
View File
@@ -0,0 +1,50 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Donchian Channels (DC)", "DC", overlay=true)
//@function Calculates the Donchian Channel (DC) efficiently using monotonic deques
//@param hi Source series for the highest high calculation (usually high)
//@param lo Source series for the lowest low calculation (usually low)
//@param p Lookback period (p > 0)
//@returns Tuple containing [basis, upper_band, lower_band]
//@optimized Uses monotonic deque for O(1) amortized complexity per bar
dc(series float hi, series float lo, simple int p) =>
if p <= 0
runtime.error("Period must be > 0")
var float[] hbuf = array.new_float(p, na)
var float[] lbuf = array.new_float(p, na)
var int[] hq = array.new_int()
var int[] lq = array.new_int()
int idx = bar_index % p
array.set(hbuf, idx, hi)
array.set(lbuf, idx, lo)
while array.size(hq) > 0 and array.get(hq, 0) <= bar_index - p
array.shift(hq)
while array.size(hq) > 0 and array.get(hbuf, array.get(hq, -1) % p) <= hi
array.pop(hq)
array.push(hq, bar_index)
while array.size(lq) > 0 and array.get(lq, 0) <= bar_index - p
array.shift(lq)
while array.size(lq) > 0 and array.get(lbuf, array.get(lq, -1) % p) >= lo
array.pop(lq)
array.push(lq, bar_index)
float top = array.get(hbuf, array.get(hq, 0) % p)
float bot = array.get(lbuf, array.get(lq, 0) % p)
[math.avg(top, bot), top, bot]
// ---------- Main loop ----------
// Inputs
i_period = input.int(20, "Period", minval=1)
i_high = input.source(high, "High Source")
i_low = input.source(low, "Low Source")
// Calculation
[basis, upper, lower] = dc(i_high, i_low, i_period)
// Plot
plot(basis, "Basis", color=color.yellow, linewidth=2)
p1 = plot(upper, "Upper", color=color.yellow, linewidth=2)
p2 = plot(lower, "Lower", color=color.yellow, linewidth=2)
fill(p1, p2, color=color.new(color.blue, 90), title="Band Fill")
+139
View File
@@ -0,0 +1,139 @@
using TradingPlatform.BusinessLayer;
using Xunit;
namespace QuanTAlib.Tests;
public class DcIndicatorTests
{
[Fact]
public void Constructor_SetsDefaults()
{
var ind = new DcIndicator();
Assert.Equal(20, ind.Period);
Assert.True(ind.ShowColdValues);
Assert.Equal("Dc - Donchian Channels", ind.Name);
Assert.False(ind.SeparateWindow);
Assert.True(ind.OnBackGround);
}
[Fact]
public void MinHistoryDepths_EqualsPeriod()
{
var ind = new DcIndicator { Period = 15 };
Assert.Equal(15, ind.MinHistoryDepths);
}
[Fact]
public void ShortName_ReflectsParameters()
{
var ind = new DcIndicator { Period = 12 };
Assert.Contains("12", ind.ShortName, StringComparison.Ordinal);
}
[Fact]
public void Initialize_AddsThreeLineSeries()
{
var ind = new DcIndicator { Period = 14 };
ind.Initialize();
Assert.Equal(3, ind.LinesSeries.Count);
Assert.Equal("Middle", ind.LinesSeries[0].Name);
Assert.Equal("Upper", ind.LinesSeries[1].Name);
Assert.Equal("Lower", ind.LinesSeries[2].Name);
}
[Fact]
public void ProcessUpdate_Historical_ComputesValues()
{
var ind = new DcIndicator { Period = 3 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, ind.LinesSeries[0].Count);
Assert.True(double.IsFinite(ind.LinesSeries[0].GetValue(0)));
Assert.True(double.IsFinite(ind.LinesSeries[1].GetValue(0)));
Assert.True(double.IsFinite(ind.LinesSeries[2].GetValue(0)));
}
[Fact]
public void ProcessUpdate_NewBar_Appends()
{
var ind = new DcIndicator { Period = 3 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
ind.HistoricalData.AddBar(now.AddMinutes(1), 102, 112, 92, 104);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, ind.LinesSeries[0].Count);
}
[Fact]
public void ProcessUpdate_NewTick_DoesNotThrow()
{
var ind = new DcIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, ind.LinesSeries[0].Count);
}
[Fact]
public void MultipleUpdates_ProducesFiniteSeries()
{
var ind = new DcIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
ind.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
Assert.Equal(10, ind.LinesSeries[0].Count);
Assert.Equal(10, ind.LinesSeries[1].Count);
Assert.Equal(10, ind.LinesSeries[2].Count);
for (int i = 0; i < 10; i++)
{
Assert.True(double.IsFinite(ind.LinesSeries[0].GetValue(i)));
Assert.True(double.IsFinite(ind.LinesSeries[1].GetValue(i)));
Assert.True(double.IsFinite(ind.LinesSeries[2].GetValue(i)));
}
}
[Fact]
public void Bands_Order_Correct()
{
var ind = new DcIndicator { Period = 3 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 6; i++)
{
ind.HistoricalData.AddBar(now.AddMinutes(i), 100, 110 + i, 90 - i, 100, 1000);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double middle = ind.LinesSeries[0].GetValue(0);
double upper = ind.LinesSeries[1].GetValue(0);
double lower = ind.LinesSeries[2].GetValue(0);
Assert.True(upper >= middle, $"Upper ({upper}) should be >= Middle ({middle})");
Assert.True(lower <= middle, $"Lower ({lower}) should be <= Middle ({middle})");
}
}
+246
View File
@@ -0,0 +1,246 @@
using System;
using QuanTAlib;
using Xunit;
namespace QuanTAlib.Tests;
public class DcTests
{
[Fact]
public void Dc_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Dc(0));
Assert.Throws<ArgumentException>(() => new Dc(-5));
var d = new Dc(10);
Assert.Equal(10, d.WarmupPeriod);
Assert.Contains("Dc", d.Name, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Dc_InitialState_Defaults()
{
var d = new Dc(5);
Assert.Equal(0, d.Last.Value);
Assert.Equal(0, d.Upper.Value);
Assert.Equal(0, d.Lower.Value);
Assert.False(d.IsHot);
}
[Fact]
public void Dc_CalculatesBands()
{
var d = new Dc(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
d.Update(new TBar(DateTime.UtcNow, 105, 115, 95, 110, 1000));
d.Update(new TBar(DateTime.UtcNow, 110, 120, 100, 115, 1000));
// Highest High = 120, Lowest Low = 90, Middle = 105
Assert.Equal(120.0, d.Upper.Value, 1e-10);
Assert.Equal(90.0, d.Lower.Value, 1e-10);
Assert.Equal(105.0, d.Last.Value, 1e-10);
Assert.True(d.IsHot);
}
[Fact]
public void Dc_SlidingWindow_Updates()
{
var d = new Dc(2);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
double mid1 = d.Last.Value;
d.Update(new TBar(DateTime.UtcNow, 102, 109, 95, 102, 1000));
Assert.NotEqual(mid1, d.Last.Value);
// Period=2: last 2 bars have H=[111,109], L=[91,95]
// Upper=111, Lower=91, Middle=101
Assert.Equal(111.0, d.Upper.Value, 1e-10);
Assert.Equal(91.0, d.Lower.Value, 1e-10);
Assert.Equal(101.0, d.Last.Value, 1e-10);
}
[Fact]
public void Dc_IsHot_TurnsTrueAfterWarmup()
{
var d = new Dc(4);
for (int i = 0; i < 3; i++)
{
d.Update(new TBar(DateTime.UtcNow, 100 + i, 101 + i, 99 + i, 100 + i, 1000));
Assert.False(d.IsHot);
}
d.Update(new TBar(DateTime.UtcNow, 200, 201, 199, 200, 1000));
Assert.True(d.IsHot);
}
[Fact]
public void Dc_IsNewFalse_RebuildsState()
{
var d = new Dc(3);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7);
TBar remembered = default;
for (int i = 0; i < 6; i++)
{
remembered = gbm.Next(isNew: true);
d.Update(remembered, isNew: true);
}
double mid = d.Last.Value;
double up = d.Upper.Value;
double lo = d.Lower.Value;
for (int i = 0; i < 3; i++)
{
var corrected = gbm.Next(isNew: false);
d.Update(corrected, isNew: false);
}
d.Update(remembered, isNew: false);
Assert.Equal(mid, d.Last.Value, 1e-10);
Assert.Equal(up, d.Upper.Value, 1e-10);
Assert.Equal(lo, d.Lower.Value, 1e-10);
}
[Fact]
public void Dc_NaN_UsesLastValid()
{
var d = new Dc(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 106, 1000));
var result = d.Update(new TBar(DateTime.UtcNow, 102, double.NaN, 92, 107, 1000));
Assert.True(double.IsFinite(result.Value));
Assert.True(double.IsFinite(d.Upper.Value));
Assert.True(double.IsFinite(d.Lower.Value));
var result2 = d.Update(new TBar(DateTime.UtcNow, 103, 113, double.PositiveInfinity, 108, 1000));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void Dc_Reset_Clears()
{
var d = new Dc(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
d.Reset();
Assert.Equal(0, d.Last.Value);
Assert.Equal(0, d.Upper.Value);
Assert.Equal(0, d.Lower.Value);
Assert.False(d.IsHot);
d.Update(new TBar(DateTime.UtcNow, 50, 60, 40, 55, 1000));
Assert.NotEqual(0, d.Last.Value);
}
[Fact]
public void Dc_BatchVsStreaming_Match()
{
var dStream = new Dc(10);
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42);
var series = new TBarSeries();
for (int i = 0; i < 200; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar);
dStream.Update(bar, isNew: true);
}
double expectedMid = dStream.Last.Value;
double expectedUp = dStream.Upper.Value;
double expectedLo = dStream.Lower.Value;
var (midBatch, upBatch, loBatch) = Dc.Batch(series, 10);
Assert.Equal(expectedMid, midBatch.Last.Value, 1e-10);
Assert.Equal(expectedUp, upBatch.Last.Value, 1e-10);
Assert.Equal(expectedLo, loBatch.Last.Value, 1e-10);
}
[Fact]
public void Dc_SpanBatch_Validates()
{
double[] high = [110, 115, 120];
double[] low = [90, 95, 100];
double[] middle = new double[3];
double[] upper = new double[3];
double[] lower = new double[3];
double[] highShort = [110, 115];
double[] smallOut = new double[1];
Assert.Throws<ArgumentException>(() => Dc.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Dc.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), -1));
Assert.Throws<ArgumentException>(() => Dc.Batch(highShort.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
Assert.Throws<ArgumentException>(() => Dc.Batch(high.AsSpan(), low.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
}
[Fact]
public void Dc_SpanBatch_ComputesCorrectly()
{
double[] high = [110, 115, 120, 125];
double[] low = [90, 95, 100, 105];
double[] middle = new double[4];
double[] upper = new double[4];
double[] lower = new double[4];
Dc.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 3);
// Period=3: index 2 is first valid (indices 0,1,2)
// H=[110,115,120], L=[90,95,100] → Upper=120, Lower=90, Middle=105
Assert.Equal(120.0, upper[2], 1e-10);
Assert.Equal(90.0, lower[2], 1e-10);
Assert.Equal(105.0, middle[2], 1e-10);
// Index 3: H=[115,120,125], L=[95,100,105] → Upper=125, Lower=95, Middle=110
Assert.Equal(125.0, upper[3], 1e-10);
Assert.Equal(95.0, lower[3], 1e-10);
Assert.Equal(110.0, middle[3], 1e-10);
}
[Fact]
public void Dc_Calculate_ReturnsIndicatorAndResults()
{
var series = new TBarSeries();
series.Add(DateTime.UtcNow, 100, 110, 90, 100, 1000);
series.Add(DateTime.UtcNow, 105, 115, 95, 105, 1000);
series.Add(DateTime.UtcNow, 110, 120, 100, 110, 1000);
var ((mid, up, lo), ind) = Dc.Calculate(series, 2);
Assert.True(ind.IsHot);
// Period=2: last 2 bars H=[115,120], L=[95,100] → Upper=120, Lower=95, Middle=107.5
Assert.Equal(120.0, up.Last.Value, 1e-10);
Assert.Equal(95.0, lo.Last.Value, 1e-10);
Assert.Equal(107.5, mid.Last.Value, 1e-10);
ind.Update(new TBar(DateTime.UtcNow, 120, 130, 110, 120, 1000));
// Period=2: last 2 bars H=[120,130], L=[100,110] → Upper=130, Lower=100, Middle=115
Assert.Equal(130.0, ind.Upper.Value, 1e-10);
Assert.Equal(100.0, ind.Lower.Value, 1e-10);
Assert.Equal(115.0, ind.Last.Value, 1e-10);
}
[Fact]
public void Dc_Event_Publishes()
{
var src = new TBarSeries();
var d = new Dc(src, 2);
bool fired = false;
d.Pub += (object? sender, in TValueEventArgs args) => fired = true;
src.Add(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.True(fired);
}
}
@@ -0,0 +1,350 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
namespace QuanTAlib.Tests;
public sealed class DcValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public DcValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose() => Dispose(true);
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_ManualCalculation_Period3()
{
var series = new TBarSeries();
var t0 = DateTime.UtcNow;
series.Add(new TBar(t0, 0, 12, 8, 10, 100));
series.Add(new TBar(t0.AddMinutes(1), 0, 14, 10, 12, 100));
series.Add(new TBar(t0.AddMinutes(2), 0, 16, 12, 14, 100));
var ind = new Dc(3);
var (mid, up, lo) = ind.Update(series);
Assert.Equal(16.0, up.Last.Value, 1e-10);
Assert.Equal(8.0, lo.Last.Value, 1e-10);
Assert.Equal(12.0, mid.Last.Value, 1e-10);
Assert.True(ind.IsHot);
_output.WriteLine("Dc manual period-3 calculation validated");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
foreach (int period in periods)
{
// Batch (instance)
var inst = new Dc(period);
var (bMid, bUp, bLo) = inst.Update(_testData.Bars);
// Static batch
var (sMid, sUp, sLo) = Dc.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bUp, sUp);
ValidationHelper.VerifySeriesEqual(bLo, sLo);
// Streaming
var streaming = new Dc(period);
var sMidStream = new TSeries();
var sUpStream = new TSeries();
var sLoStream = new TSeries();
foreach (var bar in _testData.Bars)
{
streaming.Update(bar);
sMidStream.Add(streaming.Last);
sUpStream.Add(streaming.Upper);
sLoStream.Add(streaming.Lower);
}
ValidationHelper.VerifySeriesEqual(sMid, sMidStream);
ValidationHelper.VerifySeriesEqual(sUp, sUpStream);
ValidationHelper.VerifySeriesEqual(sLo, sLoStream);
// Span
double[] high = _testData.HighPrices.ToArray();
double[] low = _testData.LowPrices.ToArray();
double[] spanMid = new double[high.Length];
double[] spanUp = new double[high.Length];
double[] spanLo = new double[high.Length];
Dc.Batch(high.AsSpan(), low.AsSpan(),
spanMid.AsSpan(), spanUp.AsSpan(), spanLo.AsSpan(), period);
for (int i = 0; i < high.Length; i++)
{
Assert.Equal(sMid[i].Value, spanMid[i], 9);
Assert.Equal(sUp[i].Value, spanUp[i], 9);
Assert.Equal(sLo[i].Value, spanLo[i], 9);
}
}
_output.WriteLine("Dc mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_EventingMode_MatchesBatch()
{
const int period = 20;
var pub = new TBarSeries();
var evtInd = new Dc(pub, period);
var evtMid = new TSeries();
var evtUp = new TSeries();
var evtLo = new TSeries();
foreach (var bar in _testData.Bars)
{
pub.Add(bar);
evtMid.Add(evtInd.Last);
evtUp.Add(evtInd.Upper);
evtLo.Add(evtInd.Lower);
}
var (bMid, bUp, bLo) = Dc.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
_output.WriteLine("Dc eventing mode validated");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 15;
var ((mid, up, lo), ind) = Dc.Calculate(_testData.Bars, period);
Assert.True(ind.IsHot);
Assert.Equal(period, ind.WarmupPeriod);
Assert.Equal(mid.Last.Value, ind.Last.Value, 1e-10);
Assert.Equal(up.Last.Value, ind.Upper.Value, 1e-10);
Assert.Equal(lo.Last.Value, ind.Lower.Value, 1e-10);
// Continue streaming
var next = new TBar(DateTime.UtcNow, 0, 150, 50, 100, 1000);
ind.Update(next);
Assert.True(ind.IsHot);
_output.WriteLine("Dc Calculate validated");
}
[Fact]
public void Validate_Prime_MatchesBatch()
{
const int period = 25;
var (bMid, bUp, bLo) = Dc.Batch(_testData.Bars, period);
var primed = new Dc(period);
var subset = new TBarSeries();
for (int i = 0; i < 200; i++)
{
subset.Add(_testData.Bars[i]);
}
primed.Prime(subset);
for (int i = 200; i < _testData.Bars.Count; i++)
{
primed.Update(_testData.Bars[i]);
}
Assert.Equal(bMid.Last.Value, primed.Last.Value, 1e-9);
Assert.Equal(bUp.Last.Value, primed.Upper.Value, 1e-9);
Assert.Equal(bLo.Last.Value, primed.Lower.Value, 1e-9);
_output.WriteLine("Dc Prime validated against batch");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, up, lo) = Dc.Batch(_testData.Bars, 50);
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
ValidationHelper.VerifyAllFinite(up, startIndex: 0);
ValidationHelper.VerifyAllFinite(lo, startIndex: 0);
for (int i = 50; i < mid.Count; i++)
{
Assert.True(up[i].Value >= lo[i].Value, $"Upper >= Lower at {i}");
}
_output.WriteLine("Dc large dataset validated");
}
[Fact]
public void Validate_Skender_Batch_UpperBand()
{
// Convention difference: Skender Donchian uses prior N bars [i-N, i-1] (excludes current bar)
// QuanTAlib Dc uses inclusive N bars [i-N+1, i] (includes current bar).
// Therefore: QuanTAlib[i] should match Skender[i+1] for converged values.
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (_, qUp, _) = Dc.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qUp.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qUp[i].Value;
double? sValue = (double?)sResult[i + 1].UpperBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dc upper band validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Batch_LowerBand()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (_, _, qLo) = Dc.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qLo.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qLo[i].Value;
double? sValue = (double?)sResult[i + 1].LowerBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dc lower band validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Batch_Centerline()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (qMid, _, _) = Dc.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qMid.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qMid[i].Value;
double? sValue = (double?)sResult[i + 1].Centerline;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dc centerline validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Streaming_UpperBand()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 10, 20, 50 };
foreach (var period in periods)
{
var dc = new Dc(period);
var qUpResults = new TSeries();
foreach (var bar in _testData.Bars)
{
dc.Update(bar);
qUpResults.Add(dc.Upper);
}
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qUpResults.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qUpResults[i].Value;
double? sValue = (double?)sResult[i + 1].UpperBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dc streaming upper band validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Dc_MatchesOoples_Structural()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var ooplesData = bars.Select(b => new TickerData
{
Date = new DateTime(b.Time, DateTimeKind.Utc),
Open = b.Open, High = b.High, Low = b.Low,
Close = b.Close, Volume = b.Volume
}).ToList();
var result = new StockData(ooplesData).CalculateDonchianChannels();
var values = result.OutputValues.Values.First();
int finiteCount = values.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
}
}