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>
/// Pc: Price Channel - 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 PcIndicator : 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 Pc? _indicator;
public int MinHistoryDepths => Period;
public override string ShortName => $"Pc({Period})";
public PcIndicator()
{
Name = "Pc - Price Channel";
Description = "Price channel using rolling highest high / lowest low with midpoint average";
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
_indicator = new Pc(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);
}
}
+434
View File
@@ -0,0 +1,434 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// PC: Price Channel
/// Upper = rolling highest high; Lower = rolling lowest low; Middle = (Upper + Lower) / 2.
/// Functionally equivalent to Donchian Channels (DC).
/// Streaming path uses monotonic deques for O(1) amortized updates; corrections (isNew=false)
/// rebuild deques without allocations.
/// </summary>
[SkipLocalsInit]
public sealed class Pc : ITValuePublisher
{
private readonly int _period;
private readonly double[] _hBuf;
private readonly double[] _lBuf;
private readonly int[] _hDeque;
private readonly int[] _lDeque;
// Queue state
private int _hHead;
private int _hCount;
private int _lHead;
private int _lCount;
// Rolling counters
private int _count;
private long _index;
private int _p_count;
private long _p_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 Pc(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];
_hDeque = new int[_period];
_lDeque = new int[_period];
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
_count = 0;
_index = -1;
_state = new State(double.NaN, double.NaN, false);
_p_state = _state;
Name = $"Pc({period})";
WarmupPeriod = period;
_barHandler = HandleBar;
}
public Pc(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)]
private void PushMax(long logicalIndex, double value)
{
long expire = logicalIndex - _period;
while (_hCount > 0 && _hDeque[_hHead] <= expire)
{
_hHead = (_hHead + 1) % _period;
_hCount--;
}
int backIdx;
while (_hCount > 0)
{
backIdx = (_hHead + _hCount - 1) % _period;
int bufIdx = _hDeque[backIdx] % _period;
if (_hBuf[bufIdx] <= value)
{
_hCount--;
}
else
{
break;
}
}
int tail = (_hHead + _hCount) % _period;
_hDeque[tail] = (int)logicalIndex;
_hCount++;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PushMin(long logicalIndex, double value)
{
long expire = logicalIndex - _period;
while (_lCount > 0 && _lDeque[_lHead] <= expire)
{
_lHead = (_lHead + 1) % _period;
_lCount--;
}
int backIdx;
while (_lCount > 0)
{
backIdx = (_lHead + _lCount - 1) % _period;
int bufIdx = _lDeque[backIdx] % _period;
if (_lBuf[bufIdx] >= value)
{
_lCount--;
}
else
{
break;
}
}
int tail = (_lHead + _lCount) % _period;
_lDeque[tail] = (int)logicalIndex;
_lCount++;
}
private void RebuildDeques()
{
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
if (_count == 0)
{
return;
}
long startLogical = _index - _count + 1;
for (int i = 0; i < _count; i++)
{
long logicalIndex = startLogical + i;
int bufIdx = (int)(logicalIndex % _period);
double h = _hBuf[bufIdx];
double l = _lBuf[bufIdx];
PushMax(logicalIndex, h);
PushMin(logicalIndex, l);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_p_index = _index;
_p_count = _count;
_index++;
if (_count < _period)
{
_count++;
}
}
else
{
_state = _p_state;
_index = _p_index;
_count = _p_count;
// Re-increment for current bar being reprocessed
_index++;
if (_count < _period)
{
_count++;
}
}
int bufIdx = (int)(_index % _period);
var (high, low) = GetValid(input.High, input.Low);
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)
{
PushMax(_index, high);
PushMin(_index, low);
}
else
{
RebuildDeques();
}
double top = _hBuf[_hDeque[_hHead] % _period];
double bot = _lBuf[_lDeque[_lHead] % _period];
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(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);
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
_count = 0;
_index = -1;
_p_count = 0;
_p_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, Pc Indicator) Calculate(TBarSeries source, int period)
{
var indicator = new Pc(source, period);
var results = indicator.Update(source);
return (results, indicator);
}
}
+123
View File
@@ -0,0 +1,123 @@
# PC: Price Channel
> *Price channels frame the trading range by its own high-low extremes, defining the field of play.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` |
| **Outputs** | Multiple series (Upper, Lower) |
| **Output range** | Tracks input |
| **Warmup** | `period` bars |
| **PineScript** | [pc.pine](pc.pine) |
- Price Channel tracks the highest high and lowest low over a lookback period with a midpoint average, creating a three-line price envelope that defines where the market has been.
- **Similar:** [DC](../dc/dc.md), [MMChannel](../mmchannel/mmchannel.md) | **Complementary:** Volume confirmation on breakouts | **Trading note:** Price channel based on percentage offset from midpoint.
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
Price Channel tracks the highest high and lowest low over a lookback period with a midpoint average, creating a three-line price envelope that defines where the market has been. Functionally identical to Donchian Channels, the indicator uses actual price extremes rather than volatility estimates, producing bands that represent real support and resistance levels. This implementation uses monotonic deques for O(1) amortized updates instead of the naive O(n) rescan that most platforms use internally.
## Historical Context
Price Channel is the generic name for what Richard Donchian formalized in the 1960s while managing one of the first publicly held commodity funds. The indicator appears under various aliases: Donchian Channels, N-period high/low channels, or breakout bands.
The "4-week rule" (buy on 20-day high, sell on 20-day low) became the foundation for systematic trend-following. The indicator gained fame through the Turtle Trading experiment in 1983, when 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 using entry signals on 20-day breakouts with exits on 10-day counter-breakouts.
Most implementations compute max/min by scanning the entire lookback window on every bar: $O(n)$ per update, $O(n^2)$ for a series. This works for period 20 but becomes costly for longer windows or real-time feeds. The monotonic deque approach maintains running max/min in $O(1)$ amortized time, enabling period 500+ without performance degradation.
## Architecture & Physics
### 1. Upper Band (Highest High)
Tracks the maximum high price over the lookback window using a decreasing monotonic deque:
$$
U_t = \max_{i=0}^{n-1} H_{t-i}
$$
where $H$ is the high price and $n$ is the period. The upper band moves up immediately on a new high but only drops when the previous highest high exits the lookback window.
### 2. Lower Band (Lowest Low)
Tracks the minimum low price using an increasing monotonic deque:
$$
L_t = \min_{i=0}^{n-1} L_{t-i}
$$
The lower band drops immediately on new lows but only rises when the previous lowest low exits the window.
### 3. Middle Band
The arithmetic mean of the upper and lower bands:
$$
M_t = \frac{U_t + L_t}{2}
$$
This represents the equilibrium price of the lookback window. Unlike MMCHANNEL which omits the midpoint, PC always emits all three lines.
### 4. Monotonic Deque Mechanism
Two deques maintain sorted order without explicit sorting:
- **Max deque:** stores indices in decreasing value order; front is always the maximum.
- **Min deque:** stores indices in increasing value order; front is always the minimum.
On each bar: (1) expire stale front indices outside the window, (2) remove back elements superseded by the new value, (3) push the new index to the back.
### 5. Complexity
Streaming: $O(1)$ amortized per bar. Each element enters and exits each deque at most once. Memory: two circular buffers of $n$ floats plus two deques of at most $n$ indices.
## Mathematical Foundation
### Parameters
| Symbol | Name | Constraint | Description |
|--------|------|------------|-------------|
| $n$ | period | $> 0$ | Lookback window size |
### Output Interpretation
| Output | Interpretation |
|--------|---------------|
| Price closes above $U_t$ | Breakout signal (Turtle entry) |
| Price closes below $L_t$ | Breakdown signal |
| $M_t$ rising | Upward drift in the price range |
| $U_t - L_t$ contracting | Consolidation; range tightening |
| $U_t - L_t$ expanding | Volatility expansion |
## Performance Profile
### Operation Count (Streaming Mode)
PC uses two monotonic deques for $O(1)$ amortized sliding-window max/min plus a midpoint:
| 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** |
Identical to DC in cost. Each element enters and exits each deque exactly once over the full series, yielding $O(N)$ total work across $N$ bars regardless of period.
### Batch Mode (SIMD Analysis)
Monotonic deques are inherently sequential. 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 | Two circular buffers + two deques; cache-friendly |
## Resources
- Donchian, R. (1960). "High Finance in Copper." *Financial Analysts Journal*.
- Faith, C. (2007). *Way of the Turtle: The Secret Methods that Turned Ordinary People into Legendary Traders*. McGraw-Hill.
+64
View File
@@ -0,0 +1,64 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Price Channel (PC)", "PC", overlay=true)
//@function Calculates Price Channel
//@param length_param Lookback period for determining the highest high and lowest low
//@returns tuple [upperChannel, middleChannel, lowerChannel]
//@optimized Uses monotonic deque for O(1) amortized complexity per bar
pc(simple int length_param) =>
if length_param <= 0
runtime.error("Length must be greater than 0")
var deque_hi = array.new_int(0)
var src_buffer_hi = array.new_float(0, na)
var int current_index_hi = 0
var deque_lo = array.new_int(0)
var src_buffer_lo = array.new_float(0, na)
var int current_index_lo = 0
if array.size(src_buffer_hi) != length_param
src_buffer_hi := array.new_float(length_param, na)
current_index_hi := 0
array.clear(deque_hi)
src_buffer_lo := array.new_float(length_param, na)
current_index_lo := 0
array.clear(deque_lo)
float cv_hi = nz(high)
array.set(src_buffer_hi, current_index_hi, cv_hi)
float cv_lo = nz(low)
array.set(src_buffer_lo, current_index_lo, cv_lo)
while array.size(deque_hi) > 0 and array.get(deque_hi, 0) <= bar_index - length_param
array.shift(deque_hi)
while array.size(deque_lo) > 0 and array.get(deque_lo, 0) <= bar_index - length_param
array.shift(deque_lo)
while array.size(deque_hi) > 0
if array.get(src_buffer_hi, array.get(deque_hi, array.size(deque_hi) - 1) % length_param) <= cv_hi
array.pop(deque_hi)
else
break
array.push(deque_hi, bar_index)
while array.size(deque_lo) > 0
if array.get(src_buffer_lo, array.get(deque_lo, array.size(deque_lo) - 1) % length_param) >= cv_lo
array.pop(deque_lo)
else
break
array.push(deque_lo, bar_index)
float highestHigh = array.get(src_buffer_hi, array.get(deque_hi, 0) % length_param)
current_index_hi := (current_index_hi + 1) % length_param
float lowestLow = array.get(src_buffer_lo, array.get(deque_lo, 0) % length_param)
current_index_lo := (current_index_lo + 1) % length_param
[highestHigh, (highestHigh + lowestLow) / 2.0, lowestLow]
// ---------- Main loop ----------
// Inputs
i_length = input.int(20, "Length", minval=1)
// Calculation
[upperCh, middleCh, lowerCh] = pc(i_length)
// Plot
plot(middleCh, "Middle Channel", color=color.yellow, linewidth=2)
p1 = plot(upperCh, "Upper Channel", color=color.yellow, linewidth=2)
p2 = plot(lowerCh, "Lower Channel", 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 PcIndicatorTests
{
[Fact]
public void Constructor_SetsDefaults()
{
var ind = new PcIndicator();
Assert.Equal(20, ind.Period);
Assert.True(ind.ShowColdValues);
Assert.Equal("Pc - Price Channel", ind.Name);
Assert.False(ind.SeparateWindow);
Assert.True(ind.OnBackGround);
}
[Fact]
public void MinHistoryDepths_EqualsPeriod()
{
var ind = new PcIndicator { Period = 15 };
Assert.Equal(15, ind.MinHistoryDepths);
}
[Fact]
public void ShortName_ReflectsParameters()
{
var ind = new PcIndicator { Period = 12 };
Assert.Contains("12", ind.ShortName, StringComparison.Ordinal);
}
[Fact]
public void Initialize_AddsThreeLineSeries()
{
var ind = new PcIndicator { 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 PcIndicator { 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 PcIndicator { 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 PcIndicator { 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 PcIndicator { 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 PcIndicator { 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})");
}
}
+274
View File
@@ -0,0 +1,274 @@
using System;
using QuanTAlib;
using Xunit;
namespace QuanTAlib.Tests;
public class PcTests
{
[Fact]
public void Pc_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Pc(0));
Assert.Throws<ArgumentException>(() => new Pc(-5));
var pc = new Pc(10);
Assert.Equal(10, pc.WarmupPeriod);
Assert.Contains("Pc", pc.Name, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Pc_InitialState_Defaults()
{
var pc = new Pc(5);
Assert.Equal(0, pc.Last.Value);
Assert.Equal(0, pc.Upper.Value);
Assert.Equal(0, pc.Lower.Value);
Assert.False(pc.IsHot);
}
[Fact]
public void Pc_CalculatesBands()
{
var pc = new Pc(3);
pc.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
pc.Update(new TBar(DateTime.UtcNow, 105, 115, 95, 110, 1000));
pc.Update(new TBar(DateTime.UtcNow, 110, 120, 100, 115, 1000));
// Highest High = 120, Lowest Low = 90, Middle = 105
Assert.Equal(120.0, pc.Upper.Value, 1e-10);
Assert.Equal(90.0, pc.Lower.Value, 1e-10);
Assert.Equal(105.0, pc.Last.Value, 1e-10);
Assert.True(pc.IsHot);
}
[Fact]
public void Pc_SlidingWindow_Updates()
{
var pc = new Pc(2);
pc.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
pc.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
double lo1 = pc.Lower.Value;
pc.Update(new TBar(DateTime.UtcNow, 102, 109, 95, 102, 1000));
// Period=2: last 2 bars have H=[111,109], L=[91,95]
// Upper=111, Lower=91, Middle=101
Assert.Equal(111.0, pc.Upper.Value, 1e-10);
Assert.Equal(91.0, pc.Lower.Value, 1e-10);
Assert.Equal(101.0, pc.Last.Value, 1e-10);
Assert.NotEqual(lo1, pc.Lower.Value);
}
[Fact]
public void Pc_IsHot_TurnsTrueAfterWarmup()
{
var pc = new Pc(4);
for (int i = 0; i < 3; i++)
{
pc.Update(new TBar(DateTime.UtcNow, 100 + i, 101 + i, 99 + i, 100 + i, 1000));
Assert.False(pc.IsHot);
}
pc.Update(new TBar(DateTime.UtcNow, 200, 201, 199, 200, 1000));
Assert.True(pc.IsHot);
}
[Fact]
public void Pc_IsNewFalse_RebuildsState()
{
var pc = new Pc(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);
pc.Update(remembered, isNew: true);
}
double mid = pc.Last.Value;
double up = pc.Upper.Value;
double lo = pc.Lower.Value;
for (int i = 0; i < 3; i++)
{
var corrected = gbm.Next(isNew: false);
pc.Update(corrected, isNew: false);
}
pc.Update(remembered, isNew: false);
Assert.Equal(mid, pc.Last.Value, 1e-10);
Assert.Equal(up, pc.Upper.Value, 1e-10);
Assert.Equal(lo, pc.Lower.Value, 1e-10);
}
[Fact]
public void Pc_NaN_UsesLastValid()
{
var pc = new Pc(3);
pc.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
pc.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 106, 1000));
var result = pc.Update(new TBar(DateTime.UtcNow, 102, double.NaN, 92, 107, 1000));
Assert.True(double.IsFinite(result.Value));
Assert.True(double.IsFinite(pc.Upper.Value));
Assert.True(double.IsFinite(pc.Lower.Value));
var result2 = pc.Update(new TBar(DateTime.UtcNow, 103, 113, double.PositiveInfinity, 108, 1000));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void Pc_Reset_Clears()
{
var pc = new Pc(3);
pc.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
pc.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
pc.Reset();
Assert.Equal(0, pc.Last.Value);
Assert.Equal(0, pc.Upper.Value);
Assert.Equal(0, pc.Lower.Value);
Assert.False(pc.IsHot);
pc.Update(new TBar(DateTime.UtcNow, 50, 60, 40, 55, 1000));
Assert.NotEqual(0, pc.Last.Value);
}
[Fact]
public void Pc_BatchVsStreaming_Match()
{
var pcStream = new Pc(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);
pcStream.Update(bar, isNew: true);
}
double expectedMid = pcStream.Last.Value;
double expectedUp = pcStream.Upper.Value;
double expectedLo = pcStream.Lower.Value;
var (midBatch, upBatch, loBatch) = Pc.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 Pc_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>(() => Pc.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Pc.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), -1));
Assert.Throws<ArgumentException>(() => Pc.Batch(highShort.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
Assert.Throws<ArgumentException>(() => Pc.Batch(high.AsSpan(), low.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
}
[Fact]
public void Pc_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];
Pc.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 Pc_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) = Pc.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 Pc_Event_Publishes()
{
var src = new TBarSeries();
var pc = new Pc(src, 2);
bool fired = false;
pc.Pub += (object? sender, in TValueEventArgs args) => fired = true;
src.Add(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.True(fired);
}
[Fact]
public void Pc_MiddleIsMidpoint()
{
var pc = new Pc(3);
pc.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
pc.Update(new TBar(DateTime.UtcNow, 105, 115, 95, 110, 1000));
pc.Update(new TBar(DateTime.UtcNow, 110, 120, 100, 115, 1000));
double expectedMiddle = (pc.Upper.Value + pc.Lower.Value) / 2.0;
Assert.Equal(expectedMiddle, pc.Last.Value, 1e-10);
}
[Fact]
public void Pc_UpperGreaterOrEqualLower()
{
var pc = new Pc(5);
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.2, seed: 123);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
pc.Update(bar);
Assert.True(pc.Upper.Value >= pc.Lower.Value,
$"Bar {i}: Upper ({pc.Upper.Value}) should be >= Lower ({pc.Lower.Value})");
}
}
}
@@ -0,0 +1,247 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class PcValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public PcValidationTests(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 Pc(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("Pc manual period-3 calculation validated");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
foreach (int period in periods)
{
var inst = new Pc(period);
var (bMid, bUp, bLo) = inst.Update(_testData.Bars);
var (sMid, sUp, sLo) = Pc.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bUp, sUp);
ValidationHelper.VerifySeriesEqual(bLo, sLo);
var streaming = new Pc(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);
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];
Pc.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("Pc mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_EventingMode_MatchesBatch()
{
const int period = 20;
var pub = new TBarSeries();
var evtInd = new Pc(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) = Pc.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
_output.WriteLine("Pc eventing mode validated");
}
[Fact]
public void Validate_AgainstDc_ExactMatch()
{
// Pc should produce identical results to Dc
int[] periods = { 10, 20, 50 };
foreach (int period in periods)
{
var (dcMid, dcUp, dcLo) = Dc.Batch(_testData.Bars, period);
var (pcMid, pcUp, pcLo) = Pc.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(dcMid, pcMid);
ValidationHelper.VerifySeriesEqual(dcUp, pcUp);
ValidationHelper.VerifySeriesEqual(dcLo, pcLo);
}
_output.WriteLine("Pc matches Dc exactly");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 15;
var ((mid, up, lo), ind) = Pc.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);
var next = new TBar(DateTime.UtcNow, 0, 150, 50, 100, 1000);
ind.Update(next);
Assert.True(ind.IsHot);
_output.WriteLine("Pc Calculate validated");
}
[Fact]
public void Validate_Prime_MatchesBatch()
{
const int period = 25;
var (bMid, bUp, bLo) = Pc.Batch(_testData.Bars, period);
var primed = new Pc(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("Pc Prime validated against batch");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, up, lo) = Pc.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("Pc large dataset validated");
}
[Fact]
public void Validate_StateRestoration_Iterative()
{
var ind = new Pc(15);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
for (int i = 0; i < 50; i++)
{
ind.Update(gbm.Next(isNew: true), isNew: true);
}
var remembered = gbm.Next(isNew: true);
ind.Update(remembered, isNew: true);
var savedMid = ind.Last.Value;
var savedUp = ind.Upper.Value;
var savedLo = ind.Lower.Value;
for (int i = 0; i < 10; i++)
{
var corrected = gbm.Next(isNew: false);
ind.Update(corrected, isNew: false);
}
ind.Update(remembered, isNew: false);
Assert.Equal(savedMid, ind.Last.Value, 1e-10);
Assert.Equal(savedUp, ind.Upper.Value, 1e-10);
Assert.Equal(savedLo, ind.Lower.Value, 1e-10);
_output.WriteLine("Pc state restoration validated");
}
}