feat: Add Awesome Oscillator (AO) implementation with tests and documentation

This commit is contained in:
Miha Kralj
2025-12-14 21:01:21 -08:00
parent db6f994d75
commit f761cc5712
8 changed files with 681 additions and 1 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ Momentum indicators measure the speed or strength of price movements. This inclu
| AC | Acceleration Oscillator | |
| [ADX](adx/Adx.md) | Average Directional Index | Measures the strength of a trend, regardless of its direction. |
| ADXR | Average Directional Movement Rating | |
| AO | Awesome Oscillator | |
| [AO](ao/Ao.md) | Awesome Oscillator | Measures market momentum using the difference between 34-period and 5-period SMAs of median price. |
| APO | Absolute Price Oscillator | |
| AROON | Aroon | |
| AROONOSC | Aroon Oscillator | |
+125
View File
@@ -0,0 +1,125 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class AoIndicatorTests
{
[Fact]
public void AoIndicator_Constructor_SetsDefaults()
{
var indicator = new AoIndicator();
Assert.Equal(5, indicator.FastPeriod);
Assert.Equal(34, indicator.SlowPeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("AO - Awesome Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AoIndicator_MinHistoryDepths_EqualsSlowPeriod()
{
var indicator = new AoIndicator { SlowPeriod = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(20, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void AoIndicator_ShortName_IncludesParameters()
{
var indicator = new AoIndicator { FastPeriod = 10, SlowPeriod = 40 };
indicator.Initialize();
Assert.Contains("AO", indicator.ShortName);
Assert.Contains("10", indicator.ShortName);
Assert.Contains("40", indicator.ShortName);
}
[Fact]
public void AoIndicator_SourceCodeLink_IsValid()
{
var indicator = new AoIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Ao.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void AoIndicator_Initialize_CreatesInternalAo()
{
var indicator = new AoIndicator { FastPeriod = 5, SlowPeriod = 34 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (Up and Down)
Assert.Equal(2, indicator.LinesSeries.Length);
}
[Fact]
public void AoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AoIndicator { FastPeriod = 2, SlowPeriod = 5 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
// Need enough bars for Period
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value (either Up or Down)
// One should be NaN, other should be value, or both NaN if cold
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 AoIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new AoIndicator { FastPeriod = 2, SlowPeriod = 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));
// Add new bar
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 AoIndicator_Parameters_CanBeChanged()
{
var indicator = new AoIndicator { FastPeriod = 5, SlowPeriod = 34 };
Assert.Equal(5, indicator.FastPeriod);
Assert.Equal(34, indicator.SlowPeriod);
indicator.FastPeriod = 10;
indicator.SlowPeriod = 40;
Assert.Equal(10, indicator.FastPeriod);
Assert.Equal(40, indicator.SlowPeriod);
Assert.Equal(40, indicator.MinHistoryDepths);
}
}
+99
View File
@@ -0,0 +1,99 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class AoIndicator : 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("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ao? _ao;
protected LineSeries? UpSeries;
protected LineSeries? DownSeries;
public int MinHistoryDepths => SlowPeriod;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"AO {FastPeriod}:{SlowPeriod}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/ao/Ao.Quantower.cs";
public AoIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "AO - Awesome Oscillator";
Description = "Momentum indicator measuring market momentum";
UpSeries = new(name: "AO Up", color: Color.Green, width: 2, style: LineStyle.Solid);
DownSeries = new(name: "AO Down", color: Color.Red, width: 2, style: LineStyle.Solid);
AddLineSeries(UpSeries);
AddLineSeries(DownSeries);
}
protected override void OnInit()
{
_ao = new Ao(FastPeriod, SlowPeriod);
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TBar bar = this.GetInputBar(args);
TValue result = _ao!.Update(bar, isNew);
if (!_ao.IsHot && !ShowColdValues)
{
return;
}
// Determine color based on momentum
// Green if rising, Red if falling
// We need previous value to compare.
// Since OnUpdate is called multiple times for the same bar (ticks),
// we need to be careful about "previous value".
// Ideally, we compare with the value of the *previous bar*.
// But AO coloring is usually: Current > Previous Bar's AO => Green.
// Or Current > Previous Value (intra-bar)?
// Standard is: "Green bar if the bar is higher than the previous bar. Red bar if the bar is lower than the previous bar."
// "Previous bar" usually means the AO value of the previous period.
// We can get the previous value from the indicator history if we stored it,
// or just use _ao.Last (which is current) and we need the previous one.
// But _ao doesn't expose history directly unless we use TSeries.
// However, Quantower stores history in the Series.
// Get previous value from series
double prevAo = double.NaN;
if (Count > 1)
{
// Try to get from UpSeries
prevAo = UpSeries!.GetValue(1);
if (double.IsNaN(prevAo))
{
prevAo = DownSeries!.GetValue(1);
}
}
// If first bar, just pick a color (e.g. Green) or NaN
if (double.IsNaN(prevAo) || result.Value > prevAo)
{
UpSeries!.SetValue(result.Value);
DownSeries!.SetValue(double.NaN);
}
else
{
UpSeries!.SetValue(double.NaN);
DownSeries!.SetValue(result.Value);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using Xunit;
namespace QuanTAlib.Tests;
public class AoTests
{
[Fact]
public void Constructor_ValidatesParameters()
{
Assert.Throws<ArgumentException>(() => new Ao(0, 34));
Assert.Throws<ArgumentException>(() => new Ao(5, 0));
Assert.Throws<ArgumentException>(() => new Ao(34, 5)); // Fast >= Slow
}
[Fact]
public void IsHot_BecomesTrueAfterSlowPeriod()
{
var ao = new Ao(2, 5);
// Add 4 values
for (int i = 0; i < 4; i++)
{
ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100));
Assert.False(ao.IsHot);
}
// Add 5th value
ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100));
Assert.True(ao.IsHot);
}
[Fact]
public void Calculation_Correctness()
{
// AO = SMA(Median, 5) - SMA(Median, 34)
// Let's use smaller periods for testing: 2 and 4
var ao = new Ao(2, 4);
// Median prices: 10, 20, 30, 40, 50
// SMA2: -, 15, 25, 35, 45
// SMA4: -, -, -, 25, 35
// AO: -, -, -, 10, 10
var data = new[] { 10.0, 20.0, 30.0, 40.0, 50.0 };
// Sma returns average of available data.
// SMA2(10) = 10
// SMA2(10, 20) = 15
// SMA2(20, 30) = 25
// SMA2(30, 40) = 35
// SMA2(40, 50) = 45
// SMA4(10) = 10
// SMA4(10, 20) = 15
// SMA4(10, 20, 30) = 20
// SMA4(10, 20, 30, 40) = 25
// SMA4(20, 30, 40, 50) = 35
// AO:
// 1: 10 - 10 = 0
// 2: 15 - 15 = 0
// 3: 25 - 20 = 5
// 4: 35 - 25 = 10
// 5: 45 - 35 = 10
for (int i = 0; i < data.Length; i++)
{
var bar = new TBar(DateTime.UtcNow, data[i], data[i], data[i], data[i], 100);
var result = ao.Update(bar);
if (i == 2) Assert.Equal(5.0, result.Value);
if (i >= 3) Assert.Equal(10.0, result.Value);
}
}
[Fact]
public void Update_WithIsNewFalse_UpdatesLastValue()
{
var ao = new Ao(2, 4);
// 1. Add 10
ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100));
// SMA2=10, SMA4=10, AO=0
// 2. Add 20
ao.Update(new TBar(DateTime.UtcNow, 20, 20, 20, 20, 100));
// SMA2=15, SMA4=15, AO=0
// 3. Update last with 30 (instead of 20)
var result = ao.Update(new TBar(DateTime.UtcNow, 30, 30, 30, 30, 100), isNew: false);
// SMA2(10, 30) = 20
// SMA4(10, 30) = 20
// AO = 0
Assert.Equal(0.0, result.Value);
// 4. Add 40
result = ao.Update(new TBar(DateTime.UtcNow, 40, 40, 40, 40, 100));
// SMA2(30, 40) = 35
// SMA4(10, 30, 40) = 26.666...
// AO = 35 - 26.666... = 8.333...
Assert.True(result.Value > 0);
}
[Fact]
public void Reset_ClearsState()
{
var ao = new Ao(2, 4);
ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100));
ao.Update(new TBar(DateTime.UtcNow, 20, 20, 20, 20, 100));
ao.Reset();
Assert.False(ao.IsHot);
Assert.Equal(0, ao.Last.Value);
// Should behave like new
ao.Update(new TBar(DateTime.UtcNow, 10, 10, 10, 10, 100));
Assert.Equal(0, ao.Last.Value);
}
}
+131
View File
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Skender.Stock.Indicators;
using Tulip;
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Xunit;
using QuanTAlib.Tests;
namespace QuanTAlib;
public class AoValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public AoValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_data.Dispose();
}
}
[Fact]
public void MatchesSkender()
{
var ao = new Ao(5, 34);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
var res = ao.Update(_data.Bars[i]);
results.Add(res.Value);
}
var skenderResults = _data.SkenderQuotes.GetAwesome(5, 34).ToList();
Assert.Equal(_data.Bars.Count, skenderResults.Count);
for (int i = 0; i < _data.Bars.Count; i++)
{
// Skender returns null for warmup
if (skenderResults[i].Oscillator == null)
{
continue;
}
Assert.Equal((double)skenderResults[i].Oscillator!, results[i], 1e-6);
}
}
[Fact]
public void MatchesTulip()
{
var ao = new Ao(5, 34);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
var res = ao.Update(_data.Bars[i]);
results.Add(res.Value);
}
var high = _data.Bars.High.Select(x => x.Value).ToArray();
var low = _data.Bars.Low.Select(x => x.Value).ToArray();
var tulipIndicator = Tulip.Indicators.ao;
double[][] inputs = { high, low };
double[] options = { };
int lookback = 33;
double[][] outputs = { new double[_data.Bars.Count - lookback] };
tulipIndicator.Run(inputs, options, outputs);
var tulipResults = outputs[0];
for (int i = 0; i < tulipResults.Length; i++)
{
Assert.Equal(tulipResults[i], results[i + lookback], 1e-6);
}
}
[Fact]
public void MatchesOoples()
{
var ao = new Ao(5, 34);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
var res = ao.Update(_data.Bars[i]);
results.Add(res.Value);
}
var ooplesData = _data.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
}).ToList();
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateAwesomeOscillator(fastLength: 5, slowLength: 34);
var oValues = oResult.OutputValues["Ao"];
Assert.Equal(_data.Bars.Count, oValues.Count);
for (int i = 0; i < _data.Bars.Count; i++)
{
// Ooples might return 0 for warmup
if (i < 33) continue; // Skip warmup
Assert.Equal(oValues[i], results[i], 1e-3);
}
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// AO: Awesome Oscillator
/// </summary>
/// <remarks>
/// The Awesome Oscillator (AO) is a momentum indicator used to measure market momentum.
/// It calculates the difference between a 34-period and 5-period Simple Moving Average (SMA)
/// of the median prices (High + Low) / 2.
///
/// Calculation:
/// Median Price = (High + Low) / 2
/// AO = SMA(Median Price, 5) - SMA(Median Price, 34)
///
/// Sources:
/// https://www.investopedia.com/terms/a/awesomeoscillator.asp
/// https://www.tradingview.com/support/solutions/43000501826-awesome-oscillator-ao/
/// </remarks>
[SkipLocalsInit]
public sealed class Ao : ITValuePublisher
{
private readonly Sma _smaFast;
private readonly Sma _smaSlow;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Current AO value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the AO has enough data to produce valid results.
/// </summary>
public bool IsHot => _smaSlow.IsHot;
/// <summary>
/// Creates AO with specified periods.
/// </summary>
/// <param name="fastPeriod">Fast SMA period (default 5)</param>
/// <param name="slowPeriod">Slow SMA period (default 34)</param>
public Ao(int fastPeriod = 5, int slowPeriod = 34)
{
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));
_smaFast = new Sma(fastPeriod);
_smaSlow = new Sma(slowPeriod);
Name = $"Ao({fastPeriod},{slowPeriod})";
}
/// <summary>
/// Resets the AO state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_smaFast.Reset();
_smaSlow.Reset();
Last = default;
}
/// <summary>
/// Updates the AO 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 AO value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
double medianPrice = (input.High + input.Low) * 0.5;
var val = new TValue(input.Time, medianPrice);
var sFast = _smaFast.Update(val, isNew);
var sSlow = _smaSlow.Update(val, isNew);
double ao = sFast.Value - sSlow.Value;
Last = new TValue(input.Time, ao);
Pub?.Invoke(Last);
return Last;
}
/// <summary>
/// Updates the AO 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 AO value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
var sFast = _smaFast.Update(input, isNew);
var sSlow = _smaSlow.Update(input, isNew);
double ao = sFast.Value - sSlow.Value;
Last = new TValue(input.Time, ao);
Pub?.Invoke(Last);
return Last;
}
/// <summary>
/// Updates the AO with a series of bars.
/// </summary>
/// <param name="source">The source series of bars</param>
/// <returns>The AO series</returns>
public TSeries Update(TBarSeries source)
{
var t = new List<long>(source.Count);
var v = new List<double>(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], true);
t.Add(val.Time);
v.Add(val.Value);
}
return new TSeries(t, v);
}
/// <summary>
/// Calculates AO for the entire series using a new instance.
/// </summary>
/// <param name="source">Input series</param>
/// <param name="fastPeriod">Fast SMA period (default 5)</param>
/// <param name="slowPeriod">Slow SMA period (default 34)</param>
/// <returns>AO series</returns>
public static TSeries Calculate(TBarSeries source, int fastPeriod = 5, int slowPeriod = 34)
{
var ao = new Ao(fastPeriod, slowPeriod);
return ao.Update(source);
}
}
+56
View File
@@ -0,0 +1,56 @@
# AO - Awesome Oscillator
The Awesome Oscillator (AO) is a momentum indicator used to measure market momentum. It calculates the difference between a 34-period and 5-period Simple Moving Average (SMA) of the median prices (High + Low) / 2.
## Formula
$$Median Price = \frac{High + Low}{2}$$
$$AO = SMA(Median Price, 5) - SMA(Median Price, 34)$$
Where:
- $SMA$ is the Simple Moving Average.
## Usage
### C# Code
```csharp
using QuanTAlib;
// Create AO with default periods (5, 34)
var ao = new Ao();
// Or specify custom periods
var aoCustom = new Ao(5, 34);
// Update with a bar
var result = ao.Update(bar);
// Result contains the AO value
Console.WriteLine($"AO: {result.Value}");
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| fastPeriod | int | 5 | The period for the fast SMA. |
| slowPeriod | int | 34 | The period for the slow SMA. |
## Properties
| Property | Type | Description |
|----------|------|-------------|
| Last | TValue | The latest calculated AO value. |
| IsHot | bool | Indicates if the indicator has enough data to be valid (slow period reached). |
| Name | string | The name of the indicator, e.g., "Ao(5,34)". |
## Methods
| Method | Description |
|--------|-------------|
| Update(TBar bar) | Updates the indicator with a new bar. |
| Update(TValue val) | Updates the indicator with a new value (assumed to be Median Price). |
| Reset() | Resets the indicator state. |
+1
View File
@@ -21,6 +21,7 @@
<Compile Include="..\lib\trends\wma\Wma.cs" />
<Compile Include="..\lib\trends\pwma\Pwma.cs" />
<Compile Include="..\lib\trends\jma\Jma.cs" />
<Compile Include="..\lib\trends\sma\Sma.cs" />
<Reference Include="TradingPlatform.BusinessLayer">
<HintPath>..\.github\TradingPlatform.BusinessLayer.dll</HintPath>
</Reference>