/*
* 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.Configuration;
using QuantConnect.Interfaces;
using QuantConnect.Logging;
using QuantConnect.Util;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace QuantConnect.Tests
{
///
/// Regression testing of C# and python notebooks via the research regression templates implemented from
///
///
/// Papermill assumes the notebook are present in output directory
/// Assumes C# templates are in Tests/Research/RegressionTemplates to update the expected output
/// Requires "research-regression-update-output" to be true in config to update results in C# templates
/// Run in the GH CI through
[TestFixture, Category("ResearchRegressionTests")]
public class ResearchRegressionTests
{
// Update in config.json when template expected result needs to be updated
private static readonly bool _updateResearchRegressionOutput = Config.GetBool("research-regression-update-output", false);
[Test, TestCaseSource(nameof(GetResearchRegressionTestParameters))]
public void ResearchRegression(ResearchRegressionTestParameters parameters)
{
var actualOutput = RunResearchNotebookAndGetOutput(parameters.NotebookPath, parameters.NotebookOutputPath, Directory.GetCurrentDirectory(), out Process process);
// Update expected result if required.
if (_updateResearchRegressionOutput)
{
UpdateResearchRegressionOutputInSourceFile(parameters.NotebookName, actualOutput);
}
var actualCells = JToken.Parse(CleanDispensableEscapeCharacters(actualOutput))["cells"];
var expectedCells = JToken.Parse(parameters.ExpectedOutput)["cells"];
var expectedAndActualCells = expectedCells.Zip(actualCells, (e, a) => new { Expected = e, Actual = a });
foreach (var cell in expectedAndActualCells)
{
// Assert Notebook Cell Input
Assert.AreEqual(cell.Expected["source"].ToString(), cell.Actual["source"].ToString());
// Assert Notebook Cell Output
var expectedCellOutputs = cell.Expected["outputs"];
var actualCellOutputs = cell.Actual["outputs"];
if (expectedCellOutputs != null)
{
if (actualCellOutputs == null)
{
Assert.Fail("Shouldn't be null when expected is not null");
}
// Iterate over all outputs for the given notebook cell
var expectedCellOutputsAndActualCellOutputs = expectedCellOutputs.Zip(actualCellOutputs, (e, a) => new { Expected = e, Actual = a });
foreach (var cellOutputsItem in expectedCellOutputsAndActualCellOutputs)
{
if (cellOutputsItem.Expected["data"] != null && cellOutputsItem.Expected["data"]["text/html"] != null)
{
continue;
}
else if (cellOutputsItem.Expected["name"]?.ToString() == "stdout" && cellOutputsItem.Expected["text"] != null)
{
if (!IsDeterministic(cellOutputsItem.Expected["text"].ToString()))
{
continue;
}
}
Assert.AreEqual(cellOutputsItem.Expected.ToString(), cellOutputsItem.Actual.ToString());
}
}
}
// Assert if the notebook was run by papermill
Assert.AreEqual(0, process.ExitCode);
process.Dispose();
}
public class ResearchRegressionTestParameters
{
public string NotebookName { get; init; }
public string NotebookPath { get; init; }
public string ExpectedOutput { get; init; }
public string NotebookOutputPath { get; init; }
public ResearchRegressionTestParameters(string notebookName, string notebookPath, string expectedOutput)
{
NotebookName = notebookName;
NotebookPath = notebookPath;
ExpectedOutput = expectedOutput;
NotebookOutputPath = notebookPath.Split(".")[0] + "-output" + ".ipynb";
}
}
private static void UpdateResearchRegressionOutputInSourceFile(string templateName, string expectedOutput)
{
var templatePath = Directory.EnumerateFiles("../../Research/RegressionTemplates", $"*{templateName}.cs", SearchOption.AllDirectories).Single();
var file = File.ReadAllLines(templatePath).ToList().GetEnumerator();
var lines = new List();
while (file.MoveNext())
{
var line = file.Current;
if (line == null)
{
continue;
}
if (line.Contains("public string ExpectedOutput =>"))
{
// Add the line as it assumes the expected output starts from next line
lines.Add(line);
// Escape the "escape" sequence for correct parse back
expectedOutput = expectedOutput
.Replace("\\\\", "\\\\\\\\")
.Replace("\\\"", "\\\\\"")
.Replace("\"", "\\\"");
expectedOutput = CleanDispensableEscapeCharacters(expectedOutput);
// Split string in multiple lines
List expectedOutputLines = new();
if (!expectedOutput.IsNullOrEmpty())
{
expectedOutputLines = SplitIntoMultipleLines(expectedOutput, 150);
}
else
{
expectedOutputLines.Add("\"\";");
}
lines.AddRange(expectedOutputLines);
// now we skip the old expected ouptut in file
while (file.MoveNext())
{
line = file.Current;
if (line != null && line.StartsWith(" }"))
{
lines.Add(line);
break;
}
}
}
else
{
lines.Add(line);
}
}
file.DisposeSafely();
File.WriteAllLines(templatePath, lines);
}
///
/// Split long string into multiple lines
///
/// Doesn't break a line if it ends with "\"
private static List SplitIntoMultipleLines(string longInput, int maxChunkSize)
{
List resultLines = new();
for (int i = 0; i < longInput.Length;)
{
var chunk = longInput.Substring(i, Math.Min(maxChunkSize, longInput.Length - i));
i += maxChunkSize;
// Can't split if last charcter is "\"
while (chunk.EndsWith("\\") && i < longInput.Length)
{
chunk += longInput[i];
i++;
}
resultLines.Add($" \"{chunk}\" +");
}
// use ; for endline
var lastLine = resultLines.Last();
resultLines[resultLines.Count - 1] = lastLine.Remove(lastLine.Length - 2) + ";";
return resultLines;
}
private static string CleanDispensableEscapeCharacters(string json)
{
json = json
.Replace("\\n", string.Empty)
.Replace("\n", string.Empty)
.Replace("\\r", string.Empty)
.Replace("\r", string.Empty)
.Replace("\\t", string.Empty)
.Replace("\t", string.Empty);
return json;
}
private static string RunResearchNotebookAndGetOutput(string notebookPath, string notebookoutputPath, string workingDirectoryForNotebook, out Process process)
{
var args = $"-m papermill \"{notebookPath}\" \"{notebookoutputPath}\" --log-output --cwd {workingDirectoryForNotebook}";
TestProcess.RunPythonProcess(args, out process);
return File.ReadAllText(notebookoutputPath);
}
private static bool IsDeterministic(string input)
{
Regex rgxDateTime = new(@"(\d{4})(\d{2})(\d{2}) (\d{2}):(\d{2}):(\d{2}).(\d{3}) TRACE::");
if (input.Contains("Initialize.csx"))
{
return false;
}
else if (input.Contains("Runtime.Initialize():"))
{
return false;
}
else if (input.Contains("PythonEngine.Initialize():"))
{
return false;
}
else if (rgxDateTime.IsMatch(input))
{
return false;
}
return true;
}
private static ResearchRegressionTestParameters[] GetResearchTemplates()
{
return Composer.Instance.GetExportedTypes()
.Where(type =>
!type.IsAbstract &&
type.GetConstructor(Array.Empty()) != null)
.Select(type => new
{
instance = (IRegressionResearchDefinition)Activator.CreateInstance(type),
name = type.Name,
path = Path.GetFullPath(Path.Combine(Path.Combine(type.Assembly.Location, @"../Research/RegressionTemplates"), type.Name + ".ipynb"))
})
.Select(tempObj => new ResearchRegressionTestParameters(tempObj.name, tempObj.path, tempObj.instance.ExpectedOutput))
.ToArray();
}
private static TestCaseData[] GetResearchRegressionTestParameters()
{
// since these are static test cases, they are executed before test setup
AssemblyInitialize.AdjustCurrentDirectory();
var result = GetResearchTemplates()
.OrderBy(x => x.NotebookName)
// generate test cases from test parameters
.Select(x => new TestCaseData(x).SetName(x.NotebookName))
.ToArray();
return result; ;
}
}
}