mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 03:58:04 +00:00
Add Stochastic Oscillator implementation and validation tests
- Implemented Stochastic Oscillator (%K and %D) in Stoch.cs with streaming and batch processing capabilities. - Added validation tests for the Stochastic Oscillator in Stoch.Validation.Tests.cs, ensuring consistency with Skender.Stock.Indicators. - Created documentation for the Stochastic Oscillator in Stoch.md, detailing its mathematical formula, architecture, parameters, and common pitfalls. - Updated project file to include necessary numeric libraries for highest and lowest calculations.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class PgoIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void PgoIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new PgoIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("PGO - Pretty Good Oscillator", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 14 };
|
||||
|
||||
Assert.Equal(0, PgoIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("PGO", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new PgoIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Pgo.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_Initialize_CreatesInternalPgo()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 10 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal(4, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double value = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; 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(20), 120, 130, 110, 125);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_Parameters_CanBeChanged()
|
||||
{
|
||||
var indicator = new PgoIndicator { Period = 14 };
|
||||
|
||||
indicator.Period = 20;
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(0, PgoIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PgoIndicator_ReferenceLines_SetCorrectly()
|
||||
{
|
||||
var indicator = new PgoIndicator { 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));
|
||||
}
|
||||
|
||||
// Zero line should be 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[1].GetValue(0));
|
||||
// Overbought line should be 3
|
||||
Assert.Equal(3.0, indicator.LinesSeries[2].GetValue(0));
|
||||
// Oversold line should be -3
|
||||
Assert.Equal(-3.0, indicator.LinesSeries[3].GetValue(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class PgoIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Pgo _pgo = null!;
|
||||
private readonly LineSeries _series;
|
||||
private readonly LineSeries _zeroLine;
|
||||
private readonly LineSeries _obLine;
|
||||
private readonly LineSeries _osLine;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"PGO ({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/pgo/Pgo.Quantower.cs";
|
||||
|
||||
public PgoIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "PGO - Pretty Good Oscillator";
|
||||
Description = "Distance from SMA normalized by ATR (units: ATR multiples)";
|
||||
|
||||
_series = new LineSeries("PGO", Color.Yellow, 2, LineStyle.Solid);
|
||||
_zeroLine = new LineSeries("Zero", Color.Gray, 1, LineStyle.Solid);
|
||||
_obLine = new LineSeries("OB", Color.FromArgb(128, Color.Red), 1, LineStyle.Dash);
|
||||
_osLine = new LineSeries("OS", Color.FromArgb(128, Color.Green), 1, LineStyle.Dash);
|
||||
AddLineSeries(_series);
|
||||
AddLineSeries(_zeroLine);
|
||||
AddLineSeries(_obLine);
|
||||
AddLineSeries(_osLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_pgo = new Pgo(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double open = item[PriceType.Open];
|
||||
double high = item[PriceType.High];
|
||||
double low = item[PriceType.Low];
|
||||
double close = item[PriceType.Close];
|
||||
double volume = item[PriceType.Volume];
|
||||
|
||||
TBar bar = new(item.TimeLeft, open, high, low, close, volume);
|
||||
TValue result = _pgo.Update(bar, args.IsNewBar());
|
||||
|
||||
if (!_pgo.IsHot && !ShowColdValues)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_series.SetValue(result.Value);
|
||||
_zeroLine.SetValue(0.0);
|
||||
_obLine.SetValue(3.0);
|
||||
_osLine.SetValue(-3.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class PgoTests
|
||||
{
|
||||
private const int DefaultPeriod = 14;
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
// ───── A) Constructor validation ─────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PeriodZero_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Pgo(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Pgo(period: -1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidPeriod_SetsProperties()
|
||||
{
|
||||
var pgo = new Pgo(period: 10);
|
||||
Assert.Equal(10, pgo.Period);
|
||||
Assert.Equal("Pgo(10)", pgo.Name);
|
||||
Assert.Equal(10, pgo.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ───── B) Basic calculation ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var pgo = new Pgo(DefaultPeriod);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
var result = pgo.Update(bar);
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_IsAccessible()
|
||||
{
|
||||
var pgo = new Pgo(DefaultPeriod);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
pgo.Update(bar);
|
||||
Assert.NotEqual(default, pgo.Last);
|
||||
Assert.False(pgo.IsHot);
|
||||
Assert.Equal($"Pgo({DefaultPeriod})", pgo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantBars_ZeroPgo()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 50, 50, 50, 50, 100));
|
||||
}
|
||||
// Constant bars have TR=0, SMA=close => PGO = 0/0 => 0.0 (guard)
|
||||
Assert.Equal(0.0, pgo.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_RisingClose_PositivePgo()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
}
|
||||
// Rising close above SMA => positive PGO
|
||||
Assert.True(pgo.Last.Value > 0);
|
||||
}
|
||||
|
||||
// ───── C) State + bar correction ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNew_True_AdvancesState()
|
||||
{
|
||||
var pgo = new Pgo(DefaultPeriod);
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000), isNew: true);
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 102, 110, 98, 108, 1000), isNew: true);
|
||||
|
||||
var last = pgo.Last;
|
||||
Assert.NotEqual(default, last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNew_False_RollsBack()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100), isNew: true);
|
||||
}
|
||||
|
||||
// Bar correction: rewrite last bar
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 104, 107, 103, 105, 100), isNew: false);
|
||||
var corrected = pgo.Last;
|
||||
|
||||
// Repeat same correction — should produce identical result
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 104, 107, 103, 105, 100), isNew: false);
|
||||
var corrected2 = pgo.Last;
|
||||
|
||||
Assert.Equal(corrected.Value, corrected2.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_Restore()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
TBar[] bars =
|
||||
[
|
||||
new(DateTime.UtcNow, 99, 102, 98, 100, 100),
|
||||
new(DateTime.UtcNow, 101, 104, 100, 102, 100),
|
||||
new(DateTime.UtcNow, 103, 106, 102, 104, 100),
|
||||
new(DateTime.UtcNow, 105, 108, 104, 106, 100),
|
||||
new(DateTime.UtcNow, 107, 110, 106, 108, 100),
|
||||
new(DateTime.UtcNow, 109, 112, 108, 110, 100),
|
||||
];
|
||||
|
||||
for (int i = 0; i < bars.Length; i++)
|
||||
{
|
||||
pgo.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
double baseline = pgo.Last.Value;
|
||||
|
||||
// Correct last bar 3 times, then restore original
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 120, 130, 110, 999, 100), isNew: false);
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 120, 130, 110, 888, 100), isNew: false);
|
||||
pgo.Update(bars[^1], isNew: false);
|
||||
|
||||
Assert.Equal(baseline, pgo.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var pgo = new Pgo(DefaultPeriod);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
}
|
||||
Assert.True(pgo.IsHot);
|
||||
|
||||
pgo.Reset();
|
||||
Assert.False(pgo.IsHot);
|
||||
Assert.Equal(default, pgo.Last);
|
||||
}
|
||||
|
||||
// ───── D) Warmup / convergence ─────
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsWhenBufferFull()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
Assert.False(pgo.IsHot);
|
||||
}
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 103, 106, 102, 104, 100));
|
||||
Assert.True(pgo.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesPeriod()
|
||||
{
|
||||
var pgo = new Pgo(period: 20);
|
||||
Assert.Equal(20, pgo.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ───── E) Robustness ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValid()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
}
|
||||
|
||||
pgo.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 100));
|
||||
Assert.True(double.IsFinite(pgo.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
}
|
||||
|
||||
pgo.Update(new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity,
|
||||
double.PositiveInfinity, double.PositiveInfinity, 100));
|
||||
Assert.True(double.IsFinite(pgo.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BatchNaN_Safe()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
pgo.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0));
|
||||
}
|
||||
Assert.True(double.IsFinite(pgo.Last.Value));
|
||||
}
|
||||
|
||||
// ───── F) Consistency (4 modes match) ─────
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResults()
|
||||
{
|
||||
int period = 10;
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 1. Streaming (TBar)
|
||||
var streaming = new Pgo(period);
|
||||
var streamResults = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(bars[i]).Value;
|
||||
}
|
||||
|
||||
// 2. Batch TBarSeries
|
||||
TSeries batchSeries = Pgo.Batch(bars, period);
|
||||
|
||||
// 3. Batch Span
|
||||
var spanOutput = new double[bars.Count];
|
||||
Pgo.Batch(bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput, period);
|
||||
|
||||
// Compare all modes
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchSeries.Values[i], Tolerance);
|
||||
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── G) Span API tests ─────
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MismatchedLength_ThrowsArgumentException()
|
||||
{
|
||||
var high = new double[10];
|
||||
var low = new double[10];
|
||||
var close = new double[10];
|
||||
var output = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pgo.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan(), DefaultPeriod));
|
||||
Assert.Equal("destination", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_ZeroPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var high = new double[10];
|
||||
var low = new double[10];
|
||||
var close = new double[10];
|
||||
var output = new double[10];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pgo.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan(), 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_Empty_NoException()
|
||||
{
|
||||
double[] high = [];
|
||||
double[] low = [];
|
||||
double[] close = [];
|
||||
double[] output = [];
|
||||
var ex = Record.Exception(() =>
|
||||
Pgo.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan(), DefaultPeriod));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MatchesTBarSeries()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
int period = 10;
|
||||
|
||||
TSeries batchTs = Pgo.Batch(bars, period);
|
||||
var spanOutput = new double[bars.Count];
|
||||
Pgo.Batch(bars.High.Values, bars.Low.Values, bars.Close.Values, spanOutput, period);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchTs.Values[i], spanOutput[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_NaN_Handled()
|
||||
{
|
||||
double[] high = [102, 104, double.NaN, 108, 110, 112, 114, 116, 118, 120];
|
||||
double[] low = [98, 100, double.NaN, 104, 106, 108, 110, 112, 114, 116];
|
||||
double[] close = [100, 102, double.NaN, 106, 108, 110, 112, 114, 116, 118];
|
||||
var output = new double[close.Length];
|
||||
var ex = Record.Exception(() =>
|
||||
Pgo.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan(), 5));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ───── H) Chainability ─────
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_FiresOnUpdate()
|
||||
{
|
||||
var pgo = new Pgo(DefaultPeriod);
|
||||
int firedCount = 0;
|
||||
pgo.Pub += (object? _, in TValueEventArgs _) => firedCount++;
|
||||
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000));
|
||||
Assert.Equal(1, firedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventChaining_Works()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
var downstream = new TSeries();
|
||||
pgo.Pub += (object? _, in TValueEventArgs e) => downstream.Add(e.Value);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double c = 100.0 + i;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 2, c - 2, c, 100));
|
||||
}
|
||||
|
||||
Assert.Equal(10, downstream.Count);
|
||||
}
|
||||
|
||||
// ───── Calculate ─────
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndHotIndicator()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var (results, indicator) = Pgo.Calculate(bars, period: 5);
|
||||
|
||||
Assert.Equal(bars.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
// ───── Update(TBarSeries) ─────
|
||||
|
||||
[Fact]
|
||||
public void UpdateTBarSeries_MatchesStreaming()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
int period = 10;
|
||||
|
||||
var streaming = new Pgo(period);
|
||||
var streamResults = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(bars[i]).Value;
|
||||
}
|
||||
|
||||
var batch = new Pgo(period);
|
||||
TSeries batchResults = batch.Update(bars);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults.Values[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── TValue overload ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_TValue_ReturnsResult()
|
||||
{
|
||||
var pgo = new Pgo(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pgo.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
// TValue creates synthetic bars (O=H=L=C=val). TR = |val - prevClose| > 0
|
||||
// when values change, so ATR > 0 and PGO is nonzero for rising prices.
|
||||
Assert.True(double.IsFinite(pgo.Last.Value));
|
||||
Assert.True(pgo.Last.Value > 0, "Rising TValue inputs should produce positive PGO");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class PgoValidationTests
|
||||
{
|
||||
private readonly TBarSeries _bars;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PgoValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
_bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Streaming_Batch_Span_Agree()
|
||||
{
|
||||
int period = 14;
|
||||
|
||||
// Streaming
|
||||
var streaming = new Pgo(period);
|
||||
var streamValues = new List<double>(_bars.Count);
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
streamValues.Add(streaming.Update(_bars[i]).Value);
|
||||
}
|
||||
|
||||
// Batch (TBarSeries)
|
||||
TSeries batchSeries = Pgo.Batch(_bars, period);
|
||||
|
||||
// Span
|
||||
var spanOutput = new double[_bars.Count];
|
||||
Pgo.Batch(_bars.High.Values, _bars.Low.Values, _bars.Close.Values, spanOutput, period);
|
||||
|
||||
// Batch vs span should match exactly (same code path).
|
||||
// Streaming vs batch should agree closely.
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchSeries[i].Value, spanOutput[i], 12); // batch=span (same path)
|
||||
Assert.Equal(batchSeries[i].Value, streamValues[i], 10); // streaming matches batch
|
||||
}
|
||||
|
||||
_output.WriteLine("PGO validation: streaming, batch, and span outputs agree within tolerance.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_KnownValues_ConstantPrice()
|
||||
{
|
||||
// Constant OHLC bars: close=SMA, TR=0, ATR=0 → PGO = 0
|
||||
int period = 5;
|
||||
var pgo = new Pgo(period);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
pgo.Update(new TBar(DateTime.UtcNow, 50, 50, 50, 50, 100));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, pgo.Last.Value, 10);
|
||||
_output.WriteLine("PGO known-values: constant bars produce PGO=0.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_KnownValues_PriceAboveSma()
|
||||
{
|
||||
// When close > SMA and ATR > 0, PGO should be positive
|
||||
int period = 5;
|
||||
var pgo = new Pgo(period);
|
||||
|
||||
// Feed gradually rising prices
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double c = 100.0 + i * 2;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 3, c - 3, c, 100));
|
||||
}
|
||||
|
||||
Assert.True(pgo.Last.Value > 0, $"Expected positive PGO for rising prices, got {pgo.Last.Value}");
|
||||
_output.WriteLine($"PGO known-values: rising prices produce positive PGO = {pgo.Last.Value:F6}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_KnownValues_PriceBelowSma()
|
||||
{
|
||||
// When close < SMA and ATR > 0, PGO should be negative
|
||||
int period = 5;
|
||||
var pgo = new Pgo(period);
|
||||
|
||||
// Feed rising prices first, then drop
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
double c = 100.0 + i * 5;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 3, c - 3, c, 100));
|
||||
}
|
||||
// Now drop sharply
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
double c = 80.0 - i * 5;
|
||||
pgo.Update(new TBar(DateTime.UtcNow, c - 1, c + 3, c - 3, c, 100));
|
||||
}
|
||||
|
||||
Assert.True(pgo.Last.Value < 0, $"Expected negative PGO for dropped prices, got {pgo.Last.Value}");
|
||||
_output.WriteLine($"PGO known-values: dropped prices produce negative PGO = {pgo.Last.Value:F6}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultiPeriod_Consistency()
|
||||
{
|
||||
// Different periods should produce different results
|
||||
int[] periods = [5, 14, 50];
|
||||
var results = new List<TSeries>();
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
results.Add(Pgo.Batch(_bars, period));
|
||||
}
|
||||
|
||||
// After all warmups, values should differ for different periods
|
||||
int checkIdx = 100;
|
||||
for (int i = 0; i < results.Count - 1; i++)
|
||||
{
|
||||
Assert.NotEqual(results[i][checkIdx].Value, results[i + 1][checkIdx].Value);
|
||||
}
|
||||
|
||||
_output.WriteLine("PGO multi-period: different periods produce different results.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Component_SmaAtr_Identity()
|
||||
{
|
||||
// Manually verify PGO = (close - SMA) / ATR
|
||||
// by computing SMA and ATR independently and comparing
|
||||
int period = 10;
|
||||
var pgo = new Pgo(period);
|
||||
|
||||
// Manual SMA/ATR tracking
|
||||
var smaBuffer = new RingBuffer(period);
|
||||
double smaSum = 0.0;
|
||||
double ema = 0.0;
|
||||
double e = 1.0;
|
||||
double alpha = 1.0 / period;
|
||||
double decay = 1.0 - alpha;
|
||||
double atr = 0.0;
|
||||
bool warmup = true;
|
||||
double prevClose = 0.0;
|
||||
bool hasPrev = false;
|
||||
|
||||
int validCount = 0;
|
||||
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
var bar = _bars[i];
|
||||
double close = bar.Close;
|
||||
double pc = hasPrev ? prevClose : close;
|
||||
|
||||
// SMA
|
||||
if (smaBuffer.Count == smaBuffer.Capacity)
|
||||
{
|
||||
smaSum -= smaBuffer.Oldest;
|
||||
}
|
||||
smaSum += close;
|
||||
smaBuffer.Add(close);
|
||||
double sma = smaSum / smaBuffer.Count;
|
||||
|
||||
// TR
|
||||
double tr = Math.Max(bar.High - bar.Low,
|
||||
Math.Max(Math.Abs(bar.High - pc), Math.Abs(bar.Low - pc)));
|
||||
|
||||
// EMA of TR
|
||||
ema = Math.FusedMultiplyAdd(alpha, tr - ema, ema);
|
||||
if (warmup)
|
||||
{
|
||||
e *= decay;
|
||||
double c = 1.0 / (1.0 - e);
|
||||
atr = c * ema;
|
||||
warmup = e > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
atr = ema;
|
||||
}
|
||||
|
||||
prevClose = close;
|
||||
hasPrev = true;
|
||||
|
||||
// PGO
|
||||
var result = pgo.Update(bar);
|
||||
double expectedPgo = atr > 0 ? (close - sma) / atr : 0.0;
|
||||
|
||||
if (smaBuffer.IsFull)
|
||||
{
|
||||
Assert.Equal(expectedPgo, result.Value, 10);
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(validCount > 0, "No valid comparison points");
|
||||
_output.WriteLine($"PGO component identity: validated {validCount} points.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Determinism()
|
||||
{
|
||||
// Run twice with same data — results must be identical
|
||||
int period = 14;
|
||||
var results1 = new double[_bars.Count];
|
||||
var results2 = new double[_bars.Count];
|
||||
|
||||
var pgo1 = new Pgo(period);
|
||||
var pgo2 = new Pgo(period);
|
||||
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
results1[i] = pgo1.Update(_bars[i]).Value;
|
||||
results2[i] = pgo2.Update(_bars[i]).Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(results1[i], results2[i], 15);
|
||||
}
|
||||
|
||||
_output.WriteLine("PGO determinism: two runs produce identical results.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// PGO: Pretty Good Oscillator
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measures the distance of the current price from its Simple Moving Average,
|
||||
/// normalized by the Average True Range (ATR). Output is in ATR multiples:
|
||||
/// <c>PGO = (source − SMA(source, period)) / ATR(period)</c>
|
||||
///
|
||||
/// ATR uses EMA smoothing with warmup compensation (PineScript convention).
|
||||
/// Values above +3 suggest overbought; below −3 suggest oversold.
|
||||
///
|
||||
/// References:
|
||||
/// Mark Johnson, "Pretty Good Oscillator"
|
||||
/// PineScript reference: pgo.pine
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Pgo : ITValuePublisher
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
private readonly RingBuffer _smaBuffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double SmaSum,
|
||||
double Ema,
|
||||
double E,
|
||||
double Atr,
|
||||
double PrevClose,
|
||||
double LastValid,
|
||||
bool Warmup,
|
||||
bool HasPrevClose);
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
private TValue _pLast;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the indicator.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
/// <summary>
|
||||
/// Current PGO value.
|
||||
/// </summary>
|
||||
public TValue Last { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public bool IsHot => _smaBuffer.IsFull;
|
||||
|
||||
/// <summary>
|
||||
/// The number of bars required to warm up the indicator.
|
||||
/// </summary>
|
||||
public int WarmupPeriod { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lookback period.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <summary>
|
||||
/// Creates PGO with specified period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period for SMA and ATR (must be > 0)</param>
|
||||
public Pgo(int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_alpha = 1.0 / period;
|
||||
_decay = 1.0 - _alpha;
|
||||
_smaBuffer = new RingBuffer(period);
|
||||
WarmupPeriod = period;
|
||||
Name = $"Pgo({period})";
|
||||
|
||||
_s = new State(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, true, false);
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the PGO state.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_smaBuffer.Clear();
|
||||
_s = new State(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, true, false);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
_pLast = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates PGO with a new bar (primary API — provides full OHLC for ATR).
|
||||
/// </summary>
|
||||
/// <param name="input">The new bar data</param>
|
||||
/// <param name="isNew">Whether this is a new bar or an update to the last bar</param>
|
||||
/// <returns>The updated PGO value</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
double close = input.Close;
|
||||
|
||||
// Sanitize input
|
||||
if (!double.IsFinite(close))
|
||||
{
|
||||
close = double.IsFinite(_s.LastValid) ? _s.LastValid : 0.0;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_pLast = Last;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
Last = _pLast;
|
||||
}
|
||||
|
||||
// Update last valid
|
||||
if (double.IsFinite(input.Close))
|
||||
{
|
||||
_s.LastValid = close;
|
||||
}
|
||||
|
||||
// --- SMA of close ---
|
||||
if (_smaBuffer.Count == _smaBuffer.Capacity)
|
||||
{
|
||||
_s.SmaSum -= _smaBuffer.Oldest;
|
||||
}
|
||||
_s.SmaSum += close;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_smaBuffer.Add(close);
|
||||
}
|
||||
else
|
||||
{
|
||||
_smaBuffer.UpdateNewest(close);
|
||||
// Recalculate sum after UpdateNewest
|
||||
_s.SmaSum = 0.0;
|
||||
for (int i = 0; i < _smaBuffer.Count; i++)
|
||||
{
|
||||
_s.SmaSum += _smaBuffer[i];
|
||||
}
|
||||
}
|
||||
|
||||
double sma = _smaBuffer.Count > 0 ? _s.SmaSum / _smaBuffer.Count : close;
|
||||
|
||||
// --- ATR via EMA(TR) with warmup compensation ---
|
||||
double high = double.IsFinite(input.High) ? input.High : close;
|
||||
double low = double.IsFinite(input.Low) ? input.Low : close;
|
||||
double prevClose = _s.HasPrevClose ? _s.PrevClose : close;
|
||||
|
||||
double tr1 = high - low;
|
||||
double tr2 = Math.Abs(high - prevClose);
|
||||
double tr3 = Math.Abs(low - prevClose);
|
||||
double tr = Math.Max(tr1, Math.Max(tr2, tr3));
|
||||
|
||||
// EMA: ema = alpha * (tr - ema) + ema
|
||||
_s.Ema = Math.FusedMultiplyAdd(_alpha, tr - _s.Ema, _s.Ema);
|
||||
|
||||
if (_s.Warmup)
|
||||
{
|
||||
_s.E *= _decay;
|
||||
double c = 1.0 / (1.0 - _s.E);
|
||||
_s.Atr = c * _s.Ema;
|
||||
_s.Warmup = _s.E > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s.Atr = _s.Ema;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_s.PrevClose = close;
|
||||
_s.HasPrevClose = true;
|
||||
}
|
||||
|
||||
// --- PGO ---
|
||||
double pgo = _s.Atr > 0 ? (close - sma) / _s.Atr : 0.0;
|
||||
|
||||
Last = new TValue(input.Time, pgo);
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates PGO with a new value. Uses value as close; TR = 0 (no OHLC context).
|
||||
/// For full accuracy, prefer <see cref="Update(TBar, bool)"/>.
|
||||
/// </summary>
|
||||
/// <param name="input">The new value (treated as close)</param>
|
||||
/// <param name="isNew">Whether this is a new value or an update to the last value</param>
|
||||
/// <returns>The updated PGO value</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double val = input.Value;
|
||||
// Create a synthetic bar: O=H=L=C=val → TR = 0 for single values
|
||||
return Update(new TBar(input.Time, val, val, val, val, 0), isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates PGO with a series of bars.
|
||||
/// </summary>
|
||||
/// <param name="source">The source bar series</param>
|
||||
/// <returns>PGO output series</returns>
|
||||
public TSeries Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var v = new double[len];
|
||||
|
||||
Batch(source.High.Values, source.Low.Values, source.Close.Values, v, _period);
|
||||
|
||||
var tList = new List<long>(len);
|
||||
CollectionsMarshal.SetCount(tList, len);
|
||||
var tSpan = CollectionsMarshal.AsSpan(tList);
|
||||
source.Open.Times.CopyTo(tSpan);
|
||||
|
||||
var vList = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(vList, len);
|
||||
var vSpan = CollectionsMarshal.AsSpan(vList);
|
||||
v.AsSpan().CopyTo(vSpan);
|
||||
|
||||
// Restore streaming state
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(tList, vList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the indicator state using historical bar data.
|
||||
/// </summary>
|
||||
/// <param name="source">Historical bar series</param>
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch PGO calculation over OHLC spans.
|
||||
/// </summary>
|
||||
/// <param name="high">High prices</param>
|
||||
/// <param name="low">Low prices</param>
|
||||
/// <param name="close">Close prices (used for SMA and TR)</param>
|
||||
/// <param name="destination">Output PGO values</param>
|
||||
/// <param name="period">Lookback period (default 14)</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> high, ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close, Span<double> destination, int period = 14)
|
||||
{
|
||||
if (high.Length != low.Length || high.Length != close.Length || high.Length != destination.Length)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"High, low, close, and destination spans must have the same length.", nameof(destination));
|
||||
}
|
||||
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SMA buffer
|
||||
var smaBuffer = new RingBuffer(period);
|
||||
double smaSum = 0.0;
|
||||
|
||||
// ATR via EMA with warmup compensation
|
||||
double alpha = 1.0 / period;
|
||||
double decay = 1.0 - alpha;
|
||||
double ema = 0.0;
|
||||
double e = 1.0;
|
||||
double atr = 0.0;
|
||||
bool warmup = true;
|
||||
double prevClose = close[0];
|
||||
double lastValid = 0.0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = close[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
// SMA
|
||||
if (smaBuffer.Count == smaBuffer.Capacity)
|
||||
{
|
||||
smaSum -= smaBuffer.Oldest;
|
||||
}
|
||||
smaSum += val;
|
||||
smaBuffer.Add(val);
|
||||
double sma = smaSum / smaBuffer.Count;
|
||||
|
||||
// TR
|
||||
double h = double.IsFinite(high[i]) ? high[i] : val;
|
||||
double l = double.IsFinite(low[i]) ? low[i] : val;
|
||||
double pc = i > 0 ? prevClose : val;
|
||||
|
||||
double tr1 = h - l;
|
||||
double tr2 = Math.Abs(h - pc);
|
||||
double tr3 = Math.Abs(l - pc);
|
||||
double tr = Math.Max(tr1, Math.Max(tr2, tr3));
|
||||
|
||||
// EMA of TR
|
||||
ema = Math.FusedMultiplyAdd(alpha, tr - ema, ema);
|
||||
|
||||
if (warmup)
|
||||
{
|
||||
e *= decay;
|
||||
double c = 1.0 / (1.0 - e);
|
||||
atr = c * ema;
|
||||
warmup = e > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
atr = ema;
|
||||
}
|
||||
|
||||
prevClose = val;
|
||||
|
||||
destination[i] = atr > 0 ? (val - sma) / atr : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates PGO for the entire bar series using a stateless batch path.
|
||||
/// </summary>
|
||||
/// <param name="source">Input bar series</param>
|
||||
/// <param name="period">Lookback period (default 14)</param>
|
||||
/// <returns>PGO output series</returns>
|
||||
public static TSeries Batch(TBarSeries source, int period = 14)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var v = new double[len];
|
||||
|
||||
Batch(source.High.Values, source.Low.Values, source.Close.Values, v, period);
|
||||
|
||||
var tList = new List<long>(len);
|
||||
CollectionsMarshal.SetCount(tList, len);
|
||||
var tSpan = CollectionsMarshal.AsSpan(tList);
|
||||
source.Open.Times.CopyTo(tSpan);
|
||||
|
||||
var vList = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(vList, len);
|
||||
var vSpan = CollectionsMarshal.AsSpan(vList);
|
||||
v.AsSpan().CopyTo(vSpan);
|
||||
|
||||
return new TSeries(tList, vList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates PGO for the entire series, returning both results and indicator.
|
||||
/// </summary>
|
||||
public static (TSeries Results, Pgo Indicator) Calculate(TBarSeries source, int period = 14)
|
||||
{
|
||||
var indicator = new Pgo(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# PGO: Pretty Good Oscillator
|
||||
|
||||
> "Good enough to trade, honest enough not to pretend otherwise." — Mark Johnson (probably)
|
||||
|
||||
## Introduction
|
||||
|
||||
The Pretty Good Oscillator (PGO) measures how far the current price has deviated from its Simple Moving Average, expressed in Average True Range (ATR) units. A reading of +2.0 means price is two ATRs above the SMA; -3.0 means three ATRs below. The volatility normalization makes PGO readings comparable across instruments and timeframes, unlike raw price-minus-average oscillators that scale with price level.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Mark Johnson introduced PGO in the late 1990s as a practical alternative to oscillators that produce instrument-dependent readings. The core insight: dividing by ATR creates a dimensionless ratio. A stock at $500 and a penny stock at $2 can both produce a PGO of +3.0, and that reading carries the same statistical meaning in both cases. The name "Pretty Good" reflects Johnson's deliberately modest positioning: not the ultimate oscillator, but a reliable workhorse that normalizes displacement by realized volatility rather than by standard deviation (like Bollinger %B) or by price level (like CFO).
|
||||
|
||||
The indicator shares conceptual DNA with z-scores and Bollinger Bands but uses ATR (which captures gap risk through True Range) rather than standard deviation (which doesn't). This makes PGO more responsive to overnight gaps and limit moves.
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Simple Moving Average (SMA)
|
||||
|
||||
Standard arithmetic mean over the lookback period:
|
||||
|
||||
$$\text{SMA}_t = \frac{1}{N} \sum_{i=0}^{N-1} \text{Close}_{t-i}$$
|
||||
|
||||
Implemented as a running sum with O(1) incremental updates via RingBuffer.
|
||||
|
||||
### 2. True Range (TR)
|
||||
|
||||
$$\text{TR}_t = \max\bigl(\text{High}_t - \text{Low}_t,\; |\text{High}_t - \text{Close}_{t-1}|,\; |\text{Low}_t - \text{Close}_{t-1}|\bigr)$$
|
||||
|
||||
Captures both intrabar range and gap risk from the previous close.
|
||||
|
||||
### 3. Average True Range (ATR)
|
||||
|
||||
Exponential Moving Average of TR with warmup compensation:
|
||||
|
||||
$$\text{ATR}_t = \text{EMA}(\text{TR}, N) \cdot \frac{1}{1 - (1 - \alpha)^t}$$
|
||||
|
||||
where $\alpha = 1/N$. The compensation factor corrects the EMA bias during the initial warmup period, converging to 1.0 as $t \to \infty$.
|
||||
|
||||
### 4. PGO Computation
|
||||
|
||||
$$\text{PGO}_t = \frac{\text{Close}_t - \text{SMA}_t}{\text{ATR}_t}$$
|
||||
|
||||
When $\text{ATR} = 0$ (constant price), PGO returns 0.0 by convention.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The PGO is a volatility-normalized displacement measure. In continuous terms:
|
||||
|
||||
$$\text{PGO} = \frac{P - \bar{P}}{\sigma_{\text{ATR}}}$$
|
||||
|
||||
where $\bar{P}$ is the rolling mean and $\sigma_{\text{ATR}}$ is the ATR-based volatility estimate.
|
||||
|
||||
### Parameter Mapping
|
||||
|
||||
| Parameter | Default | Range | Effect |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Period ($N$) | 14 | 1-500 | Controls both SMA lookback and ATR smoothing. Larger periods produce smoother, slower oscillations. |
|
||||
|
||||
### Transfer Function
|
||||
|
||||
PGO has no recursive (IIR) component in its numerator; SMA is pure FIR. The ATR denominator uses EMA (IIR) with transfer function:
|
||||
|
||||
$$H(z) = \frac{\alpha}{1 - (1-\alpha)z^{-1}}$$
|
||||
|
||||
This gives the denominator exponential decay characteristics while the numerator remains finite-impulse.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| SMA update | O(1) | RingBuffer running sum |
|
||||
| TR calculation | O(1) | Three comparisons |
|
||||
| ATR (EMA) update | O(1) | FMA-optimized IIR |
|
||||
| PGO computation | O(1) | Single division |
|
||||
| **Total per bar** | **O(1)** | Zero allocations in hot path |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score (1-10) | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Noise rejection | 5 | SMA has no frequency selectivity |
|
||||
| Lag | 6 | SMA lag = (N-1)/2 bars; ATR smoothing adds minimal lag |
|
||||
| Sensitivity | 7 | ATR normalization adapts to volatility regimes |
|
||||
| Simplicity | 9 | Two components, one parameter |
|
||||
| Cross-instrument comparability | 9 | Dimensionless output |
|
||||
|
||||
## Interpretation
|
||||
|
||||
### Overbought/Oversold Levels
|
||||
|
||||
- **Above +3.0**: Price is 3 ATRs above the mean. Statistically extended; reversal probability increases.
|
||||
- **Below -3.0**: Price is 3 ATRs below the mean. Statistically depressed; bounce probability increases.
|
||||
- **Between -1.0 and +1.0**: Normal range; no directional bias.
|
||||
|
||||
### Zero Line Crossovers
|
||||
|
||||
- PGO crosses above zero: price crosses above SMA (bullish momentum shift).
|
||||
- PGO crosses below zero: price crosses below SMA (bearish momentum shift).
|
||||
|
||||
### Divergence Analysis
|
||||
|
||||
- **Bullish divergence**: Price makes lower lows while PGO makes higher lows. ATR-normalized displacement is contracting despite new price lows — sellers exhausting.
|
||||
- **Bearish divergence**: Price makes higher highs while PGO makes lower highs. Despite new highs, displacement relative to volatility is shrinking.
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Validated | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Skender | - | No PGO implementation |
|
||||
| TA-Lib | - | No PGO implementation |
|
||||
| Tulip | - | No PGO implementation |
|
||||
| Ooples | - | Not verified |
|
||||
| Self-consistency | ✔️ | Batch/streaming/span agree; component identity verified |
|
||||
|
||||
Cross-validation: PGO is verified against manual SMA + ATR computation. Streaming, batch (TBarSeries), and span paths produce identical results within floating-point tolerance ($10^{-10}$).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Ignoring ATR=0**: Constant-price instruments produce zero ATR. Division by zero must be guarded (returns 0.0).
|
||||
2. **Comparing across periods**: PGO(14) and PGO(50) are not directly comparable. Longer periods smooth more aggressively, producing smaller absolute readings.
|
||||
3. **Using without OHLC data**: PGO requires High/Low/Close for True Range. Feeding only close prices produces TR=0 (synthetic bars with H=L=C), making the oscillator meaningless.
|
||||
4. **Fixed overbought/oversold thresholds**: The ±3.0 levels are guidelines. Fat-tailed distributions (common in finance) produce more extreme readings than Gaussian models suggest.
|
||||
5. **SMA lag in trending markets**: SMA introduces (N-1)/2 bars of lag. In strong trends, PGO may show persistent readings of ±2-4 without mean reversion. This is a feature, not a bug — it confirms trend strength.
|
||||
6. **Warmup period**: PGO requires N bars to fill the SMA buffer and begin producing valid readings. ATR warmup is handled by exponential compensation but converges asymptotically.
|
||||
7. **Not a standalone signal**: PGO measures displacement, not direction. Combine with trend filters (e.g., moving average slope) for directional context.
|
||||
|
||||
## References
|
||||
|
||||
- Johnson, M. "Pretty Good Oscillator." Technical analysis community publication.
|
||||
- Wilder, J.W. "New Concepts in Technical Trading Systems." Trend Research, 1978. (ATR foundation)
|
||||
- PineScript reference implementation: `pgo.pine`
|
||||
Reference in New Issue
Block a user