diff --git a/Api/Api.cs b/Api/Api.cs index 30d5f0773208..8d01a2308183 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -42,6 +42,12 @@ namespace QuantConnect.Api /// public class Api : IApi, IDownloadProvider { + // Widest start/end window each paging endpoint documents + private const int MaxBacktestOrdersWindow = 100; + private const int MaxLiveOrdersWindow = 1000; + private const int MaxInsightsWindow = 100; + private const int MaxLogLinesWindow = 200; + private readonly BlockingCollection> _clientPool; private string _dataFolder; @@ -412,13 +418,16 @@ public Backtest ReadBacktest(int projectId, string backtestId, bool getCharts = /// /// Id of the project from which to read the orders /// Id of the backtest from which to read the orders - /// Starting index of the orders to be fetched. Required if end > 100 - /// Last index of the orders to be fetched. Note that end - start must be less than 100 + /// Starting index of the orders to be fetched + /// Last index of the orders to be fetched. Note that end - start must not exceed 100. + /// Defaults to a full window starting at /// Will throw an if there are any API errors - /// The list of - - public List ReadBacktestOrders(int projectId, string backtestId, int start = 0, int end = 100) + /// The with the requested orders and the total order count + /// The requested window is wider than the documented maximum + public OrdersResponseWrapper ReadBacktestOrders(int projectId, string backtestId, int start = 0, int end = 0) { + end = ResolveWindowEnd(start, end, MaxBacktestOrdersWindow, "orders"); + using var request = ApiUtils.CreateJsonPostRequest("backtests/orders/read", new { start, @@ -427,7 +436,7 @@ public List ReadBacktestOrders(int projectId, string backtestI backtestId }); - return MakeRequestOrThrow(request, nameof(ReadBacktestOrders)).Orders; + return MakeRequestOrThrow(request, nameof(ReadBacktestOrders)); } /// @@ -531,26 +540,37 @@ public RestResponse UpdateBacktestTags(int projectId, string backtestId, IReadOn /// Id of the project from which to read the backtest /// Backtest id from which we want to get the insights /// Starting index of the insights to be fetched - /// Last index of the insights to be fetched. Note that end - start must be less than 100 + /// Last index of the insights to be fetched. Note that end - start must not exceed 100. + /// Defaults to a full window starting at /// - /// + /// The requested window is wider than the documented maximum public InsightResponse ReadBacktestInsights(int projectId, string backtestId, int start = 0, int end = 0) { - //var reque - var diff = end - start; - if (diff > 100) - { - throw new ArgumentException($"The difference between the start and end index of the insights must be smaller than 100, but it was {diff}."); - } - else if (end == 0) - { - end = start + 100; - } + end = ResolveWindowEnd(start, end, MaxInsightsWindow, "insights"); TryJsonPost("backtests/insights/read", out InsightResponse result, new { projectId, backtestId, start, end }); return result; } + /// + /// Gets the logs of a specific backtest + /// + /// Id of the project from which to read the backtest + /// Id of the backtest from which to read the logs + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read. Note that end - start must not exceed 200. + /// Defaults to a full window starting at + /// Optional keyword to filter the log lines, null to return every line + /// with the requested log lines and the total log line count + /// The requested window is wider than the documented maximum + public BacktestLog ReadBacktestLog(int projectId, string backtestId, int start = 0, int end = 0, string query = null) + { + end = ResolveWindowEnd(start, end, MaxLogLinesWindow, "log lines"); + + TryJsonPost("backtests/read/log", out BacktestLog result, new { projectId, backtestId, start, end, query }); + return result; + } + /// /// Create a live algorithm. /// @@ -683,21 +703,24 @@ public PortfolioResponse ReadLivePortfolio(int projectId) /// Returns the orders of the specified project id live algorithm. /// /// Id of the project from which to read the live orders - /// Starting index of the orders to be fetched. Required if end > 100 - /// Last index of the orders to be fetched. Note that end - start must be less than 100 + /// Deploy id (algorithm id) of the live running algorithm. Optional, the API + /// defaults to the latest deployment of the project + /// Starting index of the orders to be fetched + /// Last index of the orders to be fetched. Note that end - start must not exceed 1000. + /// Defaults to a full window starting at /// Will throw an if there are any API errors - /// The list of - - public List ReadLiveOrders(int projectId, int start = 0, int end = 100) + /// The with the requested orders and the total order count + /// The requested window is wider than the documented maximum + public OrdersResponseWrapper ReadLiveOrders(int projectId, string algorithmId = null, int start = 0, int end = 0) { - using var request = ApiUtils.CreateJsonPostRequest("live/orders/read", new - { - start, - end, - projectId - }); + end = ResolveWindowEnd(start, end, MaxLiveOrdersWindow, "orders"); - return MakeRequestOrThrow(request, nameof(ReadLiveOrders)).Orders; + object payload = string.IsNullOrEmpty(algorithmId) + ? new { start, end, projectId } + : new { start, end, projectId, algorithmId }; + using var request = ApiUtils.CreateJsonPostRequest("live/orders/read", payload); + + return MakeRequestOrThrow(request, nameof(ReadLiveOrders)); } /// @@ -753,16 +776,17 @@ public RestResponse BroadcastLiveCommand(string organizationId, int? excludeProj /// /// Project Id of the live running algorithm /// Algorithm Id of the live running algorithm - /// Start line of logs to read - /// End line of logs to read + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read. Note that endLine - startLine must not exceed 200. + /// Defaults to a full window starting at + /// Optional keyword to filter the log lines, null to return every line + /// Whether only the logs of the given deployment should be returned /// List of strings that represent the logs of the algorithm - public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine, int endLine) + /// The requested window is wider than the documented maximum + public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0, int endLine = 0, string query = null, + bool deploymentLogs = false) { - var logLinesNumber = endLine - startLine; - if (logLinesNumber > 250) - { - throw new ArgumentException($"The maximum number of log lines allowed is 250. But the number of log lines was {logLinesNumber}."); - } + endLine = ResolveWindowEnd(startLine, endLine, MaxLogLinesWindow, "log lines"); TryJsonPost("live/logs/read", out LiveLog result, @@ -773,6 +797,8 @@ public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine, in algorithmId, startLine, endLine, + deploymentLogs, + query, }); return result; } @@ -817,20 +843,13 @@ public ReadChartResponse ReadLiveChart(int projectId, string name, int start, in /// /// Id of the project from which to read the live algorithm /// Starting index of the insights to be fetched - /// Last index of the insights to be fetched. Note that end - start must be less than 100 + /// Last index of the insights to be fetched. Note that end - start must not exceed 100. + /// Defaults to a full window starting at /// - /// + /// The requested window is wider than the documented maximum public InsightResponse ReadLiveInsights(int projectId, int start = 0, int end = 0) { - var diff = end - start; - if (diff > 100) - { - throw new ArgumentException($"The difference between the start and end index of the insights must be smaller than 100, but it was {diff}."); - } - else if (end == 0) - { - end = start + 100; - } + end = ResolveWindowEnd(start, end, MaxInsightsWindow, "insights"); TryJsonPost("live/insights/read", out InsightResponse result, new { projectId, start, end }); return result; @@ -1484,6 +1503,32 @@ protected virtual ApiConnection CreateApiConnection(int userId, string token) return new ApiConnection(userId, token); } + /// + /// Resolves the end index of a paging request, where an unset end means a full window from the start index + /// + /// Start index of the requested window + /// End index of the requested window, zero to request a full window + /// Widest window the endpoint documents + /// Name of the paged items, used in the error message + /// The end index to send + /// The requested window is wider than + private static int ResolveWindowEnd(int start, int end, int maxWindow, string itemsName) + { + if (end == 0) + { + return start + maxWindow; + } + + var window = end - start; + if (window > maxWindow) + { + throw new ArgumentException($"The difference between the start and end index of the {itemsName} must be " + + $"smaller than or equal to {maxWindow}, but it was {window}."); + } + + return end; + } + /// /// Helper method that will execute the given api request and throw an exception if it fails /// diff --git a/Common/Api/BacktestLog.cs b/Common/Api/BacktestLog.cs new file mode 100644 index 000000000000..3536b9ee9ea4 --- /dev/null +++ b/Common/Api/BacktestLog.cs @@ -0,0 +1,35 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System.Collections.Generic; + +namespace QuantConnect.Api +{ + /// + /// Logs from a backtest + /// + public class BacktestLog : RestResponse + { + /// + /// List of log lines from the backtest + /// + public List Logs { get; set; } + + /// + /// Total number of log lines in the backtest + /// + public int Length { get; set; } + } +} diff --git a/Common/Api/LiveLog.cs b/Common/Api/LiveLog.cs index 3cce5b12145c..a604c9feed75 100644 --- a/Common/Api/LiveLog.cs +++ b/Common/Api/LiveLog.cs @@ -29,12 +29,12 @@ public class LiveLog : RestResponse public List Logs { get; set; } /// - /// Total amount of rows in the logs + /// Total amount of rows in the logs across all the live deployments of the project /// public int Length { get; set; } /// - /// Amount of log rows before the current deployment + /// Amount of log rows before the deployment requested through the algorithm id /// public int DeploymentOffset { get; set; } } diff --git a/Common/Interfaces/IApi.cs b/Common/Interfaces/IApi.cs index 1d1a5961267e..f4301e398e5c 100644 --- a/Common/Interfaces/IApi.cs +++ b/Common/Interfaces/IApi.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.ComponentModel.Composition; using QuantConnect.Api; +using QuantConnect.Orders; using QuantConnect.Notifications; using QuantConnect.Optimizer.Objectives; using QuantConnect.Optimizer.Parameters; @@ -174,6 +175,15 @@ public interface IApi : IDisposable /// Rest response on success RestResponse UpdateBacktest(int projectId, string backtestId, string name = "", string note = ""); + /// + /// Updates the tags collection for a backtest + /// + /// Project for the backtest we want to update + /// Backtest id we want to update + /// The new backtest tags + /// + RestResponse UpdateBacktestTags(int projectId, string backtestId, IReadOnlyCollection tags); + /// /// Delete a backtest from the specified project and backtestId. /// @@ -201,6 +211,27 @@ public interface IApi : IDisposable /// public InsightResponse ReadBacktestInsights(int projectId, string backtestId, int start = 0, int end = 0); + /// + /// Returns the orders of the specified backtest and project id. + /// + /// Id of the project from which to read the orders + /// Id of the backtest from which to read the orders + /// Starting index of the orders to be fetched + /// Last index of the orders to be fetched + /// The with the requested orders and the total order count + OrdersResponseWrapper ReadBacktestOrders(int projectId, string backtestId, int start = 0, int end = 0); + + /// + /// Gets the logs of a specific backtest + /// + /// Id of the project from which to read the backtest + /// Id of the backtest from which to read the logs + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read + /// Keyword to filter the log lines + /// with the requested log lines and the total log line count + BacktestLog ReadBacktestLog(int projectId, string backtestId, int start = 0, int end = 0, string query = null); + #pragma warning disable CS1574 /// /// Estimate optimization with the specified parameters via QuantConnect.com API @@ -300,10 +331,12 @@ public OptimizationSummary CreateOptimization( /// /// Project Id of the live running algorithm /// Algorithm Id of the live running algorithm - /// Start line of logs to read - /// End line of logs to read - /// List of strings that represent the logs of the algorithm - LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine, int endLine); + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read + /// Keyword to filter the log lines + /// Whether only the logs of the given deployment should be returned + /// with the requested log lines + LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0, int endLine = 0, string query = null, bool deploymentLogs = false); /// /// Returns a chart object from a live algorithm @@ -333,6 +366,16 @@ public OptimizationSummary CreateOptimization( /// public InsightResponse ReadLiveInsights(int projectId, int start = 0, int end = 0); + /// + /// Returns the orders of the specified project id live algorithm. + /// + /// Id of the project from which to read the live orders + /// Deploy id (algorithm id) of the live running algorithm + /// Starting index of the orders to be fetched + /// Last index of the orders to be fetched + /// The with the requested orders and the total order count + OrdersResponseWrapper ReadLiveOrders(int projectId, string algorithmId = null, int start = 0, int end = 0); + /// /// Gets the link to the downloadable data. /// @@ -526,11 +569,27 @@ public OptimizationSummary CreateOptimization( /// public RestResponse DeleteObjectStore(string organizationId, string key); + /// + /// Request to list Object Store files of a specific organization and path + /// + /// Organization ID we would like to list the Object Store files from + /// Path to the Object Store files + /// + ListObjectStoreResponse ListObjectStore(string organizationId, string path); + /// /// Gets a list of LEAN versions with their corresponding basic descriptions /// public VersionsResponse ReadLeanVersions(); + /// + /// Create a live command + /// + /// Project for the live instance we want to run the command against + /// The command to run + /// + RestResponse CreateLiveCommand(int projectId, object command); + /// /// Broadcast a live command /// diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs index 8f1426850fa2..88af09c592fa 100644 --- a/Common/Orders/OrderJsonConverter.cs +++ b/Common/Orders/OrderJsonConverter.cs @@ -289,7 +289,8 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) order = new LimitIfTouchedOrder { LimitPrice = SafeDecimalValueOrDefault(jObject["LimitPrice"] ?? jObject["limitPrice"]), - TriggerPrice = SafeDecimalValueOrDefault(jObject["TriggerPrice"] ?? jObject["triggerPrice"]) + TriggerPrice = SafeDecimalValueOrDefault(jObject["TriggerPrice"] ?? jObject["triggerPrice"]), + TriggerTouched = jObject["TriggerTouched"]?.Value() ?? jObject["triggerTouched"]?.Value() ?? default(bool) }; break; diff --git a/Tests/Api/LiveTradingTests.cs b/Tests/Api/LiveTradingTests.cs index 992b3e5a0055..6ce41c5847db 100644 --- a/Tests/Api/LiveTradingTests.cs +++ b/Tests/Api/LiveTradingTests.cs @@ -797,8 +797,9 @@ public void ReadLiveOrders() // Wait to receive the orders var readLiveOrders = WaitForReadLiveOrdersResponse(projectId, 60 * 5); - Assert.IsTrue(readLiveOrders.Any()); - Assert.AreEqual(Symbols.SPY, readLiveOrders.First().Symbol); + Assert.GreaterOrEqual(readLiveOrders.Length, readLiveOrders.Orders.Count); + Assert.IsTrue(readLiveOrders.Orders.Any()); + Assert.AreEqual(Symbols.SPY, readLiveOrders.Orders.First().Symbol); // Liquidate live algorithm; will also stop algorithm var liquidateLive = ApiClient.LiquidateLiveAlgorithm(projectId); @@ -879,11 +880,11 @@ def CreateLiveAlgorithmFromPython(apiClient, projectId, compileId, nodeId): /// Id of the project /// Seconds to allow for receive an order /// - private List WaitForReadLiveOrdersResponse(int projectId, int seconds) + private OrdersResponseWrapper WaitForReadLiveOrdersResponse(int projectId, int seconds) { - var readLiveOrders = new List(); + var readLiveOrders = new OrdersResponseWrapper(); var finish = DateTime.UtcNow.AddSeconds(seconds); - while (DateTime.UtcNow < finish && !readLiveOrders.Any()) + while (DateTime.UtcNow < finish && !readLiveOrders.Orders.Any()) { Thread.Sleep(10000); readLiveOrders = ApiClient.ReadLiveOrders(projectId); diff --git a/Tests/Api/LogsTests.cs b/Tests/Api/LogsTests.cs new file mode 100644 index 000000000000..d8fe9f6aa3d1 --- /dev/null +++ b/Tests/Api/LogsTests.cs @@ -0,0 +1,176 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace QuantConnect.Tests.API +{ + /// + /// Tests for the log reading endpoints, run against a loopback stub so no api credentials are needed + /// + [TestFixture, Parallelizable(ParallelScope.Fixtures)] + public class LogsTests + { + private const string SuccessfulBacktestLogResponse = @"{ + ""logs"": [ ""2013-10-07 13:31:00 Launching analysis"", ""2013-10-07 13:32:00 Error: boom"" ], + ""length"": 1337, + ""success"": true + }"; + + private const string SuccessfulLiveLogResponse = @"{ + ""logs"": [ ""2024-06-07 13:31:00 Launching analysis"", ""2024-06-07 13:32:00 Error: boom"" ], + ""length"": 1337, + ""deploymentOffset"": 1200, + ""success"": true + }"; + + [Test] + public void ReadBacktestLogSendsTheDocumentedRequest() + { + using var server = new StubApiServer(SuccessfulBacktestLogResponse); + using var api = server.CreateApi(); + + api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", 10, 60, "Error"); + + var request = server.GetSingleRequest(); + Assert.AreEqual("/backtests/read/log", request.Path); + Assert.AreEqual(23456789, request.Body["projectId"].Value()); + Assert.AreEqual("26c7bb06b8487cff1c7b3c44652b30f1", request.Body["backtestId"].Value()); + Assert.AreEqual("Error", request.Body["query"].Value()); + Assert.AreEqual(10, request.Body["start"].Value()); + Assert.AreEqual(60, request.Body["end"].Value()); + } + + [Test] + public void ReadLiveLogsSendsTheDocumentedRequest() + { + using var server = new StubApiServer(SuccessfulLiveLogResponse); + using var api = server.CreateApi(); + + api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 10, 60, "Error", deploymentLogs: true); + + var request = server.GetSingleRequest(); + Assert.AreEqual("/live/logs/read", request.Path); + Assert.AreEqual("json", request.Body["format"].Value()); + Assert.AreEqual(23456789, request.Body["projectId"].Value()); + Assert.AreEqual("L-6e9d8a78f5af89d401f630585be90e43", request.Body["algorithmId"].Value()); + Assert.AreEqual(10, request.Body["startLine"].Value()); + Assert.AreEqual(60, request.Body["endLine"].Value()); + Assert.AreEqual("Error", request.Body["query"].Value()); + Assert.IsTrue(request.Body["deploymentLogs"].Value()); + } + + [Test] + public void ReadLiveLogsRequestsEveryDeploymentAndEveryLineByDefault() + { + using var server = new StubApiServer(SuccessfulLiveLogResponse); + using var api = server.CreateApi(); + + api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43"); + + var body = server.GetSingleRequest().Body; + Assert.IsFalse(body["deploymentLogs"].Value()); + Assert.AreEqual(JTokenType.Null, body["query"].Type); + } + + [TestCase(0, 200)] + [TestCase(500, 700)] + public void ReadBacktestLogDefaultsTheEndLineToAFullWindow(int start, int expectedEnd) + { + using var server = new StubApiServer(SuccessfulBacktestLogResponse); + using var api = server.CreateApi(); + + api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start: start); + + var body = server.GetSingleRequest().Body; + Assert.AreEqual(start, body["start"].Value()); + Assert.AreEqual(expectedEnd, body["end"].Value()); + } + + [TestCase(0, 200)] + [TestCase(500, 700)] + public void ReadLiveLogsDefaultsTheEndLineToAFullWindow(int startLine, int expectedEndLine) + { + using var server = new StubApiServer(SuccessfulLiveLogResponse); + using var api = server.CreateApi(); + + api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", startLine); + + var body = server.GetSingleRequest().Body; + Assert.AreEqual(startLine, body["startLine"].Value()); + Assert.AreEqual(expectedEndLine, body["endLine"].Value()); + } + + [Test] + public void ReadBacktestLogRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start: 0, end: 201)); + } + + [Test] + public void ReadLiveLogsRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 201)); + } + + [Test] + public void ReadBacktestLogExposesTheTotalLogLineCount() + { + using var server = new StubApiServer(SuccessfulBacktestLogResponse); + using var api = server.CreateApi(); + + var response = api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1"); + + Assert.IsTrue(response.Success); + Assert.AreEqual(1337, response.Length); + Assert.AreEqual(2, response.Logs.Count); + Assert.AreEqual("2013-10-07 13:32:00 Error: boom", response.Logs[1]); + } + + [Test] + public void ReadLiveLogsExposesTheTotalLogLineCountAndTheDeploymentOffset() + { + using var server = new StubApiServer(SuccessfulLiveLogResponse); + using var api = server.CreateApi(); + + var response = api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43"); + + Assert.IsTrue(response.Success); + Assert.AreEqual(1337, response.Length); + Assert.AreEqual(1200, response.DeploymentOffset); + Assert.AreEqual(2, response.Logs.Count); + } + + [Test] + public void ReadBacktestLogReportsTheApiErrors() + { + using var server = new StubApiServer(@"{ ""success"": false, ""errors"": [ ""Backtest not found"" ] }"); + using var api = server.CreateApi(); + + var response = api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1"); + + Assert.IsFalse(response.Success); + CollectionAssert.AreEqual(new[] { "Backtest not found" }, response.Errors); + } + } +} diff --git a/Tests/Api/OrdersTests.cs b/Tests/Api/OrdersTests.cs new file mode 100644 index 000000000000..01048aa525f0 --- /dev/null +++ b/Tests/Api/OrdersTests.cs @@ -0,0 +1,296 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Net; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using QuantConnect.Api; +using QuantConnect.Orders; + +namespace QuantConnect.Tests.API +{ + /// + /// Tests for the order reading endpoints, run against a loopback stub so no api credentials are needed + /// + [TestFixture, Parallelizable(ParallelScope.Fixtures)] + public class OrdersTests + { + private const string SuccessfulOrdersResponse = @"{ + ""orders"": [ + { + ""id"": 1, + ""contingentId"": 0, + ""brokerId"": [ ""1"" ], + ""symbol"": { ""value"": ""SPY"", ""id"": ""SPY R735QTJ8XC9X"", ""permtick"": ""SPY"" }, + ""limitPrice"": 145.0, + ""stopPrice"": 144.0, + ""stopTriggered"": true, + ""price"": 144.5, + ""priceCurrency"": ""USD"", + ""time"": ""2013-10-07T13:31:00Z"", + ""createdTime"": ""2013-10-07T13:31:00Z"", + ""quantity"": 10.0, + ""type"": 3, + ""status"": 3, + ""tag"": ""stop limit"", + ""securityType"": 1, + ""direction"": 0, + ""value"": 1445.0, + ""events"": [ + { + ""algorithmId"": ""1234"", + ""orderId"": 1, + ""orderEventId"": 1, + ""symbol"": ""SPY R735QTJ8XC9X"", + ""symbolValue"": ""SPY"", + ""time"": 1381152660.0, + ""status"": ""filled"", + ""fillPrice"": 144.5, + ""fillPriceCurrency"": ""USD"", + ""fillQuantity"": 10.0, + ""direction"": ""buy"" + } + ] + }, + { + ""id"": 2, + ""brokerId"": [ ""2"" ], + ""symbol"": { ""value"": ""SPY"", ""id"": ""SPY R735QTJ8XC9X"", ""permtick"": ""SPY"" }, + ""limitPrice"": 146.0, + ""triggerPrice"": 145.5, + ""triggerTouched"": true, + ""price"": 145.5, + ""time"": ""2013-10-07T13:32:00Z"", + ""quantity"": 5.0, + ""type"": 7, + ""status"": 1, + ""securityType"": 1, + ""events"": [] + }, + { + ""id"": 3, + ""brokerId"": [ ""3"" ], + ""symbol"": { ""value"": ""SPY"", ""id"": ""SPY R735QTJ8XC9X"", ""permtick"": ""SPY"" }, + ""stopPrice"": 143.0, + ""trailingAmount"": 0.05, + ""trailingAsPercentage"": true, + ""price"": 144.0, + ""time"": ""2013-10-07T13:33:00Z"", + ""quantity"": -5.0, + ""type"": 11, + ""status"": 1, + ""securityType"": 1, + ""events"": [] + } + ], + ""length"": 42, + ""success"": true + }"; + + private const string UnsuccessfulOrdersResponse = @"{ ""success"": false, ""errors"": [ ""Backtest not found"" ] }"; + + [Test] + public void ReadBacktestOrdersSendsTheDocumentedRequest() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", 10, 60); + + var request = server.GetSingleRequest(); + Assert.AreEqual("/backtests/orders/read", request.Path); + Assert.AreEqual(23456789, request.Body["projectId"].Value()); + Assert.AreEqual("26c7bb06b8487cff1c7b3c44652b30f1", request.Body["backtestId"].Value()); + Assert.AreEqual(10, request.Body["start"].Value()); + Assert.AreEqual(60, request.Body["end"].Value()); + } + + [Test] + public void ReadLiveOrdersSendsTheDocumentedRequest() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + api.ReadLiveOrders(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 10, 60); + + var request = server.GetSingleRequest(); + Assert.AreEqual("/live/orders/read", request.Path); + Assert.AreEqual(23456789, request.Body["projectId"].Value()); + Assert.AreEqual("L-6e9d8a78f5af89d401f630585be90e43", request.Body["algorithmId"].Value()); + Assert.AreEqual(10, request.Body["start"].Value()); + Assert.AreEqual(60, request.Body["end"].Value()); + } + + [Test] + public void ReadLiveOrdersOmitsTheAlgorithmIdWhenNotProvided() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + api.ReadLiveOrders(23456789); + + Assert.IsNull(server.GetSingleRequest().Body["algorithmId"]); + } + + [TestCase(0, 100)] + [TestCase(250, 350)] + public void ReadBacktestOrdersDefaultsTheEndIndexToAFullWindow(int start, int expectedEnd) + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start); + + var body = server.GetSingleRequest().Body; + Assert.AreEqual(start, body["start"].Value()); + Assert.AreEqual(expectedEnd, body["end"].Value()); + } + + [TestCase(0, 1000)] + [TestCase(250, 1250)] + public void ReadLiveOrdersDefaultsTheEndIndexToAFullWindow(int start, int expectedEnd) + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + api.ReadLiveOrders(23456789, start: start); + + var body = server.GetSingleRequest().Body; + Assert.AreEqual(start, body["start"].Value()); + Assert.AreEqual(expectedEnd, body["end"].Value()); + } + + [Test] + public void ReadBacktestOrdersRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", 0, 101)); + } + + [Test] + public void ReadLiveOrdersRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadLiveOrders(23456789, start: 0, end: 1001)); + } + + [Test] + public void ReadBacktestOrdersExposesTheTotalOrderCount() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + var response = api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1"); + + Assert.IsTrue(response.Success); + Assert.AreEqual(42, response.Length); + Assert.AreEqual(3, response.Orders.Count); + } + + [Test] + public void ReadLiveOrdersExposesTheTotalOrderCount() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + var response = api.ReadLiveOrders(23456789); + + Assert.IsTrue(response.Success); + Assert.AreEqual(42, response.Length); + Assert.AreEqual(3, response.Orders.Count); + } + + [Test] + public void ReadBacktestOrdersDeserializesTheDocumentedOrderFields() + { + using var server = new StubApiServer(SuccessfulOrdersResponse); + using var api = server.CreateApi(); + + var orders = api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1").Orders; + + var stopLimit = (StopLimitOrder)orders[0].Order; + Assert.AreEqual(Symbols.SPY, orders[0].Symbol); + Assert.AreEqual(145m, stopLimit.LimitPrice); + Assert.AreEqual(144m, stopLimit.StopPrice); + Assert.IsTrue(stopLimit.StopTriggered); + Assert.AreEqual(1, orders[0].Events.Count); + Assert.AreEqual(144.5m, orders[0].Events[0].FillPrice); + + var limitIfTouched = (LimitIfTouchedOrder)orders[1].Order; + Assert.AreEqual(145.5m, limitIfTouched.TriggerPrice); + Assert.IsTrue(limitIfTouched.TriggerTouched); + + var trailingStop = (TrailingStopOrder)orders[2].Order; + Assert.AreEqual(0.05m, trailingStop.TrailingAmount); + Assert.IsTrue(trailingStop.TrailingAsPercentage); + } + + [Test] + public void ReadBacktestOrdersThrowsOnAnUnsuccessfulResponse() + { + using var server = new StubApiServer(UnsuccessfulOrdersResponse); + using var api = server.CreateApi(); + + var exception = Assert.Throws(() => api.ReadBacktestOrders(23456789, "26c7bb06b8487cff1c7b3c44652b30f1")); + Assert.IsTrue(exception.Message.Contains("Backtest not found", StringComparison.InvariantCulture)); + } + + [Test] + public void ReadLiveOrdersThrowsOnAnUnsuccessfulResponse() + { + using var server = new StubApiServer(UnsuccessfulOrdersResponse); + using var api = server.CreateApi(); + + Assert.Throws(() => api.ReadLiveOrders(23456789)); + } + + [Test] + public void ReadBacktestInsightsRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadBacktestInsights(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", 0, 101)); + } + + [Test] + public void ReadLiveInsightsRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadLiveInsights(23456789, 0, 101)); + } + + [TestCase(0, 100)] + [TestCase(250, 350)] + public void ReadBacktestInsightsDefaultsTheEndIndexToAFullWindow(int start, int expectedEnd) + { + using var server = new StubApiServer(@"{ ""insights"": [], ""length"": 0, ""success"": true }"); + using var api = server.CreateApi(); + + api.ReadBacktestInsights(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start); + + var body = server.GetSingleRequest().Body; + Assert.AreEqual(start, body["start"].Value()); + Assert.AreEqual(expectedEnd, body["end"].Value()); + } + } +} diff --git a/Tests/Api/ProjectTests.cs b/Tests/Api/ProjectTests.cs index cd4d4ced1af8..2f96b8d20c89 100644 --- a/Tests/Api/ProjectTests.cs +++ b/Tests/Api/ProjectTests.cs @@ -17,6 +17,8 @@ using System.IO; using System.Web; using System.Linq; +using System.Text.RegularExpressions; +using System.Globalization; using NUnit.Framework; using QuantConnect.Api; using System.Collections.Generic; @@ -34,6 +36,63 @@ namespace QuantConnect.Tests.API [TestFixture, Explicit("Requires configured api access and available backtest node to run on"), Parallelizable(ParallelScope.Fixtures)] public class ProjectTests : ApiTestBase { + /// + /// Places a market order per minute bar until 150 exist: several pages for a 100 window, still quick for a small one + /// + private const string ManyOrdersAlgorithm = @" +using QuantConnect.Data; + +namespace QuantConnect.Algorithm.CSharp +{ + public class ManyOrdersAlgorithm : QCAlgorithm + { + private Symbol _spy; + + public override void Initialize() + { + SetStartDate(2013, 10, 7); + SetEndDate(2013, 10, 7); + SetCash(100000); + _spy = AddEquity(""SPY"", Resolution.Minute).Symbol; + } + + public override void OnData(Slice slice) + { + if (Transactions.OrdersCount < 150) + { + MarketOrder(_spy, Time.Minute % 2 == 0 ? 1 : -1); + } + } + } +}"; + /// + /// Logs one numbered line per minute bar on a single day, marking every tenth one, so a backtest + /// has a few hundred log lines to page through and a subset to search for + /// + private const string ManyLogsAlgorithm = @" +using QuantConnect.Data; + +namespace QuantConnect.Algorithm.CSharp +{ + public class ManyLogsAlgorithm : QCAlgorithm + { + private int _lines; + + public override void Initialize() + { + SetStartDate(2013, 10, 7); + SetEndDate(2013, 10, 7); + SetCash(100000); + AddEquity(""SPY"", Resolution.Minute); + } + + public override void OnData(Slice slice) + { + _lines++; + Log(_lines % 10 == 0 ? $""Marker line {_lines}"" : $""Plain line {_lines}""); + } + } +}"; private readonly Dictionary _defaultSettings = new Dictionary() { { "id", "QuantConnectBrokerage" }, @@ -306,8 +365,9 @@ private void Perform_CreateCompileBackTest_Tests(string projectName, Language la // In the same way, read the orders returned in the backtest var backtestOrdersRead = ApiClient.ReadBacktestOrders(project.Projects.First().ProjectId, backtest.BacktestId, 0, 1); - Assert.IsTrue(backtestOrdersRead.Any()); - Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.First().Symbol.Value); + Assert.GreaterOrEqual(backtestOrdersRead.Length, backtestOrdersRead.Orders.Count); + Assert.IsTrue(backtestOrdersRead.Orders.Any()); + Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.Orders.First().Symbol.Value); // Verify we have the backtest in our project var listBacktests = ApiClient.ListBacktests(project.Projects.First().ProjectId); @@ -339,6 +399,179 @@ private void Perform_CreateCompileBackTest_Tests(string projectName, Language la Assert.IsTrue(deleteProject.Success); } + /// + /// Pages through every order of a backtest using the given window size and checks + /// that the reported total length matches the orders actually received + /// + [TestCase(20)] + [TestCase(50)] + [TestCase(100)] + public void ReadBacktestOrdersPaginatesThroughAllOrders(int windowSize) + { + var projectName = $"{GetTimestamp()} Test {TestAccount} Orders Pagination"; + var projectResult = ApiClient.CreateProject(projectName, Language.CSharp, TestOrganization); + Assert.IsTrue(projectResult.Success, $"Error creating project:\n {string.Join("\n ", projectResult.Errors)}"); + var project = projectResult.Projects.First(); + + try + { + var updateProjectFileContent = ApiClient.UpdateProjectFileContent(project.ProjectId, "Main.cs", ManyOrdersAlgorithm); + Assert.IsTrue(updateProjectFileContent.Success, + $"Error updating project file:\n {string.Join("\n ", updateProjectFileContent.Errors)}"); + + var compile = ApiClient.CreateCompile(project.ProjectId); + compile = WaitForCompilerResponse(ApiClient, project.ProjectId, compile.CompileId); + Assert.IsTrue(compile.Success, $"Error compiling project:\n {string.Join("\n ", compile.Errors)}"); + + var backtest = ApiClient.CreateBacktest(project.ProjectId, compile.CompileId, $"Orders Pagination Backtest {GetTimestamp()}"); + backtest = WaitForBacktestCompletion(ApiClient, project.ProjectId, backtest.BacktestId, secondsTimeout: 300); + Assert.IsTrue(backtest.Success, $"Error running backtest:\n {string.Join("\n ", backtest.Errors)}"); + var totalOrders = int.Parse(backtest.Statistics["Total Orders"], System.Globalization.CultureInfo.InvariantCulture); + Assert.Greater(totalOrders, windowSize, "The backtest needs more orders than the window size to exercise pagination"); + + var orders = new List(); + var pages = 0; + int length; + do + { + var page = ApiClient.ReadBacktestOrders(project.ProjectId, backtest.BacktestId, orders.Count, orders.Count + windowSize); + Assert.IsTrue(page.Success, $"Error reading orders:\n {string.Join("\n ", page.Errors)}"); + Assert.IsNotEmpty(page.Orders, $"Received an empty page at index {orders.Count} of {page.Length}"); + pages++; + QuantConnect.Logging.Log.Trace($"Page {pages}: start {orders.Count}, window {windowSize}, received {page.Orders.Count}, length {page.Length}"); + + length = page.Length; + orders.AddRange(page.Orders); + } + while (orders.Count < length); + + Assert.AreEqual(totalOrders, length, "The length reported by the API should be the total order count of the backtest"); + Assert.AreEqual(totalOrders, orders.Count, "Paging should have received every order exactly once"); + CollectionAssert.AllItemsAreUnique(orders.Select(x => x.Order.Id)); + Assert.Greater(pages, 1); + } + finally + { + ApiClient.DeleteProject(project.ProjectId); + } + } + /// + /// Pages through every log line of a backtest using the given window size and checks that + /// the reported total matches the lines received and that the numbered lines arrive once each + /// + [TestCase(100)] + [TestCase(200)] + public void ReadBacktestLogPaginatesThroughAllLines(int windowSize) + { + RunBacktest(ManyLogsAlgorithm, "Logs Pagination", out var projectId, out var backtestId); + try + { + var lines = ReadAllBacktestLogLines(projectId, backtestId, null, windowSize, out var length); + + foreach (var line in lines.Take(20)) + { + Console.WriteLine(line); + } + + Assert.AreEqual(length, lines.Count, "Paging should have received every log line exactly once"); + var numbers = NumberedLines(lines); + Assert.Greater(numbers.Count, windowSize, "The backtest needs more numbered lines than the window size to exercise pagination"); + CollectionAssert.AreEqual(Enumerable.Range(1, numbers.Count), numbers, "The numbered lines should arrive in order with no gaps or duplicates"); + } + finally + { + ApiClient.DeleteProject(projectId); + } + } + + /// + /// Searches the backtest log with the query filter, paging through the matches, and checks that + /// only the marked lines come back and that all of them do + /// + [TestCase(20)] + [TestCase(100)] + public void ReadBacktestLogFiltersLinesByQuery(int windowSize) + { + RunBacktest(ManyLogsAlgorithm, "Logs Query", out var projectId, out var backtestId); + try + { + var allNumbers = NumberedLines(ReadAllBacktestLogLines(projectId, backtestId, null, 200, out _)); + var expected = allNumbers.Where(x => x % 10 == 0).ToList(); + Assert.Greater(expected.Count, 1); + + var lines = ReadAllBacktestLogLines(projectId, backtestId, "Marker", windowSize, out var length); + + foreach (var line in lines.Take(20)) + { + Console.WriteLine(line); + } + + Assert.AreEqual(length, lines.Count, "Paging should have received every matching line exactly once"); + Assert.IsTrue(lines.All(x => x.Contains("Marker", StringComparison.Ordinal)), "Every returned line should contain the query"); + CollectionAssert.AreEqual(expected, NumberedLines(lines), "The query should return exactly the marked lines, in order"); + } + finally + { + ApiClient.DeleteProject(projectId); + } + } + + /// + /// Creates a project with the given algorithm, compiles it and runs a backtest to completion + /// + private void RunBacktest(string algorithm, string testName, out int projectId, out string backtestId) + { + var projectResult = ApiClient.CreateProject($"{GetTimestamp()} Test {TestAccount} {testName}", Language.CSharp, TestOrganization); + Assert.IsTrue(projectResult.Success, $"Error creating project: {string.Join(", ", projectResult.Errors)}"); + projectId = projectResult.Projects.First().ProjectId; + + var updateProjectFileContent = ApiClient.UpdateProjectFileContent(projectId, "Main.cs", algorithm); + Assert.IsTrue(updateProjectFileContent.Success, $"Error updating project file: {string.Join(", ", updateProjectFileContent.Errors)}"); + + var compile = ApiClient.CreateCompile(projectId); + compile = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId); + Assert.IsTrue(compile.Success, $"Error compiling project: {string.Join(", ", compile.Errors)}"); + + var backtest = ApiClient.CreateBacktest(projectId, compile.CompileId, $"{testName} Backtest {GetTimestamp()}"); + backtest = WaitForBacktestCompletion(ApiClient, projectId, backtest.BacktestId, secondsTimeout: 300); + Assert.IsTrue(backtest.Success, $"Error running backtest: {string.Join(", ", backtest.Errors)}"); + backtestId = backtest.BacktestId; + } + + /// + /// Reads the whole backtest log, or only the lines matching the query, in pages of the given size + /// + private List ReadAllBacktestLogLines(int projectId, string backtestId, string query, int windowSize, out int length) + { + var lines = new List(); + var pages = 0; + do + { + var page = ApiClient.ReadBacktestLog(projectId, backtestId, lines.Count, lines.Count + windowSize, query); + Assert.IsTrue(page.Success, $"Error reading the backtest log: {string.Join(", ", page.Errors)}"); + Assert.IsNotEmpty(page.Logs, $"Received an empty page at index {lines.Count} of {page.Length}"); + pages++; + QuantConnect.Logging.Log.Trace($"Page {pages}: query {query ?? "(none)"}, start {lines.Count}, window {windowSize}, received {page.Logs.Count}, length {page.Length}"); + + length = page.Length; + lines.AddRange(page.Logs); + } + while (lines.Count < length); + + return lines; + } + + /// + /// Extracts the number of every line the test algorithm wrote, ignoring any other engine output + /// + private static List NumberedLines(IEnumerable lines) + { + return lines + .Select(x => Regex.Match(x, @"(?:Plain|Marker) line (\d+)")) + .Where(x => x.Success) + .Select(x => int.Parse(x.Groups[1].Value, CultureInfo.InvariantCulture)) + .ToList(); + } [Test] public void ReadBacktestOrdersReportAndChart() { @@ -373,13 +606,14 @@ public void ReadBacktestOrdersReportAndChart() backtestRead = WaitForBacktestCompletion(ApiClient, project.ProjectId, backtest.BacktestId); var backtestOrdersRead = ApiClient.ReadBacktestOrders(project.ProjectId, backtest.BacktestId); string stringRepresentation; - foreach (var backtestOrder in backtestOrdersRead) + foreach (var backtestOrder in backtestOrdersRead.Orders) { stringRepresentation = backtestOrder.ToString(); Assert.IsTrue(ApiTestBase.IsValidJson(stringRepresentation)); } - Assert.IsTrue(backtestOrdersRead.Any()); - Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.First().Symbol.Value); + Assert.GreaterOrEqual(backtestOrdersRead.Length, backtestOrdersRead.Orders.Count); + Assert.IsTrue(backtestOrdersRead.Orders.Any()); + Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.Orders.First().Symbol.Value); var readBacktestReport = ApiClient.ReadBacktestReport(project.ProjectId, backtest.BacktestId); stringRepresentation = readBacktestReport.ToString(); diff --git a/Tests/Api/StubApiServer.cs b/Tests/Api/StubApiServer.cs new file mode 100644 index 000000000000..7e370ef79319 --- /dev/null +++ b/Tests/Api/StubApiServer.cs @@ -0,0 +1,183 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using QuantConnect.Api; + +namespace QuantConnect.Tests.API +{ + internal sealed class CapturedRequest + { + public string Path { get; set; } + public JObject Body { get; set; } + } + + /// + /// Minimal loopback http endpoint that records the requests the api client sends and replies with a canned body + /// + internal sealed class StubApiServer : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly byte[] _response; + private readonly ConcurrentQueue _requests; + + public string BaseUrl { get; } + + public StubApiServer(string responseBody) + { + _cancellationTokenSource = new CancellationTokenSource(); + _requests = new ConcurrentQueue(); + var payload = Encoding.UTF8.GetBytes(responseBody); + _response = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + + $"Content-Length: {payload.Length}\r\nConnection: close\r\n\r\n").Concat(payload).ToArray(); + + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"; + Task.Run(() => Serve(_cancellationTokenSource.Token)); + } + + public Api.Api CreateApi() + { + var api = new StubbedApi(BaseUrl); + api.Initialize(0, "token", Globals.DataFolder); + return api; + } + + public CapturedRequest GetSingleRequest() + { + Assert.AreEqual(1, _requests.Count, "Expected exactly one request to reach the stub server"); + _requests.TryPeek(out var request); + return request; + } + + public void Dispose() + { + _cancellationTokenSource.Cancel(); + _listener.Stop(); + _cancellationTokenSource.Dispose(); + } + + private async Task Serve(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + return; + } + + using (client) + { + using var stream = client.GetStream(); + var request = await ReadRequest(stream, cancellationToken).ConfigureAwait(false); + if (request != null) + { + _requests.Enqueue(request); + } + await stream.WriteAsync(_response, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private static async Task ReadRequest(Stream stream, CancellationToken cancellationToken) + { + var buffer = new byte[8192]; + var received = new List(); + var headerEnd = -1; + while (headerEnd < 0) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return null; + } + received.AddRange(buffer.Take(read)); + headerEnd = IndexOfHeaderEnd(received); + } + + var header = Encoding.ASCII.GetString(received.ToArray(), 0, headerEnd); + var body = received.Skip(headerEnd + 4).ToList(); + var contentLength = GetContentLength(header); + while (body.Count < contentLength) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + body.AddRange(buffer.Take(read)); + } + + return new CapturedRequest + { + Path = header.Split("\r\n")[0].Split(' ')[1], + Body = JObject.Parse(Encoding.UTF8.GetString(body.ToArray())) + }; + } + + private static int IndexOfHeaderEnd(List received) + { + for (var i = 0; i + 3 < received.Count; i++) + { + if (received[i] == '\r' && received[i + 1] == '\n' && received[i + 2] == '\r' && received[i + 3] == '\n') + { + return i; + } + } + return -1; + } + + private static int GetContentLength(string header) + { + var line = header.Split("\r\n") + .FirstOrDefault(x => x.StartsWith("Content-Length:", StringComparison.InvariantCultureIgnoreCase)); + return line == null ? 0 : Parse.Int(line.Split(':')[1].Trim()); + } + } + + internal sealed class StubbedApi : Api.Api + { + private readonly string _baseUrl; + + public StubbedApi(string baseUrl) + { + _baseUrl = baseUrl; + } + + protected override ApiConnection CreateApiConnection(int userId, string token) + { + return new ApiConnection(userId, token, _baseUrl); + } + } +}