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:
Miha Kralj
2026-02-12 14:29:54 -08:00
parent 653aafacd8
commit 92709ef2ed
73 changed files with 14721 additions and 35 deletions
+110
View File
@@ -0,0 +1,110 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class AcIndicatorTests
{
[Fact]
public void AcIndicator_Constructor_SetsDefaults()
{
var indicator = new AcIndicator();
Assert.Equal(5, indicator.FastPeriod);
Assert.Equal(34, indicator.SlowPeriod);
Assert.Equal(5, indicator.AcPeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("AC - Acceleration Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AcIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new AcIndicator { SlowPeriod = 20 };
Assert.Equal(0, AcIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void AcIndicator_ShortName_IncludesParameters()
{
var indicator = new AcIndicator { FastPeriod = 10, SlowPeriod = 40, AcPeriod = 7 };
indicator.Initialize();
Assert.Contains("AC", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("40", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("7", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void AcIndicator_SourceCodeLink_IsValid()
{
var indicator = new AcIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ac.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void AcIndicator_Initialize_CreatesInternalAc()
{
var indicator = new AcIndicator { FastPeriod = 5, SlowPeriod = 34, AcPeriod = 5 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (Up and Down)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void AcIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AcIndicator { FastPeriod = 2, SlowPeriod = 5, AcPeriod = 3 };
indicator.Initialize();
// Add historical data
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);
}
// Line series should have a value (either Up or Down)
double up = indicator.LinesSeries[0].GetValue(0);
double down = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(up) || double.IsFinite(down));
}
[Fact]
public void AcIndicator_ProcessUpdate_NewBar_UpdatesValue()
{
var indicator = new AcIndicator { FastPeriod = 2, SlowPeriod = 5, AcPeriod = 3 };
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 reason = i < 19 ? UpdateReason.HistoricalBar : UpdateReason.NewBar;
var args = new UpdateArgs(reason);
indicator.ProcessUpdate(args);
}
// Verify line series has values
double up = indicator.LinesSeries[0].GetValue(0);
double down = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(up) || double.IsFinite(down));
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class AcIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Fast Period", sortIndex: 1, 1, 1000, 1, 0)]
public int FastPeriod { get; set; } = 5;
[InputParameter("Slow Period", sortIndex: 2, 1, 1000, 1, 0)]
public int SlowPeriod { get; set; } = 34;
[InputParameter("AC Period", sortIndex: 3, 1, 1000, 1, 0)]
public int AcPeriod { get; set; } = 5;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ac _ac = null!;
private readonly LineSeries _upSeries;
private readonly LineSeries _downSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"AC {FastPeriod}:{SlowPeriod}:{AcPeriod}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/ac/Ac.Quantower.cs";
public AcIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "AC - Acceleration Oscillator";
Description = "Measures acceleration/deceleration of market driving force";
_upSeries = new LineSeries(name: "AC Up", color: Color.Green, width: 2, style: LineStyle.Solid);
_downSeries = new LineSeries(name: "AC Down", color: Color.Red, width: 2, style: LineStyle.Solid);
AddLineSeries(_upSeries);
AddLineSeries(_downSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_ac = new Ac(FastPeriod, SlowPeriod, AcPeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _ac.Update(this.GetInputBar(args), args.IsNewBar());
if (!_ac.IsHot && !ShowColdValues)
{
return;
}
double prevAc = double.NaN;
if (Count > 1)
{
prevAc = _upSeries.GetValue(1);
if (double.IsNaN(prevAc))
{
prevAc = _downSeries.GetValue(1);
}
}
if (double.IsNaN(prevAc) || result.Value > prevAc)
{
_upSeries.SetValue(result.Value);
_downSeries.SetValue(double.NaN);
}
else
{
_downSeries.SetValue(result.Value);
_upSeries.SetValue(double.NaN);
}
}
}
+387
View File
@@ -0,0 +1,387 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class AcTests
{
private readonly GBM _gbm = new(1000.0, 0.05, 0.3, seed: 42);
// ── A) Constructor validation ──
[Fact]
public void Constructor_FastPeriodZero_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ac(fastPeriod: 0));
Assert.Equal("fastPeriod", ex.ParamName);
}
[Fact]
public void Constructor_SlowPeriodZero_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ac(slowPeriod: 0));
Assert.Equal("slowPeriod", ex.ParamName);
}
[Fact]
public void Constructor_FastGeSlow_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ac(fastPeriod: 34, slowPeriod: 5));
Assert.Equal("fastPeriod", ex.ParamName);
}
[Fact]
public void Constructor_AcPeriodZero_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ac(acPeriod: 0));
Assert.Equal("acPeriod", ex.ParamName);
}
[Fact]
public void Constructor_Defaults_NameCorrect()
{
var ac = new Ac();
Assert.Equal("Ac(5,34,5)", ac.Name);
}
[Fact]
public void Constructor_Custom_WarmupPeriod()
{
var ac = new Ac(5, 34, 5);
Assert.Equal(38, ac.WarmupPeriod); // 34 + 5 - 1
}
// ── B) Basic calculation ──
[Fact]
public void Update_SingleBar_ReturnsValue()
{
var ac = new Ac();
var bar = _gbm.Next(isNew: true);
var result = ac.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Last_IsAccessible()
{
var ac = new Ac();
var bar = _gbm.Next(isNew: true);
_ = ac.Update(bar);
Assert.True(double.IsFinite(ac.Last.Value));
}
[Fact]
public void Update_ConstantPrice_ConvergesToZero()
{
var ac = new Ac();
for (int i = 0; i < 100; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100.0, 100.0, 100.0, 100.0, 1000.0);
_ = ac.Update(bar, isNew: true);
}
Assert.True(ac.IsHot);
Assert.Equal(0.0, ac.Last.Value, 1e-10);
}
// ── C) State + bar correction ──
[Fact]
public void Update_IsNew_True_AdvancesState()
{
var ac = new Ac();
// Feed enough bars so the values diverge from zero
for (int i = 0; i < 40; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
var bar1 = _gbm.Next(isNew: true);
var result1 = ac.Update(bar1, isNew: true);
var bar2 = _gbm.Next(isNew: true);
var result2 = ac.Update(bar2, isNew: true);
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Update_IsNew_False_Rewrites()
{
var ac = new Ac();
for (int i = 0; i < 40; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
var bar = _gbm.Next(isNew: true);
var first = ac.Update(bar, isNew: true);
var correctionBar = new TBar(bar.Time, bar.Open * 1.01, bar.High * 1.01, bar.Low * 1.01, bar.Close * 1.01, bar.Volume);
var corrected = ac.Update(correctionBar, isNew: false);
Assert.NotEqual(first.Value, corrected.Value);
}
[Fact]
public void Update_IterativeCorrections_Restore()
{
var ac = new Ac();
for (int i = 0; i < 40; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
var bar = _gbm.Next(isNew: true);
var first = ac.Update(bar, isNew: true);
// Apply corrections multiple times
for (int i = 0; i < 5; i++)
{
_ = ac.Update(bar, isNew: false);
}
var final = ac.Update(bar, isNew: false);
Assert.Equal(first.Value, final.Value, 1e-10);
}
[Fact]
public void Reset_ClearsState()
{
var ac = new Ac();
for (int i = 0; i < 50; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
Assert.True(ac.IsHot);
ac.Reset();
Assert.False(ac.IsHot);
Assert.Equal(0.0, ac.Last.Value);
}
// ── D) Warmup / convergence ──
[Fact]
public void IsHot_FlipsAfterSufficientData()
{
var ac = new Ac(5, 34, 5);
// Feed just 1 bar — should not be hot yet
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
// May already become hot if inner SMA sees enough values
// After feeding enough bars, must be hot
for (int i = 1; i < 50; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
Assert.True(ac.IsHot);
}
// ── E) Robustness ──
[Fact]
public void Update_NaN_KeepsLastValid()
{
var ac = new Ac();
for (int i = 0; i < 40; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
var lastBefore = ac.Last;
var nanInput = new TValue(DateTime.UtcNow, double.NaN);
var result = ac.Update(nanInput, isNew: true);
Assert.Equal(lastBefore.Value, result.Value, 1e-10);
}
[Fact]
public void Update_Infinity_KeepsLastValid()
{
var ac = new Ac();
for (int i = 0; i < 40; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
var lastBefore = ac.Last;
var infInput = new TValue(DateTime.UtcNow, double.PositiveInfinity);
var result = ac.Update(infInput, isNew: true);
Assert.Equal(lastBefore.Value, result.Value, 1e-10);
}
// ── F) Consistency (batch == streaming == span == eventing) ──
[Fact]
public void BatchCalc_Matches_Streaming()
{
var gbm = new GBM(500.0, 0.05, 0.3, seed: 99);
var series = new TBarSeries();
for (int i = 0; i < 100; i++)
{
series.Add(gbm.Next(isNew: true));
}
// Streaming
var streaming = new Ac();
for (int i = 0; i < series.Count; i++)
{
_ = streaming.Update(series[i], isNew: true);
}
// Batch via Update(TBarSeries)
var batchAc = new Ac();
var batchResult = batchAc.Update(series);
// Compare last values
Assert.Equal(streaming.Last.Value, batchResult[^1].Value, 4);
}
[Fact]
public void SpanBatch_Matches_Streaming()
{
var gbm = new GBM(500.0, 0.05, 0.3, seed: 99);
var series = new TBarSeries();
for (int i = 0; i < 100; i++)
{
series.Add(gbm.Next(isNew: true));
}
// Streaming
var streaming = new Ac();
for (int i = 0; i < series.Count; i++)
{
_ = streaming.Update(series[i], isNew: true);
}
// Span batch
var output = new double[series.Count];
Ac.Batch(series.High.Values, series.Low.Values, output);
Assert.Equal(streaming.Last.Value, output[^1], 4);
}
[Fact]
public void EventPub_FiresOnUpdate()
{
var ac = new Ac();
int pubCount = 0;
ac.Pub += (object? sender, in TValueEventArgs e) => pubCount++;
for (int i = 0; i < 5; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
Assert.Equal(5, pubCount);
}
// ── G) Span API tests ──
[Fact]
public void Batch_Span_MismatchedLengths_Throws()
{
var high = new double[10];
var low = new double[10];
var dest = new double[5]; // wrong length
var ex = Assert.Throws<ArgumentException>(() => Ac.Batch(high, low, dest));
Assert.Equal("destination", ex.ParamName);
}
[Fact]
public void Batch_Span_Empty_NoException()
{
var output = Array.Empty<double>();
Ac.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty, output);
Assert.Empty(output);
}
// ── H) Chainability ──
[Fact]
public void EventChaining_Works()
{
var ac = new Ac();
var values = new List<double>();
ac.Pub += (object? sender, in TValueEventArgs e) => values.Add(e.Value.Value);
for (int i = 0; i < 50; i++)
{
_ = ac.Update(_gbm.Next(isNew: true), isNew: true);
}
Assert.Equal(50, values.Count);
}
// ── Additional: TValue Update path ──
[Fact]
public void TValueUpdate_Works()
{
var ac = new Ac();
for (int i = 0; i < 50; i++)
{
var val = new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.1);
_ = ac.Update(val, isNew: true);
}
Assert.True(ac.IsHot);
Assert.True(double.IsFinite(ac.Last.Value));
}
[Fact]
public void Prime_SetsState()
{
var gbm = new GBM(500.0, 0.05, 0.3, seed: 77);
var series = new TBarSeries();
for (int i = 0; i < 60; i++)
{
series.Add(gbm.Next(isNew: true));
}
var ac = new Ac();
ac.Prime(series);
Assert.True(ac.IsHot);
Assert.True(double.IsFinite(ac.Last.Value));
}
[Fact]
public void Calculate_ReturnsResultAndIndicator()
{
var gbm = new GBM(500.0, 0.05, 0.3, seed: 88);
var series = new TBarSeries();
for (int i = 0; i < 60; i++)
{
series.Add(gbm.Next(isNew: true));
}
var (results, indicator) = Ac.Calculate(series);
Assert.Equal(60, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void Batch_TBarSeries_Empty()
{
var result = Ac.Batch(new TBarSeries());
Assert.Empty(result);
}
[Fact]
public void Update_TBarSeries_Empty()
{
var ac = new Ac();
var result = ac.Update(new TBarSeries());
Assert.Empty(result);
}
}
+180
View File
@@ -0,0 +1,180 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Self-consistency validation for AC. No external library implements AC with
/// identical SMA-based methodology, so we validate AC = AO - SMA(AO, acPeriod)
/// identity, determinism, and cross-mode consistency.
/// </summary>
public sealed class AcValidationTests
{
private static TBarSeries GenerateSeries(int count, int seed = 42)
{
var gbm = new GBM(500.0, 0.05, 0.3, seed: seed);
var series = new TBarSeries();
for (int i = 0; i < count; i++)
{
series.Add(gbm.Next(isNew: true));
}
return series;
}
[Fact]
public void AC_Equals_AO_Minus_SMA_AO()
{
var series = GenerateSeries(200);
// Compute AO
var ao = new Ao();
var aoValues = new List<double>();
for (int i = 0; i < series.Count; i++)
{
var r = ao.Update(series[i], isNew: true);
aoValues.Add(r.Value);
}
// Compute SMA(AO, 5)
var smaAo = new Sma(5);
var smaAoValues = new List<double>();
for (int i = 0; i < aoValues.Count; i++)
{
var r = smaAo.Update(new TValue(DateTime.UtcNow.AddMinutes(i), aoValues[i]), isNew: true);
smaAoValues.Add(r.Value);
}
// Compute AC via streaming
var ac = new Ac();
var acValues = new List<double>();
for (int i = 0; i < series.Count; i++)
{
var r = ac.Update(series[i], isNew: true);
acValues.Add(r.Value);
}
// Verify AC = AO - SMA(AO, 5) once all are hot
int start = 38; // slowPeriod(34) + acPeriod(5) - 1
for (int i = start; i < series.Count; i++)
{
double expected = aoValues[i] - smaAoValues[i];
Assert.Equal(expected, acValues[i], 1e-10);
}
}
[Fact]
public void BatchAndStreaming_Match()
{
var series = GenerateSeries(200);
// Streaming
var streaming = new Ac();
var streamValues = new List<double>();
for (int i = 0; i < series.Count; i++)
{
var r = streaming.Update(series[i], isNew: true);
streamValues.Add(r.Value);
}
// Batch
var batchResult = Ac.Batch(series);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(streamValues[i], batchResult[i].Value, 4);
}
}
[Fact]
public void Determinism_SameSeedProducesSameResults()
{
var series1 = GenerateSeries(100, seed: 123);
var series2 = GenerateSeries(100, seed: 123);
var ac1 = new Ac();
var ac2 = new Ac();
for (int i = 0; i < series1.Count; i++)
{
var r1 = ac1.Update(series1[i], isNew: true);
var r2 = ac2.Update(series2[i], isNew: true);
Assert.Equal(r1.Value, r2.Value, 1e-12);
}
}
[Fact]
public void SpanBatch_Matches_TBarSeriesBatch()
{
var series = GenerateSeries(150);
var batchResult = Ac.Batch(series);
var output = new double[series.Count];
Ac.Batch(series.High.Values, series.Low.Values, output);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(batchResult[i].Value, output[i], 1e-10);
}
}
[Fact]
public void ParameterSensitivity_DifferentPeriods_DifferentResults()
{
var series = GenerateSeries(100);
var ac1 = new Ac(5, 34, 5);
var ac2 = new Ac(3, 20, 5);
for (int i = 0; i < series.Count; i++)
{
_ = ac1.Update(series[i], isNew: true);
_ = ac2.Update(series[i], isNew: true);
}
Assert.NotEqual(ac1.Last.Value, ac2.Last.Value);
}
[Fact]
public void LargeDataset_Stability()
{
var series = GenerateSeries(5000, seed: 55);
var ac = new Ac();
for (int i = 0; i < series.Count; i++)
{
var result = ac.Update(series[i], isNew: true);
Assert.True(double.IsFinite(result.Value), $"Non-finite at bar {i}");
}
Assert.True(ac.IsHot);
}
[Fact]
public void MonotonicConvergence_ConstantInput()
{
var ac = new Ac();
double prevAbsValue = double.MaxValue;
bool convergenceStarted = false;
for (int i = 0; i < 200; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 50.0, 50.0, 50.0, 50.0, 1000.0);
var result = ac.Update(bar, isNew: true);
if (ac.IsHot && i > 50)
{
double absVal = Math.Abs(result.Value);
if (convergenceStarted)
{
Assert.True(absVal <= prevAbsValue + 1e-10, $"Not converging at bar {i}: {absVal} > {prevAbsValue}");
}
convergenceStarted = true;
prevAbsValue = absVal;
}
}
Assert.True(convergenceStarted);
}
}
+345
View File
@@ -0,0 +1,345 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// AC: Acceleration Oscillator
/// </summary>
/// <remarks>
/// Bill Williams' Acceleration Oscillator measures the acceleration or deceleration
/// of the current market driving force. AC is the second derivative of price momentum:
///
/// Median Price = (High + Low) / 2
/// AO = SMA(Median Price, fastPeriod) - SMA(Median Price, slowPeriod)
/// AC = AO - SMA(AO, acPeriod)
///
/// Sources:
/// https://www.investopedia.com/terms/a/accelerationdeceleration-indicator.asp
/// https://www.tradingview.com/support/solutions/43000501837-accelerator-oscillator-ac/
/// </remarks>
[SkipLocalsInit]
public sealed class Ac : ITValuePublisher
{
private readonly int _fastPeriod;
private readonly int _slowPeriod;
private readonly int _acPeriod;
private readonly Sma _smaFast;
private readonly Sma _smaSlow;
private readonly Sma _smaAc;
private TValue _p_Last;
/// <summary>Display name for the indicator.</summary>
public string Name { get; }
public event TValuePublishedHandler? Pub;
/// <summary>Current AC value.</summary>
public TValue Last { get; private set; }
/// <summary>True if the AC has enough data to produce valid results.</summary>
public bool IsHot => _smaAc.IsHot;
/// <summary>The number of bars required to warm up the indicator.</summary>
public int WarmupPeriod { get; }
/// <summary>
/// Creates AC with specified periods.
/// </summary>
/// <param name="fastPeriod">Fast SMA period for AO calculation (default 5)</param>
/// <param name="slowPeriod">Slow SMA period for AO calculation (default 34)</param>
/// <param name="acPeriod">SMA period applied to AO for AC calculation (default 5)</param>
public Ac(int fastPeriod = 5, int slowPeriod = 34, int acPeriod = 5)
{
if (fastPeriod <= 0)
{
throw new ArgumentException("Fast period must be greater than 0", nameof(fastPeriod));
}
if (slowPeriod <= 0)
{
throw new ArgumentException("Slow period must be greater than 0", nameof(slowPeriod));
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
}
if (acPeriod <= 0)
{
throw new ArgumentException("AC period must be greater than 0", nameof(acPeriod));
}
_fastPeriod = fastPeriod;
_slowPeriod = slowPeriod;
_acPeriod = acPeriod;
_smaFast = new Sma(fastPeriod);
_smaSlow = new Sma(slowPeriod);
_smaAc = new Sma(acPeriod);
WarmupPeriod = slowPeriod + acPeriod - 1;
Name = $"Ac({fastPeriod},{slowPeriod},{acPeriod})";
}
/// <summary>Resets the AC state.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_smaFast.Reset();
_smaSlow.Reset();
_smaAc.Reset();
Last = default;
_p_Last = default;
}
/// <summary>
/// Updates the AC with a new bar.
/// </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 AC value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (!double.IsFinite(input.High) || !double.IsFinite(input.Low))
{
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = false });
return Last;
}
double medianPrice = (input.High + input.Low) * 0.5;
var val = new TValue(input.Time, medianPrice);
if (isNew)
{
_p_Last = Last;
}
else
{
Last = _p_Last;
}
var sFast = _smaFast.Update(val, isNew);
var sSlow = _smaSlow.Update(val, isNew);
double ao = sFast.Value - sSlow.Value;
var aoVal = new TValue(input.Time, ao);
var sAc = _smaAc.Update(aoVal, isNew);
double ac = ao - sAc.Value;
Last = new TValue(input.Time, ac);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the AC with a new value (assumes value is Median Price).
/// </summary>
/// <param name="input">The new value</param>
/// <param name="isNew">Whether this is a new value or an update to the last value</param>
/// <returns>The updated AC value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
if (!double.IsFinite(input.Value))
{
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = false });
return Last;
}
if (isNew)
{
_p_Last = Last;
}
else
{
Last = _p_Last;
}
var sFast = _smaFast.Update(input, isNew);
var sSlow = _smaSlow.Update(input, isNew);
double ao = sFast.Value - sSlow.Value;
var aoVal = new TValue(input.Time, ao);
var sAc = _smaAc.Update(aoVal, isNew);
double ac = ao - sAc.Value;
Last = new TValue(input.Time, ac);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the AC with a series of bars.
/// </summary>
/// <param name="source">The source series of bars</param>
/// <returns>The AC 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, v, _fastPeriod, _slowPeriod, _acPeriod);
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 so the instance is hot after batch update
Reset();
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
}
return new TSeries(tList, vList);
}
/// <summary>
/// Initializes the indicator state using the provided bar series history.
/// </summary>
/// <param name="source">Historical bar data.</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>
/// Calculates AC over OHLC spans into a preallocated output span.
/// Median price is computed as (High + Low) / 2.
/// </summary>
/// <param name="high">High prices</param>
/// <param name="low">Low prices</param>
/// <param name="destination">Output AC values</param>
/// <param name="fastPeriod">Fast SMA period (default 5)</param>
/// <param name="slowPeriod">Slow SMA period (default 34)</param>
/// <param name="acPeriod">AC SMA period (default 5)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> high, ReadOnlySpan<double> low, Span<double> destination, int fastPeriod = 5, int slowPeriod = 34, int acPeriod = 5)
{
if (fastPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(fastPeriod), "Fast period must be greater than 0.");
}
if (slowPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(slowPeriod), "Slow period must be greater than 0.");
}
if (fastPeriod >= slowPeriod)
{
throw new ArgumentException("Fast period must be less than slow period.", nameof(fastPeriod));
}
if (acPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(acPeriod), "AC period must be greater than 0.");
}
if (high.Length != low.Length || high.Length != destination.Length)
{
throw new ArgumentException("High, low, and destination spans must have the same length.", nameof(destination));
}
int len = high.Length;
if (len == 0)
{
return;
}
// Rent buffers: median + fast + slow + ao = 4 * len
double[] rentedBuffer = ArrayPool<double>.Shared.Rent(len * 4);
try
{
Span<double> median = rentedBuffer.AsSpan(0, len);
Span<double> fast = rentedBuffer.AsSpan(len, len);
Span<double> slow = rentedBuffer.AsSpan(len * 2, len);
Span<double> ao = rentedBuffer.AsSpan(len * 3, len);
for (int i = 0; i < len; i++)
{
median[i] = (high[i] + low[i]) * 0.5;
}
Sma.Batch(median, fast, fastPeriod);
Sma.Batch(median, slow, slowPeriod);
// AO = fast - slow
SimdExtensions.Subtract(fast, slow, ao);
// AC = AO - SMA(AO, acPeriod)
Sma.Batch(ao, destination, acPeriod);
SimdExtensions.Subtract(ao, destination, destination);
}
finally
{
ArrayPool<double>.Shared.Return(rentedBuffer);
}
}
/// <summary>
/// Calculates AC for the entire series using a stateless batch path.
/// </summary>
/// <param name="source">Input bar series</param>
/// <param name="fastPeriod">Fast SMA period (default 5)</param>
/// <param name="slowPeriod">Slow SMA period (default 34)</param>
/// <param name="acPeriod">AC SMA period (default 5)</param>
/// <returns>AC series</returns>
public static TSeries Batch(TBarSeries source, int fastPeriod = 5, int slowPeriod = 34, int acPeriod = 5)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var v = new double[len];
Batch(source.High.Values, source.Low.Values, v, fastPeriod, slowPeriod, acPeriod);
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);
}
public static (TSeries Results, Ac Indicator) Calculate(TBarSeries source, int fastPeriod = 5, int slowPeriod = 34, int acPeriod = 5)
{
var indicator = new Ac(fastPeriod, slowPeriod, acPeriod);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}
+123
View File
@@ -0,0 +1,123 @@
# AC: Acceleration Oscillator
> "Knowing speed is useful. Knowing whether you're speeding up or slowing down is what keeps you alive."
## Introduction
The Acceleration Oscillator (AC) is Bill Williams' second-derivative momentum indicator. Where the Awesome Oscillator (AO) measures the speed of market momentum, AC measures whether that momentum is accelerating or decelerating. AC is computed as AO minus a 5-period SMA of AO. Zero crossings and color changes signal shifts in market driving force before price reverses.
## Historical Context
Bill Williams introduced AC alongside AO in his "Trading Chaos" methodology. While AO already strips trend by subtracting a slow SMA from a fast SMA (both applied to the bar midpoint), traders found they needed earlier warning of momentum shifts. AC provides exactly that: the rate of change of AO itself. When AC crosses zero from below, the market's driving force is accelerating upward, often preceding AO's own zero crossing by several bars.
## Calculation
The AC indicator is calculated in two stages:
### Stage 1: Awesome Oscillator
$$\text{Median Price} = \frac{\text{High} + \text{Low}}{2}$$
$$\text{AO} = \text{SMA}(\text{Median Price}, \text{fast}) - \text{SMA}(\text{Median Price}, \text{slow})$$
### Stage 2: Acceleration
$$\text{AC} = \text{AO} - \text{SMA}(\text{AO}, \text{acPeriod})$$
Default parameters: fast = 5, slow = 34, acPeriod = 5.
## Interpretation
- **AC > 0 and rising (green):** Bullish acceleration. Momentum is strengthening.
- **AC > 0 and falling (red):** Bullish deceleration. Momentum still positive but weakening.
- **AC < 0 and falling (green to red):** Bearish acceleration. Momentum is weakening further.
- **AC < 0 and rising (red to green):** Bearish deceleration. Downward momentum is weakening.
- **Zero crossings:** Often precede AO zero crossings, providing earlier entry/exit signals.
### Bill Williams' Trading Rules
1. **Buy signal:** AC is green (rising) for two consecutive bars above zero, or three consecutive green bars below zero.
2. **Sell signal:** AC is red (falling) for two consecutive bars below zero, or three consecutive red bars above zero.
## Parameters
| Parameter | Default | Range | Description |
| :-------- | :------ | :---- | :---------- |
| fastPeriod | 5 | > 0 | Fast SMA period for AO calculation |
| slowPeriod | 34 | > fast | Slow SMA period for AO calculation |
| acPeriod | 5 | > 0 | SMA period applied to AO values |
## API
### Streaming
```csharp
var ac = new Ac(fastPeriod: 5, slowPeriod: 34, acPeriod: 5);
TValue result = ac.Update(bar, isNew: true);
```
### Batch (TBarSeries)
```csharp
TSeries results = Ac.Batch(barSeries);
```
### Batch (Span)
```csharp
Ac.Batch(highSpan, lowSpan, outputSpan, fastPeriod: 5, slowPeriod: 34, acPeriod: 5);
```
### Calculate
```csharp
var (results, indicator) = Ac.Calculate(barSeries, fastPeriod: 5, slowPeriod: 34, acPeriod: 5);
```
## Usage
```csharp
// Streaming
var ac = new Ac();
foreach (var bar in bars)
{
var result = ac.Update(bar);
if (ac.IsHot && result.Value > 0)
{
// Bullish momentum accelerating
}
}
// Event-driven chaining
ac.Pub += (sender, e) => Console.WriteLine($"AC: {e.Value.Value:F4}");
```
## Performance
| Operation | Complexity | Allocations |
| :-------- | :--------- | :---------- |
| Update (streaming) | O(1) | Zero |
| Batch (Span) | O(n) | ArrayPool |
| Warmup period | slow + ac - 1 | — |
AC uses three internal SMA instances. Each SMA uses a RingBuffer for O(1) sliding window computation. The Batch path uses SIMD-accelerated subtraction via `SimdExtensions.Subtract`.
## Validation
AC is validated via self-consistency (AC = AO - SMA(AO, acPeriod)) and batch/streaming equivalence. No external library implements AC with identical SMA methodology for cross-library validation.
| Test | Status |
| :--- | :----- |
| AC = AO - SMA(AO) identity | Pass |
| Batch/streaming match | Pass |
| Span/TBarSeries match | Pass |
| Determinism | Pass |
| Constant input convergence | Pass (→ 0) |
| Large dataset stability | Pass (5000 bars) |
## Sources
- Williams, Bill. "Trading Chaos." Wiley, 1995.
- Williams, Bill. "New Trading Dimensions." Wiley, 1998.
- [Investopedia: Accelerator Oscillator](https://www.investopedia.com/terms/a/accelerationdeceleration-indicator.asp)
- [TradingView: AC](https://www.tradingview.com/support/solutions/43000501837-accelerator-oscillator-ac/)