diff --git a/.github/workflows/build-indicators.yml b/.github/workflows/build-indicators.yml index f33ae1c78..a763d4cde 100644 --- a/.github/workflows/build-indicators.yml +++ b/.github/workflows/build-indicators.yml @@ -39,6 +39,10 @@ jobs: -warnAsError - name: Test indicators + id: test-library + env: + ALPACA_KEY: ${{ secrets.ALPACA_KEY }} + ALPACA_SECRET: ${{ secrets.ALPACA_SECRET }} run: > dotnet test tests/indicators/Tests.Indicators.csproj --configuration Release @@ -49,6 +53,7 @@ jobs: --results-directory ./test-indicators - name: Test other items + id: test-other run: > dotnet test tests/other/Tests.Other.csproj --configuration Release @@ -59,7 +64,7 @@ jobs: - name: Update tests summary uses: bibipkins/dotnet-test-reporter@v1.3.3 - if: github.event_name == 'pull_request' + if: ${{ github.event_name == 'pull_request' && (success() || (failure() && (steps.test-library.conclusion == 'failure' || steps.test-other.conclusion == 'failure'))) }} with: github-token: ${{ secrets.GITHUB_TOKEN }} comment-title: "" diff --git a/docs/examples/Backtest/Program.cs b/docs/examples/Backtest/Program.cs index 71fe1314b..0f5849ed6 100644 --- a/docs/examples/Backtest/Program.cs +++ b/docs/examples/Backtest/Program.cs @@ -23,8 +23,8 @@ public static void Main() */ // fetch historical quotes from data provider - List quotesList = GetQuotesFromFeed() - .ToList(); + List quotesList = [.. GetQuotesFromFeed() +]; // calculate Stochastic RSI List resultsList = @@ -92,7 +92,7 @@ public static void Main() } } - private static IEnumerable GetQuotesFromFeed() + private static Collection GetQuotesFromFeed() { /************************************************************ diff --git a/docs/examples/ConsoleApp/Program.cs b/docs/examples/ConsoleApp/Program.cs index 88cc004fd..652ee5e7f 100644 --- a/docs/examples/ConsoleApp/Program.cs +++ b/docs/examples/ConsoleApp/Program.cs @@ -67,7 +67,7 @@ with the same ordinal position. } } - private static IEnumerable GetQuotesFromFeed() + private static Collection GetQuotesFromFeed() { /************************************************************ diff --git a/docs/examples/CustomIndicators/CustomIndicatorsLibrary.csproj b/docs/examples/CustomIndicators/CustomIndicatorsLibrary.csproj index 3bcf14387..a05805299 100644 --- a/docs/examples/CustomIndicators/CustomIndicatorsLibrary.csproj +++ b/docs/examples/CustomIndicators/CustomIndicatorsLibrary.csproj @@ -3,7 +3,6 @@ net8.0 enable - enable diff --git a/docs/examples/CustomIndicatorsUsage/CustomIndicatorsUsage.csproj b/docs/examples/CustomIndicatorsUsage/CustomIndicatorsUsage.csproj index 3c1fd397b..391b7239a 100644 --- a/docs/examples/CustomIndicatorsUsage/CustomIndicatorsUsage.csproj +++ b/docs/examples/CustomIndicatorsUsage/CustomIndicatorsUsage.csproj @@ -4,7 +4,6 @@ Exe net8.0 enable - disable diff --git a/docs/examples/CustomIndicatorsUsage/Program.cs b/docs/examples/CustomIndicatorsUsage/Program.cs index df6dbee10..9246a8900 100644 --- a/docs/examples/CustomIndicatorsUsage/Program.cs +++ b/docs/examples/CustomIndicatorsUsage/Program.cs @@ -47,7 +47,7 @@ public static void Main() } } - private static IEnumerable GetQuotesFromFeed() + private static Collection GetQuotesFromFeed() { /************************************************************ diff --git a/docs/examples/ObserveStream/ObserveStream.csproj b/docs/examples/ObserveStream/ObserveStream.csproj index c4c176fc3..8d1a47c95 100644 --- a/docs/examples/ObserveStream/ObserveStream.csproj +++ b/docs/examples/ObserveStream/ObserveStream.csproj @@ -4,7 +4,6 @@ Exe net8.0 enable - enable diff --git a/docs/examples/ObserveStream/Program.cs b/docs/examples/ObserveStream/Program.cs index 1c7dd0f6c..738ac16f7 100644 --- a/docs/examples/ObserveStream/Program.cs +++ b/docs/examples/ObserveStream/Program.cs @@ -7,7 +7,7 @@ internal class Program { private static async Task Main(string[] args) { - if (args.Any()) + if (args.Length != 0) { Console.WriteLine(args); } @@ -22,17 +22,17 @@ private static async Task Main(string[] args) public static async Task SubscribeToQuotes(string symbol) { // get and validate keys, see README.md - string? alpacaApiKey = Environment.GetEnvironmentVariable("AlpacaApiKey"); - string? alpacaSecret = Environment.GetEnvironmentVariable("AlpacaSecret"); + string alpacaApiKey = Environment.GetEnvironmentVariable("AlpacaApiKey"); + string alpacaSecret = Environment.GetEnvironmentVariable("AlpacaSecret"); - if (alpacaApiKey == null) + if (string.IsNullOrEmpty(alpacaApiKey)) { throw new ArgumentNullException( alpacaApiKey, $"API KEY missing, use `setx AlpacaApiKey \"ALPACA_API_KEY\"` to set."); } - if (alpacaSecret == null) + if (string.IsNullOrEmpty(alpacaSecret)) { throw new ArgumentNullException( alpacaSecret, @@ -58,10 +58,8 @@ IAlpacaCryptoStreamingClient client await client.ConnectAndAuthenticateAsync(); - AutoResetEvent[] waitObjects = new[] // TODO: is this needed? - { - new AutoResetEvent(false) - }; + // TODO: is this needed? + AutoResetEvent[] waitObjects = [new AutoResetEvent(false)]; IAlpacaDataSubscription quoteSubscription = client.GetMinuteBarSubscription(symbol); diff --git a/docs/examples/Skender.Stock.Indicators-Examples.zip b/docs/examples/Skender.Stock.Indicators-Examples.zip index 9945eafc4..b56a3050c 100644 Binary files a/docs/examples/Skender.Stock.Indicators-Examples.zip and b/docs/examples/Skender.Stock.Indicators-Examples.zip differ diff --git a/docs/examples/UseQuoteApi/Program.cs b/docs/examples/UseQuoteApi/Program.cs index 4d820cb35..e93af0599 100644 --- a/docs/examples/UseQuoteApi/Program.cs +++ b/docs/examples/UseQuoteApi/Program.cs @@ -80,17 +80,21 @@ or ICollection or other IEnumerable compatible types. ************************************************************/ // get and validate keys, see README.md - string? alpacaApiKey = Environment.GetEnvironmentVariable("AlpacaApiKey"); - string? alpacaSecret = Environment.GetEnvironmentVariable("AlpacaSecret"); + string alpacaApiKey = Environment.GetEnvironmentVariable("AlpacaApiKey"); + string alpacaSecret = Environment.GetEnvironmentVariable("AlpacaSecret"); - if (alpacaApiKey == null) + if (string.IsNullOrEmpty(alpacaApiKey)) { - throw new ArgumentNullException(alpacaApiKey); + throw new ArgumentNullException( + alpacaApiKey, + $"API KEY missing, use `setx AlpacaApiKey \"ALPACA_API_KEY\"` to set."); } - if (alpacaSecret == null) + if (string.IsNullOrEmpty(alpacaSecret)) { - throw new ArgumentNullException(alpacaSecret); + throw new ArgumentNullException( + alpacaSecret, + $"API SECRET missing, use `setx AlpacaApiSecret \"ALPACA_SECRET\"` to set."); } // connect to Alpaca REST API diff --git a/docs/examples/UseQuoteApi/UseQuoteApi.csproj b/docs/examples/UseQuoteApi/UseQuoteApi.csproj index a1e4f8a38..cda3d5aec 100644 --- a/docs/examples/UseQuoteApi/UseQuoteApi.csproj +++ b/docs/examples/UseQuoteApi/UseQuoteApi.csproj @@ -4,7 +4,6 @@ Exe net8.0 enable - enable diff --git a/tests/indicators/Tests.Indicators.csproj b/tests/indicators/Tests.Indicators.csproj index 849fdbc73..64012bb63 100644 --- a/tests/indicators/Tests.Indicators.csproj +++ b/tests/indicators/Tests.Indicators.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/indicators/_common/Helper.LiveQuotes.cs b/tests/indicators/_common/Helper.LiveQuotes.cs new file mode 100644 index 000000000..37bab744a --- /dev/null +++ b/tests/indicators/_common/Helper.LiveQuotes.cs @@ -0,0 +1,75 @@ +using Alpaca.Markets; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Skender.Stock.Indicators; + +namespace Tests.Indicators; + +internal class FeedData +{ + internal static async Task> GetQuotes(string symbol) + => await GetQuotes(symbol, 365 * 2) + .ConfigureAwait(false); + + internal static async Task> GetQuotes(string symbol, int days) + { + /* This won't run if environment variables not set. + Use FeedData.InconclusiveIfNotSetup() in tests. + + (1) get your API keys + https://alpaca.markets/docs/market-data/getting-started/ + + (2) manually install in your environment (replace value) + + setx ALPACA_KEY "y0ur_Alp@ca_K3Y_v@lue" + setx ALPACA_SECRET "y0ur_Alp@ca_S3cret_v@lue" + + ****************************************************/ + + // get and validate keys + string alpacaApiKey = Environment.GetEnvironmentVariable("ALPACA_KEY"); + string alpacaSecret = Environment.GetEnvironmentVariable("ALPACA_SECRET"); + + if (string.IsNullOrEmpty(alpacaApiKey) || string.IsNullOrEmpty(alpacaSecret)) + { + Assert.Inconclusive("Data feed unusable. Environment variables missing."); + } + + ArgumentException.ThrowIfNullOrEmpty(nameof(alpacaApiKey)); + ArgumentException.ThrowIfNullOrEmpty(nameof(alpacaSecret)); + + // connect to Alpaca REST API + SecretKey secretKey = new(alpacaApiKey, alpacaSecret); + + IAlpacaDataClient client = Environments + .Paper + .GetAlpacaDataClient(secretKey); + + // compose request + // (excludes last 15 minutes for free delayed quotes) + DateTime into = DateTime.Now.Subtract(TimeSpan.FromMinutes(16)); + DateTime from = into.Subtract(TimeSpan.FromDays(days)); + + HistoricalBarsRequest request = new(symbol, from, into, BarTimeFrame.Day); + + // fetch minute-bar quotes in Alpaca's format + IPage barSet = await client + .ListHistoricalBarsAsync(request) + .ConfigureAwait(false); + + // convert library compatible quotes + IEnumerable quotes = barSet + .Items + .Select(bar => new Quote + { + Date = bar.TimeUtc, + Open = bar.Open, + High = bar.High, + Low = bar.Low, + Close = bar.Close, + Volume = bar.Volume + }) + .OrderBy(x => x.Date); + + return quotes; + } +} diff --git a/tests/indicators/s-z/Stoch/Stoch.Tests.cs b/tests/indicators/s-z/Stoch/Stoch.Tests.cs index f62679fa9..11d9c9c5d 100644 --- a/tests/indicators/s-z/Stoch/Stoch.Tests.cs +++ b/tests/indicators/s-z/Stoch/Stoch.Tests.cs @@ -14,8 +14,8 @@ public void Standard() // Slow int signalPeriods = 3; int smoothPeriods = 3; - List results = - quotes.GetStoch(lookbackPeriods, signalPeriods, smoothPeriods) + List results = quotes + .GetStoch(lookbackPeriods, signalPeriods, smoothPeriods) .ToList(); // proper quantities @@ -48,6 +48,31 @@ public void Standard() // Slow Assert.AreEqual(43.1353, r501.Oscillator.Round(4)); Assert.AreEqual(35.5674, r501.Signal.Round(4)); Assert.AreEqual(58.2712, r501.PercentJ.Round(4)); + + // test boundary condition + + for (int i = 0; i < results.Count; i++) + { + StochResult r = results[i]; + + if (r.Oscillator is not null) + { + Assert.IsTrue(r.Oscillator >= 0); + Assert.IsTrue(r.Oscillator <= 100); + } + + if (r.Signal is not null) + { + Assert.IsTrue(r.Signal >= 0); + Assert.IsTrue(r.Signal <= 100); + } + + if (r.PercentJ is not null) + { + Assert.IsTrue(r.Signal >= 0); + Assert.IsTrue(r.Signal <= 100); + } + } } [TestMethod] @@ -213,6 +238,44 @@ public void Removed() Assert.AreEqual(58.2712, last.PercentJ.Round(4)); } + [TestMethod] + public void Boundary() + { + int lookbackPeriods = 14; + int signalPeriods = 3; + int smoothPeriods = 3; + + List results = TestData + .GetRandom(2500) + .GetStoch(lookbackPeriods, signalPeriods, smoothPeriods) + .ToList(); + + // test boundary condition + + for (int i = 0; i < results.Count; i++) + { + StochResult r = results[i]; + + if (r.Oscillator is not null) + { + Assert.IsTrue(r.Oscillator >= 0); + Assert.IsTrue(r.Oscillator <= 100); + } + + if (r.Signal is not null) + { + Assert.IsTrue(r.Signal >= 0); + Assert.IsTrue(r.Signal <= 100); + } + + if (r.PercentJ is not null) + { + Assert.IsTrue(r.Signal >= 0); + Assert.IsTrue(r.Signal <= 100); + } + } + } + [TestMethod] public void Exceptions() { diff --git a/tests/indicators/s-z/WilliamsR/WilliamsR.Tests.cs b/tests/indicators/s-z/WilliamsR/WilliamsR.Tests.cs index 42011fc82..944a6aa44 100644 --- a/tests/indicators/s-z/WilliamsR/WilliamsR.Tests.cs +++ b/tests/indicators/s-z/WilliamsR/WilliamsR.Tests.cs @@ -24,6 +24,18 @@ public void Standard() WilliamsResult r2 = results[501]; Assert.AreEqual(-52.0121, r2.WilliamsR.Round(4)); + + // test boundary condition + for (int i = 0; i < results.Count; i++) + { + WilliamsResult r = results[i]; + + if (r.WilliamsR is not null) + { + Assert.IsTrue(r.WilliamsR <= 0); + Assert.IsTrue(r.WilliamsR >= -100); + } + } } [TestMethod] @@ -41,12 +53,15 @@ public void Chainor() [TestMethod] public void BadData() { - List r = badQuotes + List quotes = badQuotes + .ToSortedList(); + + List results = badQuotes .GetWilliamsR(20) .ToList(); - Assert.AreEqual(502, r.Count); - Assert.AreEqual(0, r.Count(x => x.WilliamsR is double and double.NaN)); + Assert.AreEqual(502, results.Count); + Assert.AreEqual(0, results.Count(x => x.WilliamsR is double and double.NaN)); } [TestMethod] @@ -80,6 +95,61 @@ public void Removed() Assert.AreEqual(-52.0121, last.WilliamsR.Round(4)); } + [TestMethod] + public void Boundary() + { + List results = TestData + .GetRandom(2500) + .GetWilliamsR(14) + .ToList(); + + // analyze boundary + for (int i = 0; i < results.Count; i++) + { + WilliamsResult r = results[i]; + + if (r.WilliamsR is not null) + { + Assert.IsTrue(r.WilliamsR <= 0); + Assert.IsTrue(r.WilliamsR >= -100); + } + } + } + + [TestMethod] + public async Task Issue1127() + { + // initialize + IEnumerable quotes = await FeedData + .GetQuotes("A", 365 * 3) + .ConfigureAwait(false); + + List quotesList = quotes.ToList(); + int length = quotesList.Count; + + // get indicators + List resultsList = quotes + .GetWilliamsR(14) + .ToList(); + + Console.WriteLine($"%R from {length} quotes."); + + // analyze boundary + for (int i = 0; i < length; i++) + { + Quote q = quotesList[i]; + WilliamsResult r = resultsList[i]; + + Console.WriteLine($"{q.Date:s} {r.WilliamsR}"); + + if (r.WilliamsR is not null) + { + Assert.IsTrue(r.WilliamsR <= 0); + Assert.IsTrue(r.WilliamsR >= -100); + } + } + } + // bad lookback period [TestMethod] public void Exceptions()