diff --git a/lib/momentum/_index.md b/lib/momentum/_index.md index b819922c..a7747da8 100644 --- a/lib/momentum/_index.md +++ b/lib/momentum/_index.md @@ -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 | | diff --git a/lib/momentum/ao/Ao.Quantower.Tests.cs b/lib/momentum/ao/Ao.Quantower.Tests.cs new file mode 100644 index 00000000..c421d936 --- /dev/null +++ b/lib/momentum/ao/Ao.Quantower.Tests.cs @@ -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); + } +} diff --git a/lib/momentum/ao/Ao.Quantower.cs b/lib/momentum/ao/Ao.Quantower.cs new file mode 100644 index 00000000..029abe88 --- /dev/null +++ b/lib/momentum/ao/Ao.Quantower.cs @@ -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); + } + } +} diff --git a/lib/momentum/ao/Ao.Tests.cs b/lib/momentum/ao/Ao.Tests.cs new file mode 100644 index 00000000..e464bf11 --- /dev/null +++ b/lib/momentum/ao/Ao.Tests.cs @@ -0,0 +1,121 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public class AoTests +{ + [Fact] + public void Constructor_ValidatesParameters() + { + Assert.Throws(() => new Ao(0, 34)); + Assert.Throws(() => new Ao(5, 0)); + Assert.Throws(() => 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); + } +} diff --git a/lib/momentum/ao/Ao.Validation.Tests.cs b/lib/momentum/ao/Ao.Validation.Tests.cs new file mode 100644 index 00000000..54a642b7 --- /dev/null +++ b/lib/momentum/ao/Ao.Validation.Tests.cs @@ -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(); + + 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(); + + 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(); + + 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); + } + } +} diff --git a/lib/momentum/ao/Ao.cs b/lib/momentum/ao/Ao.cs new file mode 100644 index 00000000..b08fcd73 --- /dev/null +++ b/lib/momentum/ao/Ao.cs @@ -0,0 +1,147 @@ +using System.Runtime.CompilerServices; + +namespace QuanTAlib; + +/// +/// 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. +/// +/// 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/ +/// +[SkipLocalsInit] +public sealed class Ao : ITValuePublisher +{ + private readonly Sma _smaFast; + private readonly Sma _smaSlow; + + /// + /// Display name for the indicator. + /// + public string Name { get; } + + public event Action? Pub; + + /// + /// Current AO value. + /// + public TValue Last { get; private set; } + + /// + /// True if the AO has enough data to produce valid results. + /// + public bool IsHot => _smaSlow.IsHot; + + /// + /// Creates AO with specified periods. + /// + /// Fast SMA period (default 5) + /// Slow SMA period (default 34) + 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})"; + } + + /// + /// Resets the AO state. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _smaFast.Reset(); + _smaSlow.Reset(); + Last = default; + } + + /// + /// Updates the AO with a new bar. + /// + /// The new bar data + /// Whether this is a new bar or an update to the last bar + /// The updated AO value + [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; + } + + /// + /// Updates the AO with a new value (assumes value is Median Price). + /// + /// The new value + /// Whether this is a new value or an update to the last value + /// The updated AO value + [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; + } + + /// + /// Updates the AO with a series of bars. + /// + /// The source series of bars + /// The AO series + public TSeries Update(TBarSeries source) + { + var t = new List(source.Count); + var v = new List(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); + } + + /// + /// Calculates AO for the entire series using a new instance. + /// + /// Input series + /// Fast SMA period (default 5) + /// Slow SMA period (default 34) + /// AO series + public static TSeries Calculate(TBarSeries source, int fastPeriod = 5, int slowPeriod = 34) + { + var ao = new Ao(fastPeriod, slowPeriod); + return ao.Update(source); + } +} diff --git a/lib/momentum/ao/Ao.md b/lib/momentum/ao/Ao.md new file mode 100644 index 00000000..81988592 --- /dev/null +++ b/lib/momentum/ao/Ao.md @@ -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. | diff --git a/quantower/Momentum.csproj b/quantower/Momentum.csproj index 2cfc2ebb..92f68e43 100644 --- a/quantower/Momentum.csproj +++ b/quantower/Momentum.csproj @@ -21,6 +21,7 @@ + ..\.github\TradingPlatform.BusinessLayer.dll