From f1333356ea4c6fbf26f206dd35df2c7d1b1f062e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 17 Sep 2026 12:57:36 -0400 Subject: [PATCH 01/10] Return the total order count from the order reading api methods and validate the paging window ReadBacktestOrders and ReadLiveOrders now return the OrdersResponseWrapper, whose Length holds the total order count, instead of only the page of orders, so callers can page through the whole collection. Both methods share the insights methods' window guard: a window larger than 100 throws and an end of 0 defaults to start + 100. Part of #9798 --- Api/Api.cs | 66 +++++------ Tests/Api/LiveTradingTests.cs | 11 +- Tests/Api/ProjectTests.cs | 97 +++++++++++++++- Tests/Api/ReadOrdersTests.cs | 201 ++++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 41 deletions(-) create mode 100644 Tests/Api/ReadOrdersTests.cs diff --git a/Api/Api.cs b/Api/Api.cs index 30d5f0773208..de832e0c2848 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -44,6 +44,7 @@ public class Api : IApi, IDownloadProvider { private readonly BlockingCollection> _clientPool; private string _dataFolder; + private const int MaxPageSize = 100; /// /// Serializer settings to use @@ -412,13 +413,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 be less than or equal to 100. + /// If 0, it defaults to start + 100 /// 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) + /// holding the requested orders and the total order count + /// The requested window is larger than 100 orders + public OrdersResponseWrapper ReadBacktestOrders(int projectId, string backtestId, int start = 0, int end = 0) { + end = GetPageEnd("orders", start, end); + using var request = ApiUtils.CreateJsonPostRequest("backtests/orders/read", new { start, @@ -427,7 +431,7 @@ public List ReadBacktestOrders(int projectId, string backtestI backtestId }); - return MakeRequestOrThrow(request, nameof(ReadBacktestOrders)).Orders; + return MakeRequestOrThrow(request, nameof(ReadBacktestOrders)); } /// @@ -536,16 +540,7 @@ public RestResponse UpdateBacktestTags(int projectId, string backtestId, IReadOn /// 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 = GetPageEnd("insights", start, end); TryJsonPost("backtests/insights/read", out InsightResponse result, new { projectId, backtestId, start, end }); return result; @@ -683,13 +678,16 @@ 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 + /// Starting index of the orders to be fetched + /// Last index of the orders to be fetched. Note that end - start must be less than or equal to 100. + /// If 0, it defaults to start + 100 /// Will throw an if there are any API errors - /// The list of - - public List ReadLiveOrders(int projectId, int start = 0, int end = 100) + /// holding the requested orders and the total order count + /// The requested window is larger than 100 orders + public OrdersResponseWrapper ReadLiveOrders(int projectId, int start = 0, int end = 0) { + end = GetPageEnd("orders", start, end); + using var request = ApiUtils.CreateJsonPostRequest("live/orders/read", new { start, @@ -697,7 +695,7 @@ public List ReadLiveOrders(int projectId, int start = 0, int e projectId }); - return MakeRequestOrThrow(request, nameof(ReadLiveOrders)).Orders; + return MakeRequestOrThrow(request, nameof(ReadLiveOrders)); } /// @@ -822,15 +820,7 @@ public ReadChartResponse ReadLiveChart(int projectId, string name, int start, in /// 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 = GetPageEnd("insights", start, end); TryJsonPost("live/insights/read", out InsightResponse result, new { projectId, start, end }); return result; @@ -1503,6 +1493,20 @@ private T MakeRequestOrThrow(HttpRequestMessage request, string callerName) return result; } + /// + /// Validates a paging window of at most items and returns the end index, + /// defaulting an unset end (0) to a full page from start + /// + private static int GetPageEnd(string itemsName, int start, int end) + { + var diff = end - start; + if (diff > MaxPageSize) + { + throw new ArgumentException($"The difference between the start and end index of the {itemsName} must be smaller than {MaxPageSize}, but it was {diff}."); + } + return end == 0 ? start + MaxPageSize : end; + } + /// /// Borrows and HTTP client from the pool /// diff --git a/Tests/Api/LiveTradingTests.cs b/Tests/Api/LiveTradingTests.cs index 992b3e5a0055..5efc94c7b22e 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, 1); + 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/ProjectTests.cs b/Tests/Api/ProjectTests.cs index cd4d4ced1af8..e1ade48ac957 100644 --- a/Tests/Api/ProjectTests.cs +++ b/Tests/Api/ProjectTests.cs @@ -34,6 +34,35 @@ 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); + } + } + } +}"; private readonly Dictionary _defaultSettings = new Dictionary() { { "id", "QuantConnectBrokerage" }, @@ -306,8 +335,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, 1); + 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 +369,62 @@ 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); + } + } [Test] public void ReadBacktestOrdersReportAndChart() { @@ -373,13 +459,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, 1); + 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/ReadOrdersTests.cs b/Tests/Api/ReadOrdersTests.cs new file mode 100644 index 000000000000..fc1fdcf082a9 --- /dev/null +++ b/Tests/Api/ReadOrdersTests.cs @@ -0,0 +1,201 @@ +/* + * 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 Newtonsoft.Json.Linq; +using NUnit.Framework; +using QuantConnect.Api; +using QuantConnect.Orders; +using QuantConnect.Util; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace QuantConnect.Tests.API +{ + [TestFixture] + public class ReadOrdersTests + { + private StubApiServer _server; + private Api.Api _apiClient; + + [SetUp] + public void SetUp() + { + _server = new StubApiServer(); + _apiClient = new StubbedApi(_server.BaseUrl); + _apiClient.Initialize(123, "token", ""); + } + + [TearDown] + public void TearDown() + { + _apiClient.DisposeSafely(); + _server.DisposeSafely(); + } + + [Test] + public void ReadBacktestOrdersThrowsWhenWindowIsTooLarge() + { + Assert.Throws(() => _apiClient.ReadBacktestOrders(1, "id", 0, 101)); + Assert.IsNull(_server.LastRequestBody); + } + + [Test] + public void ReadLiveOrdersThrowsWhenWindowIsTooLarge() + { + Assert.Throws(() => _apiClient.ReadLiveOrders(1, 0, 101)); + Assert.IsNull(_server.LastRequestBody); + } + + [Test] + public void ReadBacktestOrdersAcceptsMaximumWindow() + { + Assert.That(() => _apiClient.ReadBacktestOrders(1, "id", 0, 100), Throws.Nothing); + + var payload = JObject.Parse(_server.LastRequestBody); + Assert.AreEqual(0, payload["start"].Value()); + Assert.AreEqual(100, payload["end"].Value()); + } + + [Test] + public void ReadLiveOrdersAcceptsMaximumWindow() + { + Assert.That(() => _apiClient.ReadLiveOrders(1, 0, 100), Throws.Nothing); + + var payload = JObject.Parse(_server.LastRequestBody); + Assert.AreEqual(0, payload["start"].Value()); + Assert.AreEqual(100, payload["end"].Value()); + } + + [Test] + public void ReadBacktestOrdersDefaultsEndToStartPlusOneHundred() + { + _apiClient.ReadBacktestOrders(1, "id", 500); + + var payload = JObject.Parse(_server.LastRequestBody); + Assert.AreEqual(500, payload["start"].Value()); + Assert.AreEqual(600, payload["end"].Value()); + } + + [Test] + public void ReadLiveOrdersDefaultsEndToStartPlusOneHundred() + { + _apiClient.ReadLiveOrders(1, 500); + + var payload = JObject.Parse(_server.LastRequestBody); + Assert.AreEqual(500, payload["start"].Value()); + Assert.AreEqual(600, payload["end"].Value()); + } + + [Test] + public void ReadBacktestOrdersReturnsTheTotalOrderCount() + { + var response = _apiClient.ReadBacktestOrders(1, "id"); + + Assert.AreEqual(1234, response.Length); + Assert.IsEmpty(response.Orders); + } + + private 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); + } + } + + /// + /// Local HTTP server that captures the request body and replies with a canned orders response + /// + private class StubApiServer : IDisposable + { + private readonly HttpListener _listener; + private readonly Thread _thread; + + public string BaseUrl { get; } + + public string LastRequestBody { get; private set; } + + public StubApiServer() + { + BaseUrl = $"http://localhost:{GetAvailablePort()}/"; + _listener = new HttpListener(); + _listener.Prefixes.Add(BaseUrl); + _listener.Start(); + + _thread = new Thread(Listen) { IsBackground = true }; + _thread.Start(); + } + + public void Dispose() + { + _listener.Stop(); + _listener.Close(); + _thread.Join(TimeSpan.FromSeconds(5)); + } + + private void Listen() + { + while (_listener.IsListening) + { + HttpListenerContext context; + try + { + context = _listener.GetContext(); + } + catch (Exception) + { + // the listener was stopped + return; + } + + using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8)) + { + LastRequestBody = reader.ReadToEnd(); + } + + var buffer = Encoding.UTF8.GetBytes(EmptyOrdersResponse); + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = buffer.Length; + context.Response.OutputStream.Write(buffer, 0, buffer.Length); + context.Response.Close(); + } + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } + + private const string EmptyOrdersResponse = @"{ ""orders"": [], ""length"": 1234, ""success"": true }"; + } +} From 4d6e16369f393bacd21ec6b80720c5253f4074ed Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 17 Sep 2026 16:25:00 -0400 Subject: [PATCH 02/10] Send the live algorithm id when reading live orders and share the paging window guard across the api methods ReadLiveOrders takes an optional algorithmId, documented in the API spec. The window guard used by the orders, insights and logs methods is now a single helper with a per-endpoint cap, the log line range defaults to a full window like its siblings, and the order json converter reads the documented stopTriggered, triggerTouched and trailingPercentage fields. Part of #9798 --- Api/Api.cs | 109 ++++--- Common/Orders/OrderJsonConverter.cs | 26 +- Tests/Api/LiveTradingTests.cs | 2 +- Tests/Api/OrdersTests.cs | 479 ++++++++++++++++++++++++++++ Tests/Api/ProjectTests.cs | 4 +- Tests/Api/ReadOrdersTests.cs | 201 ------------ 6 files changed, 568 insertions(+), 253 deletions(-) create mode 100644 Tests/Api/OrdersTests.cs delete mode 100644 Tests/Api/ReadOrdersTests.cs diff --git a/Api/Api.cs b/Api/Api.cs index de832e0c2848..9102bb334ec0 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -42,9 +42,14 @@ 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 = 100; + private const int MaxInsightsWindow = 100; + private const int MaxLogLinesWindow = 250; + private readonly BlockingCollection> _clientPool; private string _dataFolder; - private const int MaxPageSize = 100; /// /// Serializer settings to use @@ -414,14 +419,14 @@ 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 - /// Last index of the orders to be fetched. Note that end - start must be less than or equal to 100. - /// If 0, it defaults to start + 100 + /// 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 - /// holding the requested orders and the total order count - /// The requested window is larger than 100 orders + /// 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 = GetPageEnd("orders", start, end); + end = ResolveWindowEnd(start, end, MaxBacktestOrdersWindow, "orders"); using var request = ApiUtils.CreateJsonPostRequest("backtests/orders/read", new { @@ -535,12 +540,13 @@ 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) { - end = GetPageEnd("insights", start, end); + end = ResolveWindowEnd(start, end, MaxInsightsWindow, "insights"); TryJsonPost("backtests/insights/read", out InsightResponse result, new { projectId, backtestId, start, end }); return result; @@ -678,22 +684,22 @@ 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 + /// 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 be less than or equal to 100. - /// If 0, it defaults to start + 100 + /// 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 - /// holding the requested orders and the total order count - /// The requested window is larger than 100 orders - public OrdersResponseWrapper ReadLiveOrders(int projectId, int start = 0, int end = 0) + /// 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) { - end = GetPageEnd("orders", start, end); + end = ResolveWindowEnd(start, end, MaxLiveOrdersWindow, "orders"); - using var request = ApiUtils.CreateJsonPostRequest("live/orders/read", new - { - start, - end, - projectId - }); + 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)); } @@ -751,16 +757,14 @@ 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 250. + /// Defaults to a full window starting at /// 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) { - 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, @@ -815,12 +819,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) { - end = GetPageEnd("insights", start, end); + end = ResolveWindowEnd(start, end, MaxInsightsWindow, "insights"); TryJsonPost("live/insights/read", out InsightResponse result, new { projectId, start, end }); return result; @@ -1474,6 +1479,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 /// @@ -1493,20 +1524,6 @@ private T MakeRequestOrThrow(HttpRequestMessage request, string callerName) return result; } - /// - /// Validates a paging window of at most items and returns the end index, - /// defaulting an unset end (0) to a full page from start - /// - private static int GetPageEnd(string itemsName, int start, int end) - { - var diff = end - start; - if (diff > MaxPageSize) - { - throw new ArgumentException($"The difference between the start and end index of the {itemsName} must be smaller than {MaxPageSize}, but it was {diff}."); - } - return end == 0 ? start + MaxPageSize : end; - } - /// /// Borrows and HTTP client from the pool /// diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs index 8f1426850fa2..353e48dbed22 100644 --- a/Common/Orders/OrderJsonConverter.cs +++ b/Common/Orders/OrderJsonConverter.cs @@ -271,7 +271,7 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { LimitPrice = SafeDecimalValueOrDefault(jObject["LimitPrice"] ?? jObject["limitPrice"]), StopPrice = SafeDecimalValueOrDefault(jObject["stopPrice"] ?? jObject["StopPrice"]), - StopTriggered = jObject["StopTriggered"]?.Value() ?? jObject["stopTriggered"]?.Value() ?? default(bool), + StopTriggered = SafeBooleanValueOrDefault(jObject, "stopTriggered"), StopTriggeredTime = jObject["StopTriggeredTime"]?.Value() ?? jObject["stopTriggeredTime"]?.Value() }; break; @@ -281,7 +281,8 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { StopPrice = SafeDecimalValueOrDefault(jObject["StopPrice"] ?? jObject["stopPrice"]), TrailingAmount = SafeDecimalValueOrDefault(jObject["TrailingAmount"] ?? jObject["trailingAmount"]), - TrailingAsPercentage = jObject["TrailingAsPercentage"]?.Value() ?? jObject["trailingAsPercentage"]?.Value() ?? default(bool) + // the api documents this flag as 'trailingPercentage', lean serializes it as 'trailingAsPercentage' + TrailingAsPercentage = SafeBooleanValueOrDefault(jObject, "trailingAsPercentage", "trailingPercentage") }; break; @@ -289,7 +290,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 = SafeBooleanValueOrDefault(jObject, "triggerTouched") }; break; @@ -408,5 +410,23 @@ private static decimal SafeDecimalValueOrDefault(JToken token) { return token == null ? default : SafeDecimalValue(token); } + + /// + /// Gets the boolean value of the first of the given camel case property names present in the object, + /// also trying each name in pascal case, or false if none of them is present + /// + private static bool SafeBooleanValueOrDefault(JObject jObject, params string[] camelCaseNames) + { + foreach (var name in camelCaseNames) + { + var token = jObject[name] ?? jObject[char.ToUpperInvariant(name[0]) + name.Substring(1)]; + if (token != null && token.Type != JTokenType.Null) + { + return token.Value(); + } + } + + return default; + } } } diff --git a/Tests/Api/LiveTradingTests.cs b/Tests/Api/LiveTradingTests.cs index 5efc94c7b22e..6ce41c5847db 100644 --- a/Tests/Api/LiveTradingTests.cs +++ b/Tests/Api/LiveTradingTests.cs @@ -797,7 +797,7 @@ public void ReadLiveOrders() // Wait to receive the orders var readLiveOrders = WaitForReadLiveOrdersResponse(projectId, 60 * 5); - Assert.GreaterOrEqual(readLiveOrders.Length, 1); + Assert.GreaterOrEqual(readLiveOrders.Length, readLiveOrders.Orders.Count); Assert.IsTrue(readLiveOrders.Orders.Any()); Assert.AreEqual(Symbols.SPY, readLiveOrders.Orders.First().Symbol); diff --git a/Tests/Api/OrdersTests.cs b/Tests/Api/OrdersTests.cs new file mode 100644 index 000000000000..21a5182dd096 --- /dev/null +++ b/Tests/Api/OrdersTests.cs @@ -0,0 +1,479 @@ +/* + * 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; +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, + ""trailingPercentage"": 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, 100)] + [TestCase(250, 350)] + 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: 101)); + } + + [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)); + } + + [Test] + public void ReadLiveLogsRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 251)); + } + + [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()); + } + + [TestCase(0, 250)] + [TestCase(500, 750)] + public void ReadLiveLogsDefaultsTheEndLineToAFullWindow(int startLine, int expectedEndLine) + { + using var server = new StubApiServer(@"{ ""logs"": [], ""length"": 0, ""success"": true }"); + 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()); + } + + private 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 + /// + private 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()); + } + } + + private 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); + } + } + } +} diff --git a/Tests/Api/ProjectTests.cs b/Tests/Api/ProjectTests.cs index e1ade48ac957..9af4198a0b3e 100644 --- a/Tests/Api/ProjectTests.cs +++ b/Tests/Api/ProjectTests.cs @@ -335,7 +335,7 @@ 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.GreaterOrEqual(backtestOrdersRead.Length, 1); + Assert.GreaterOrEqual(backtestOrdersRead.Length, backtestOrdersRead.Orders.Count); Assert.IsTrue(backtestOrdersRead.Orders.Any()); Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.Orders.First().Symbol.Value); @@ -464,7 +464,7 @@ public void ReadBacktestOrdersReportAndChart() stringRepresentation = backtestOrder.ToString(); Assert.IsTrue(ApiTestBase.IsValidJson(stringRepresentation)); } - Assert.GreaterOrEqual(backtestOrdersRead.Length, 1); + Assert.GreaterOrEqual(backtestOrdersRead.Length, backtestOrdersRead.Orders.Count); Assert.IsTrue(backtestOrdersRead.Orders.Any()); Assert.AreEqual(Symbols.SPY.Value, backtestOrdersRead.Orders.First().Symbol.Value); diff --git a/Tests/Api/ReadOrdersTests.cs b/Tests/Api/ReadOrdersTests.cs deleted file mode 100644 index fc1fdcf082a9..000000000000 --- a/Tests/Api/ReadOrdersTests.cs +++ /dev/null @@ -1,201 +0,0 @@ -/* - * 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 Newtonsoft.Json.Linq; -using NUnit.Framework; -using QuantConnect.Api; -using QuantConnect.Orders; -using QuantConnect.Util; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; - -namespace QuantConnect.Tests.API -{ - [TestFixture] - public class ReadOrdersTests - { - private StubApiServer _server; - private Api.Api _apiClient; - - [SetUp] - public void SetUp() - { - _server = new StubApiServer(); - _apiClient = new StubbedApi(_server.BaseUrl); - _apiClient.Initialize(123, "token", ""); - } - - [TearDown] - public void TearDown() - { - _apiClient.DisposeSafely(); - _server.DisposeSafely(); - } - - [Test] - public void ReadBacktestOrdersThrowsWhenWindowIsTooLarge() - { - Assert.Throws(() => _apiClient.ReadBacktestOrders(1, "id", 0, 101)); - Assert.IsNull(_server.LastRequestBody); - } - - [Test] - public void ReadLiveOrdersThrowsWhenWindowIsTooLarge() - { - Assert.Throws(() => _apiClient.ReadLiveOrders(1, 0, 101)); - Assert.IsNull(_server.LastRequestBody); - } - - [Test] - public void ReadBacktestOrdersAcceptsMaximumWindow() - { - Assert.That(() => _apiClient.ReadBacktestOrders(1, "id", 0, 100), Throws.Nothing); - - var payload = JObject.Parse(_server.LastRequestBody); - Assert.AreEqual(0, payload["start"].Value()); - Assert.AreEqual(100, payload["end"].Value()); - } - - [Test] - public void ReadLiveOrdersAcceptsMaximumWindow() - { - Assert.That(() => _apiClient.ReadLiveOrders(1, 0, 100), Throws.Nothing); - - var payload = JObject.Parse(_server.LastRequestBody); - Assert.AreEqual(0, payload["start"].Value()); - Assert.AreEqual(100, payload["end"].Value()); - } - - [Test] - public void ReadBacktestOrdersDefaultsEndToStartPlusOneHundred() - { - _apiClient.ReadBacktestOrders(1, "id", 500); - - var payload = JObject.Parse(_server.LastRequestBody); - Assert.AreEqual(500, payload["start"].Value()); - Assert.AreEqual(600, payload["end"].Value()); - } - - [Test] - public void ReadLiveOrdersDefaultsEndToStartPlusOneHundred() - { - _apiClient.ReadLiveOrders(1, 500); - - var payload = JObject.Parse(_server.LastRequestBody); - Assert.AreEqual(500, payload["start"].Value()); - Assert.AreEqual(600, payload["end"].Value()); - } - - [Test] - public void ReadBacktestOrdersReturnsTheTotalOrderCount() - { - var response = _apiClient.ReadBacktestOrders(1, "id"); - - Assert.AreEqual(1234, response.Length); - Assert.IsEmpty(response.Orders); - } - - private 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); - } - } - - /// - /// Local HTTP server that captures the request body and replies with a canned orders response - /// - private class StubApiServer : IDisposable - { - private readonly HttpListener _listener; - private readonly Thread _thread; - - public string BaseUrl { get; } - - public string LastRequestBody { get; private set; } - - public StubApiServer() - { - BaseUrl = $"http://localhost:{GetAvailablePort()}/"; - _listener = new HttpListener(); - _listener.Prefixes.Add(BaseUrl); - _listener.Start(); - - _thread = new Thread(Listen) { IsBackground = true }; - _thread.Start(); - } - - public void Dispose() - { - _listener.Stop(); - _listener.Close(); - _thread.Join(TimeSpan.FromSeconds(5)); - } - - private void Listen() - { - while (_listener.IsListening) - { - HttpListenerContext context; - try - { - context = _listener.GetContext(); - } - catch (Exception) - { - // the listener was stopped - return; - } - - using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8)) - { - LastRequestBody = reader.ReadToEnd(); - } - - var buffer = Encoding.UTF8.GetBytes(EmptyOrdersResponse); - context.Response.ContentType = "application/json"; - context.Response.ContentLength64 = buffer.Length; - context.Response.OutputStream.Write(buffer, 0, buffer.Length); - context.Response.Close(); - } - } - - private static int GetAvailablePort() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - } - - private const string EmptyOrdersResponse = @"{ ""orders"": [], ""length"": 1234, ""success"": true }"; - } -} From 0cce7d9c341ad3de2b121ec1b03b0fbbffdd49af Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 17 Sep 2026 18:05:02 -0400 Subject: [PATCH 03/10] Add the backtest log api method and the documented query and deployment filters to the live logs method ReadBacktestLog posts to backtests/read/log with the optional query keyword filter and the shared paging window guard. ReadLiveLogs now sends the documented query and deploymentLogs fields. The loopback stub server moves out of the orders test fixture so the new logs fixture can share it. --- Api/Api.cs | 32 ++++++- Common/Api/BacktestLog.cs | 35 +++++++ Common/Api/LiveLog.cs | 4 +- Tests/Api/LogsTests.cs | 176 +++++++++++++++++++++++++++++++++++ Tests/Api/OrdersTests.cs | 183 ------------------------------------- Tests/Api/StubApiServer.cs | 183 +++++++++++++++++++++++++++++++++++++ 6 files changed, 427 insertions(+), 186 deletions(-) create mode 100644 Common/Api/BacktestLog.cs create mode 100644 Tests/Api/LogsTests.cs create mode 100644 Tests/Api/StubApiServer.cs diff --git a/Api/Api.cs b/Api/Api.cs index 9102bb334ec0..1a4452a75f5f 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -552,6 +552,25 @@ public InsightResponse ReadBacktestInsights(int projectId, string backtestId, in 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 + /// Optional keyword to filter the log lines, null to return every line + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read. Note that end - start must not exceed 250. + /// Defaults to a full window starting at + /// 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, string query = null, int start = 0, int end = 0) + { + 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. /// @@ -760,9 +779,12 @@ public RestResponse BroadcastLiveCommand(string organizationId, int? excludeProj /// Start line (inclusive) of logs to read /// End line (exclusive) of logs to read. Note that endLine - startLine must not exceed 250. /// 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 /// The requested window is wider than the documented maximum - public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0, int endLine = 0) + public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0, int endLine = 0, string query = null, + bool deploymentLogs = false) { endLine = ResolveWindowEnd(startLine, endLine, MaxLogLinesWindow, "log lines"); @@ -775,10 +797,18 @@ public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0 algorithmId, startLine, endLine, + deploymentLogs, + query, }); return result; } + // Explicit so the added optional arguments don't change the arity IApi declares + LiveLog IApi.ReadLiveLogs(int projectId, string algorithmId, int startLine, int endLine) + { + return ReadLiveLogs(projectId, algorithmId, startLine, endLine); + } + /// /// Returns a chart object from a live algorithm /// 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/Tests/Api/LogsTests.cs b/Tests/Api/LogsTests.cs new file mode 100644 index 000000000000..5de7d667fdb6 --- /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", "Error", 10, 60); + + 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, 250)] + [TestCase(500, 750)] + 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, 250)] + [TestCase(500, 750)] + 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: 251)); + } + + [Test] + public void ReadLiveLogsRejectsAWindowWiderThanTheDocumentedMaximum() + { + using var api = new Api.Api(); + api.Initialize(0, "token", Globals.DataFolder); + + Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 251)); + } + + [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 index 21a5182dd096..e418957a31e1 100644 --- a/Tests/Api/OrdersTests.cs +++ b/Tests/Api/OrdersTests.cs @@ -14,15 +14,7 @@ */ 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; @@ -287,15 +279,6 @@ public void ReadLiveInsightsRejectsAWindowWiderThanTheDocumentedMaximum() Assert.Throws(() => api.ReadLiveInsights(23456789, 0, 101)); } - [Test] - public void ReadLiveLogsRejectsAWindowWiderThanTheDocumentedMaximum() - { - using var api = new Api.Api(); - api.Initialize(0, "token", Globals.DataFolder); - - Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 251)); - } - [TestCase(0, 100)] [TestCase(250, 350)] public void ReadBacktestInsightsDefaultsTheEndIndexToAFullWindow(int start, int expectedEnd) @@ -309,171 +292,5 @@ public void ReadBacktestInsightsDefaultsTheEndIndexToAFullWindow(int start, int Assert.AreEqual(start, body["start"].Value()); Assert.AreEqual(expectedEnd, body["end"].Value()); } - - [TestCase(0, 250)] - [TestCase(500, 750)] - public void ReadLiveLogsDefaultsTheEndLineToAFullWindow(int startLine, int expectedEndLine) - { - using var server = new StubApiServer(@"{ ""logs"": [], ""length"": 0, ""success"": true }"); - 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()); - } - - private 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 - /// - private 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()); - } - } - - private 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); - } - } } } 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); + } + } +} From 8eb056082d581ad1ea623cc65b7875127ce4ea0e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 08:29:59 -0400 Subject: [PATCH 04/10] Declare the public api endpoint methods on IApi Adds ReadBacktestOrders, ReadLiveOrders, ReadBacktestLog, UpdateBacktestTags, CreateLiveCommand and ListObjectStore to the interface, widens ReadLiveLogs to the query and deploymentLogs arguments, and drops the explicit forwarding implementation that kept the old arity. --- Api/Api.cs | 6 ---- Common/Interfaces/IApi.cs | 67 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/Api/Api.cs b/Api/Api.cs index 1a4452a75f5f..a8e4844ea6df 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -803,12 +803,6 @@ public LiveLog ReadLiveLogs(int projectId, string algorithmId, int startLine = 0 return result; } - // Explicit so the added optional arguments don't change the arity IApi declares - LiveLog IApi.ReadLiveLogs(int projectId, string algorithmId, int startLine, int endLine) - { - return ReadLiveLogs(projectId, algorithmId, startLine, endLine); - } - /// /// Returns a chart object from a live algorithm /// diff --git a/Common/Interfaces/IApi.cs b/Common/Interfaces/IApi.cs index 1d1a5961267e..8e1cdfc4a0c1 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 + /// Keyword to filter the log lines + /// Start line (inclusive) of logs to read + /// End line (exclusive) of logs to read + /// with the requested log lines and the total log line count + BacktestLog ReadBacktestLog(int projectId, string backtestId, string query = null, int start = 0, int end = 0); + #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 /// From 685a3c1f02b984133c9231ae881cc67691d7eb57 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 11:21:33 -0400 Subject: [PATCH 05/10] Raise the live orders paging window cap to the documented 1000 --- Api/Api.cs | 4 ++-- Tests/Api/OrdersTests.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Api/Api.cs b/Api/Api.cs index a8e4844ea6df..b80a6a1212a1 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -44,7 +44,7 @@ public class Api : IApi, IDownloadProvider { // Widest start/end window each paging endpoint documents private const int MaxBacktestOrdersWindow = 100; - private const int MaxLiveOrdersWindow = 100; + private const int MaxLiveOrdersWindow = 1000; private const int MaxInsightsWindow = 100; private const int MaxLogLinesWindow = 250; @@ -706,7 +706,7 @@ public PortfolioResponse ReadLivePortfolio(int projectId) /// 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 100. + /// 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 with the requested orders and the total order count diff --git a/Tests/Api/OrdersTests.cs b/Tests/Api/OrdersTests.cs index e418957a31e1..a80e88fdeebc 100644 --- a/Tests/Api/OrdersTests.cs +++ b/Tests/Api/OrdersTests.cs @@ -159,8 +159,8 @@ public void ReadBacktestOrdersDefaultsTheEndIndexToAFullWindow(int start, int ex Assert.AreEqual(expectedEnd, body["end"].Value()); } - [TestCase(0, 100)] - [TestCase(250, 350)] + [TestCase(0, 1000)] + [TestCase(250, 1250)] public void ReadLiveOrdersDefaultsTheEndIndexToAFullWindow(int start, int expectedEnd) { using var server = new StubApiServer(SuccessfulOrdersResponse); @@ -188,7 +188,7 @@ public void ReadLiveOrdersRejectsAWindowWiderThanTheDocumentedMaximum() using var api = new Api.Api(); api.Initialize(0, "token", Globals.DataFolder); - Assert.Throws(() => api.ReadLiveOrders(23456789, start: 0, end: 101)); + Assert.Throws(() => api.ReadLiveOrders(23456789, start: 0, end: 1001)); } [Test] From 5631466851bde6adbab4a6fdeab4cb43b3b1c6b7 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 14:16:22 -0400 Subject: [PATCH 06/10] Cap the backtest log window at 200 lines and add api tests that page and search the backtest log The backtest log endpoint accepts windows of up to 200 lines, unlike the live logs endpoint's 250, so it gets its own cap. The new credentialed tests run an algorithm that logs a numbered line per bar, page through the whole log with different window sizes, and search it with the query filter. --- Api/Api.cs | 5 +- Tests/Api/LogsTests.cs | 6 +- Tests/Api/ProjectTests.cs | 147 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/Api/Api.cs b/Api/Api.cs index b80a6a1212a1..eecc66234408 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -47,6 +47,7 @@ public class Api : IApi, IDownloadProvider private const int MaxLiveOrdersWindow = 1000; private const int MaxInsightsWindow = 100; private const int MaxLogLinesWindow = 250; + private const int MaxBacktestLogLinesWindow = 200; private readonly BlockingCollection> _clientPool; private string _dataFolder; @@ -559,13 +560,13 @@ public InsightResponse ReadBacktestInsights(int projectId, string backtestId, in /// Id of the backtest from which to read the logs /// Optional keyword to filter the log lines, null to return every line /// Start line (inclusive) of logs to read - /// End line (exclusive) of logs to read. Note that end - start must not exceed 250. + /// End line (exclusive) of logs to read. Note that end - start must not exceed 200. /// Defaults to a full window starting at /// 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, string query = null, int start = 0, int end = 0) { - end = ResolveWindowEnd(start, end, MaxLogLinesWindow, "log lines"); + end = ResolveWindowEnd(start, end, MaxBacktestLogLinesWindow, "log lines"); TryJsonPost("backtests/read/log", out BacktestLog result, new { projectId, backtestId, start, end, query }); return result; diff --git a/Tests/Api/LogsTests.cs b/Tests/Api/LogsTests.cs index 5de7d667fdb6..469e34a97c9e 100644 --- a/Tests/Api/LogsTests.cs +++ b/Tests/Api/LogsTests.cs @@ -87,8 +87,8 @@ public void ReadLiveLogsRequestsEveryDeploymentAndEveryLineByDefault() Assert.AreEqual(JTokenType.Null, body["query"].Type); } - [TestCase(0, 250)] - [TestCase(500, 750)] + [TestCase(0, 200)] + [TestCase(500, 700)] public void ReadBacktestLogDefaultsTheEndLineToAFullWindow(int start, int expectedEnd) { using var server = new StubApiServer(SuccessfulBacktestLogResponse); @@ -121,7 +121,7 @@ public void ReadBacktestLogRejectsAWindowWiderThanTheDocumentedMaximum() using var api = new Api.Api(); api.Initialize(0, "token", Globals.DataFolder); - Assert.Throws(() => api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start: 0, end: 251)); + Assert.Throws(() => api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", start: 0, end: 201)); } [Test] diff --git a/Tests/Api/ProjectTests.cs b/Tests/Api/ProjectTests.cs index 9af4198a0b3e..dee40fe78024 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; @@ -62,6 +64,34 @@ public override void OnData(Slice slice) } } } +}"; + /// + /// 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() { @@ -425,6 +455,123 @@ public void ReadBacktestOrdersPaginatesThroughAllOrders(int windowSize) 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, query, lines.Count, lines.Count + windowSize); + 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() { From d72ad15e3af7c6a6328e660e4b18458265eb5a6b Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 14:23:11 -0400 Subject: [PATCH 07/10] Cap the live logs window at 200 lines as well --- Api/Api.cs | 7 +++---- Tests/Api/LogsTests.cs | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Api/Api.cs b/Api/Api.cs index eecc66234408..297ae900170b 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -46,8 +46,7 @@ public class Api : IApi, IDownloadProvider private const int MaxBacktestOrdersWindow = 100; private const int MaxLiveOrdersWindow = 1000; private const int MaxInsightsWindow = 100; - private const int MaxLogLinesWindow = 250; - private const int MaxBacktestLogLinesWindow = 200; + private const int MaxLogLinesWindow = 200; private readonly BlockingCollection> _clientPool; private string _dataFolder; @@ -566,7 +565,7 @@ public InsightResponse ReadBacktestInsights(int projectId, string backtestId, in /// The requested window is wider than the documented maximum public BacktestLog ReadBacktestLog(int projectId, string backtestId, string query = null, int start = 0, int end = 0) { - end = ResolveWindowEnd(start, end, MaxBacktestLogLinesWindow, "log lines"); + end = ResolveWindowEnd(start, end, MaxLogLinesWindow, "log lines"); TryJsonPost("backtests/read/log", out BacktestLog result, new { projectId, backtestId, start, end, query }); return result; @@ -778,7 +777,7 @@ public RestResponse BroadcastLiveCommand(string organizationId, int? excludeProj /// Project Id of the live running algorithm /// Algorithm Id of the live running algorithm /// Start line (inclusive) of logs to read - /// End line (exclusive) of logs to read. Note that endLine - startLine must not exceed 250. + /// 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 diff --git a/Tests/Api/LogsTests.cs b/Tests/Api/LogsTests.cs index 469e34a97c9e..e75306189b0a 100644 --- a/Tests/Api/LogsTests.cs +++ b/Tests/Api/LogsTests.cs @@ -101,8 +101,8 @@ public void ReadBacktestLogDefaultsTheEndLineToAFullWindow(int start, int expect Assert.AreEqual(expectedEnd, body["end"].Value()); } - [TestCase(0, 250)] - [TestCase(500, 750)] + [TestCase(0, 200)] + [TestCase(500, 700)] public void ReadLiveLogsDefaultsTheEndLineToAFullWindow(int startLine, int expectedEndLine) { using var server = new StubApiServer(SuccessfulLiveLogResponse); @@ -130,7 +130,7 @@ public void ReadLiveLogsRejectsAWindowWiderThanTheDocumentedMaximum() using var api = new Api.Api(); api.Initialize(0, "token", Globals.DataFolder); - Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 251)); + Assert.Throws(() => api.ReadLiveLogs(23456789, "L-6e9d8a78f5af89d401f630585be90e43", 0, 201)); } [Test] From 5a46c770552db7166f04c6b0f9892598a1fa8806 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 14:31:53 -0400 Subject: [PATCH 08/10] Take the backtest log query after the line range so both log methods share the same argument order --- Api/Api.cs | 4 ++-- Common/Interfaces/IApi.cs | 4 ++-- Tests/Api/LogsTests.cs | 2 +- Tests/Api/ProjectTests.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Api/Api.cs b/Api/Api.cs index 297ae900170b..8d01a2308183 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -557,13 +557,13 @@ public InsightResponse ReadBacktestInsights(int projectId, string backtestId, in /// /// Id of the project from which to read the backtest /// Id of the backtest from which to read the logs - /// Optional keyword to filter the log lines, null to return every line /// 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, string query = null, int start = 0, int end = 0) + public BacktestLog ReadBacktestLog(int projectId, string backtestId, int start = 0, int end = 0, string query = null) { end = ResolveWindowEnd(start, end, MaxLogLinesWindow, "log lines"); diff --git a/Common/Interfaces/IApi.cs b/Common/Interfaces/IApi.cs index 8e1cdfc4a0c1..f4301e398e5c 100644 --- a/Common/Interfaces/IApi.cs +++ b/Common/Interfaces/IApi.cs @@ -226,11 +226,11 @@ public interface IApi : IDisposable /// /// Id of the project from which to read the backtest /// Id of the backtest from which to read the logs - /// Keyword to filter the log lines /// 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, string query = null, int start = 0, int end = 0); + BacktestLog ReadBacktestLog(int projectId, string backtestId, int start = 0, int end = 0, string query = null); #pragma warning disable CS1574 /// diff --git a/Tests/Api/LogsTests.cs b/Tests/Api/LogsTests.cs index e75306189b0a..d8fe9f6aa3d1 100644 --- a/Tests/Api/LogsTests.cs +++ b/Tests/Api/LogsTests.cs @@ -44,7 +44,7 @@ public void ReadBacktestLogSendsTheDocumentedRequest() using var server = new StubApiServer(SuccessfulBacktestLogResponse); using var api = server.CreateApi(); - api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", "Error", 10, 60); + api.ReadBacktestLog(23456789, "26c7bb06b8487cff1c7b3c44652b30f1", 10, 60, "Error"); var request = server.GetSingleRequest(); Assert.AreEqual("/backtests/read/log", request.Path); diff --git a/Tests/Api/ProjectTests.cs b/Tests/Api/ProjectTests.cs index dee40fe78024..2f96b8d20c89 100644 --- a/Tests/Api/ProjectTests.cs +++ b/Tests/Api/ProjectTests.cs @@ -547,7 +547,7 @@ private List ReadAllBacktestLogLines(int projectId, string backtestId, s var pages = 0; do { - var page = ApiClient.ReadBacktestLog(projectId, backtestId, query, lines.Count, lines.Count + windowSize); + 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++; From b7502651100125ae4b6f7eeab8817092775506a7 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 16:30:21 -0400 Subject: [PATCH 09/10] Read the trailing stop percentage flag only under the name the api actually sends --- Common/Orders/OrderJsonConverter.cs | 3 +-- Tests/Api/OrdersTests.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs index 353e48dbed22..7b089f04a3dc 100644 --- a/Common/Orders/OrderJsonConverter.cs +++ b/Common/Orders/OrderJsonConverter.cs @@ -281,8 +281,7 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { StopPrice = SafeDecimalValueOrDefault(jObject["StopPrice"] ?? jObject["stopPrice"]), TrailingAmount = SafeDecimalValueOrDefault(jObject["TrailingAmount"] ?? jObject["trailingAmount"]), - // the api documents this flag as 'trailingPercentage', lean serializes it as 'trailingAsPercentage' - TrailingAsPercentage = SafeBooleanValueOrDefault(jObject, "trailingAsPercentage", "trailingPercentage") + TrailingAsPercentage = SafeBooleanValueOrDefault(jObject, "trailingAsPercentage") }; break; diff --git a/Tests/Api/OrdersTests.cs b/Tests/Api/OrdersTests.cs index a80e88fdeebc..01048aa525f0 100644 --- a/Tests/Api/OrdersTests.cs +++ b/Tests/Api/OrdersTests.cs @@ -86,7 +86,7 @@ public class OrdersTests ""symbol"": { ""value"": ""SPY"", ""id"": ""SPY R735QTJ8XC9X"", ""permtick"": ""SPY"" }, ""stopPrice"": 143.0, ""trailingAmount"": 0.05, - ""trailingPercentage"": true, + ""trailingAsPercentage"": true, ""price"": 144.0, ""time"": ""2013-10-07T13:33:00Z"", ""quantity"": -5.0, From 0b2262c68ea81e9e8b264d2ec3ba010456056f71 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 18 Sep 2026 16:37:19 -0400 Subject: [PATCH 10/10] Read the trigger touched flag inline and drop the boolean helper the alias no longer needs --- Common/Orders/OrderJsonConverter.cs | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs index 7b089f04a3dc..88af09c592fa 100644 --- a/Common/Orders/OrderJsonConverter.cs +++ b/Common/Orders/OrderJsonConverter.cs @@ -271,7 +271,7 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { LimitPrice = SafeDecimalValueOrDefault(jObject["LimitPrice"] ?? jObject["limitPrice"]), StopPrice = SafeDecimalValueOrDefault(jObject["stopPrice"] ?? jObject["StopPrice"]), - StopTriggered = SafeBooleanValueOrDefault(jObject, "stopTriggered"), + StopTriggered = jObject["StopTriggered"]?.Value() ?? jObject["stopTriggered"]?.Value() ?? default(bool), StopTriggeredTime = jObject["StopTriggeredTime"]?.Value() ?? jObject["stopTriggeredTime"]?.Value() }; break; @@ -281,7 +281,7 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { StopPrice = SafeDecimalValueOrDefault(jObject["StopPrice"] ?? jObject["stopPrice"]), TrailingAmount = SafeDecimalValueOrDefault(jObject["TrailingAmount"] ?? jObject["trailingAmount"]), - TrailingAsPercentage = SafeBooleanValueOrDefault(jObject, "trailingAsPercentage") + TrailingAsPercentage = jObject["TrailingAsPercentage"]?.Value() ?? jObject["trailingAsPercentage"]?.Value() ?? default(bool) }; break; @@ -290,7 +290,7 @@ private static Order CreateOrder(OrderType orderType, JObject jObject) { LimitPrice = SafeDecimalValueOrDefault(jObject["LimitPrice"] ?? jObject["limitPrice"]), TriggerPrice = SafeDecimalValueOrDefault(jObject["TriggerPrice"] ?? jObject["triggerPrice"]), - TriggerTouched = SafeBooleanValueOrDefault(jObject, "triggerTouched") + TriggerTouched = jObject["TriggerTouched"]?.Value() ?? jObject["triggerTouched"]?.Value() ?? default(bool) }; break; @@ -409,23 +409,5 @@ private static decimal SafeDecimalValueOrDefault(JToken token) { return token == null ? default : SafeDecimalValue(token); } - - /// - /// Gets the boolean value of the first of the given camel case property names present in the object, - /// also trying each name in pascal case, or false if none of them is present - /// - private static bool SafeBooleanValueOrDefault(JObject jObject, params string[] camelCaseNames) - { - foreach (var name in camelCaseNames) - { - var token = jObject[name] ?? jObject[char.ToUpperInvariant(name[0]) + name.Substring(1)]; - if (token != null && token.Type != JTokenType.Null) - { - return token.Value(); - } - } - - return default; - } } }