diff --git a/Algorithm.CSharp/CallbackCommandRegressionAlgorithm.cs b/Algorithm.CSharp/CallbackCommandRegressionAlgorithm.cs new file mode 100644 index 000000000000..db31b1a8dcdd --- /dev/null +++ b/Algorithm.CSharp/CallbackCommandRegressionAlgorithm.cs @@ -0,0 +1,147 @@ +/* + * 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 QuantConnect.Commands; +using QuantConnect.Interfaces; +using System.Collections.Generic; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of different callback commands call + /// + public class CallbackCommandRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + + AddEquity("SPY"); + AddEquity("BAC"); + AddEquity("IBM"); + AddCommand(); + AddCommand(); + } + + /// + /// Handle generic command callback + /// + public override bool? OnCommand(dynamic data) + { + Buy(data.Symbol, data.parameters["quantity"]); + return true; + } + + private class VoidCommand : Command + { + public DateTime TargetTime { get; set; } + public string[] Target { get; set; } + public decimal Quantity { get; set; } + public Dictionary Parameters { get; set; } + public override bool? Run(IAlgorithm algorithm) + { + if (TargetTime != algorithm.Time) + { + return null; + } + + ((QCAlgorithm)algorithm).Order(Target[0], Quantity, tag: Parameters["tag"]); + return null; + } + } + private class BoolCommand : Command + { + public bool? Result { get; set; } + public override bool? Run(IAlgorithm algorithm) + { + var shouldTrade = MyCustomMethod(); + if (shouldTrade.HasValue && shouldTrade.Value) + { + ((QCAlgorithm)algorithm).Buy("IBM", 1); + } + return shouldTrade; + } + + private bool? MyCustomMethod() + { + return Result; + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "1"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "271.453%"}, + {"Drawdown", "2.200%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "101691.92"}, + {"Net Profit", "1.692%"}, + {"Sharpe Ratio", "8.854"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "67.609%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "-0.005"}, + {"Beta", "0.996"}, + {"Annual Standard Deviation", "0.222"}, + {"Annual Variance", "0.049"}, + {"Information Ratio", "-14.565"}, + {"Tracking Error", "0.001"}, + {"Treynor Ratio", "1.97"}, + {"Total Fees", "$3.44"}, + {"Estimated Strategy Capacity", "$56000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "19.93%"}, + {"OrderListHash", "3da9fa60bf95b9ed148b95e02e0cfc9e"} + }; + } +} diff --git a/Algorithm.Python/CallbackCommandRegressionAlgorithm.py b/Algorithm.Python/CallbackCommandRegressionAlgorithm.py new file mode 100644 index 000000000000..2d495bb38b3f --- /dev/null +++ b/Algorithm.Python/CallbackCommandRegressionAlgorithm.py @@ -0,0 +1,73 @@ +# 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. + +from AlgorithmImports import * + +class InvalidCommand(): + variable = 10 + +class VoidCommand(): + quantity = 0 + target = [] + parameters = {} + targettime = None + + def run(self, algo: QCAlgorithm) -> bool | None: + if not self.targettime or self.targettime != algo.time: + return + tag = self.parameters["tag"] + algo.order(self.target[0], self.get_quantity(), tag=tag) + + def get_quantity(self): + return self.quantity + +class BoolCommand(Command): + result = False + + def run(self, algo: QCAlgorithm) -> bool | None: + trade_ibm = self.my_custom_method() + if trade_ibm: + algo.buy("IBM", 1) + return trade_ibm + + def my_custom_method(self): + return self.result + +### +### Regression algorithm asserting the behavior of different callback commands call +### +class CallbackCommandRegressionAlgorithm(QCAlgorithm): + def initialize(self): + '''Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized.''' + + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + + self.add_equity("SPY") + self.add_equity("IBM") + self.add_equity("BAC") + + self.add_command(VoidCommand) + self.add_command(BoolCommand) + + threw_exception = False + try: + self.add_command(InvalidCommand) + except: + threw_exception = True + if not threw_exception: + raise ValueError('InvalidCommand did not throw!') + + def on_command(self, data): + self.buy(data.symbol, data.parameters["quantity"]) + return True # False, None diff --git a/Algorithm/QCAlgorithm.Python.cs b/Algorithm/QCAlgorithm.Python.cs index 0a22a1c9e823..dbca952c68b3 100644 --- a/Algorithm/QCAlgorithm.Python.cs +++ b/Algorithm/QCAlgorithm.Python.cs @@ -31,6 +31,7 @@ using QuantConnect.Util; using QuantConnect.Interfaces; using QuantConnect.Orders; +using QuantConnect.Commands; namespace QuantConnect.Algorithm { @@ -1620,6 +1621,23 @@ public List Liquidate(PyObject symbols, bool asynchronous = false, return Liquidate(symbols.ConvertToSymbolEnumerable(), asynchronous, tag, orderProperties); } + /// + /// Register a command type to be used + /// + /// The command type + public void AddCommand(PyObject type) + { + // create a test instance to validate interface is implemented accurate + var testInstance = new CommandPythonWrapper(type); + + var wrappedType = Extensions.CreateType(type); + _registeredCommands[wrappedType.Name] = (CallbackCommand command) => + { + var commandWrapper = new CommandPythonWrapper(type, command.Payload); + return commandWrapper.Run(this); + }; + } + /// /// Gets indicator base type /// diff --git a/Algorithm/QCAlgorithm.cs b/Algorithm/QCAlgorithm.cs index 56549a44c0d7..c1deaf6f1b24 100644 --- a/Algorithm/QCAlgorithm.cs +++ b/Algorithm/QCAlgorithm.cs @@ -54,6 +54,8 @@ using QuantConnect.Algorithm.Framework.Alphas.Analysis; using QuantConnect.Algorithm.Framework.Portfolio.SignalExports; using Python.Runtime; +using QuantConnect.Commands; +using Newtonsoft.Json; namespace QuantConnect.Algorithm { @@ -115,6 +117,9 @@ public partial class QCAlgorithm : MarshalByRefObject, IAlgorithm private IStatisticsService _statisticsService; private IBrokerageModel _brokerageModel; + private readonly HashSet _oneTimeCommandErrors = new(); + private readonly Dictionary> _registeredCommands = new(StringComparer.InvariantCultureIgnoreCase); + //Error tracking to avoid message flooding: private string _previousDebugMessage = ""; private string _previousErrorMessage = ""; @@ -3385,6 +3390,62 @@ public DataHistory OptionChain(Symbol symbol) return new DataHistory(optionChain, new Lazy(() => PandasConverter.GetDataFrame(optionChain))); } + /// + /// Register a command type to be used + /// + /// The command type + public void AddCommand() where T : Command + { + _registeredCommands[typeof(T).Name] = (CallbackCommand command) => + { + var commandInstance = JsonConvert.DeserializeObject(command.Payload); + return commandInstance.Run(this); + }; + } + + /// + /// Run a callback command instance + /// + /// The callback command instance + /// The command result + public CommandResultPacket RunCommand(CallbackCommand command) + { + bool? result = null; + if (_registeredCommands.TryGetValue(command.Type, out var target)) + { + try + { + result = target.Invoke(command); + } + catch (Exception ex) + { + QuantConnect.Logging.Log.Error(ex); + if (_oneTimeCommandErrors.Add(command.Type)) + { + Log($"Unexpected error running command '{command.Type}' error: '{ex.Message}'"); + } + } + } + else + { + if (_oneTimeCommandErrors.Add(command.Type)) + { + Log($"Detected unregistered command type '{command.Type}', will be ignored"); + } + } + return new CommandResultPacket(command, result) { CommandName = command.Type }; + } + + /// + /// Generic untyped command call handler + /// + /// The associated data + /// True if success, false otherwise. Returning null will disable command feedback + public virtual bool? OnCommand(dynamic data) + { + return true; + } + private static Symbol GetCanonicalOptionSymbol(Symbol symbol) { // We got the underlying diff --git a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs index 68681ac269f7..0ab8d255923f 100644 --- a/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs +++ b/AlgorithmFactory/Python/Wrappers/AlgorithmPythonWrapper.cs @@ -37,6 +37,7 @@ using QuantConnect.Statistics; using QuantConnect.Data.Market; using QuantConnect.Algorithm.Framework.Alphas.Analysis; +using QuantConnect.Commands; namespace QuantConnect.AlgorithmFactory.Python.Wrappers { @@ -62,6 +63,7 @@ public class AlgorithmPythonWrapper : BasePythonWrapper, IAlgorithm private dynamic _onEndOfDay; private dynamic _onMarginCallWarning; private dynamic _onOrderEvent; + private dynamic _onCommand; private dynamic _onAssignmentOrderEvent; private dynamic _onSecuritiesChanged; private dynamic _onFrameworkSecuritiesChanged; @@ -153,6 +155,7 @@ public AlgorithmPythonWrapper(string moduleName) _onDelistings = _algorithm.GetMethod("OnDelistings"); _onSymbolChangedEvents = _algorithm.GetMethod("OnSymbolChangedEvents"); _onEndOfDay = _algorithm.GetMethod("OnEndOfDay"); + _onCommand = _algorithm.GetMethod("OnCommand"); _onMarginCallWarning = _algorithm.GetMethod("OnMarginCallWarning"); _onOrderEvent = _algorithm.GetMethod("OnOrderEvent"); _onAssignmentOrderEvent = _algorithm.GetMethod("OnAssignmentOrderEvent"); @@ -921,6 +924,16 @@ public void OnOrderEvent(OrderEvent newEvent) _onOrderEvent(newEvent); } + /// + /// Generic untyped command call handler + /// + /// The associated data + /// True if success, false otherwise. Returning null will disable command feedback + public bool? OnCommand(dynamic data) + { + return _onCommand(data); + } + /// /// Will submit an order request to the algorithm /// @@ -1242,5 +1255,13 @@ public void SetTags(HashSet tags) { _baseAlgorithm.SetTags(tags); } + + /// + /// Run a callback command instance + /// + /// The callback command instance + /// The command result + public CommandResultPacket RunCommand(CallbackCommand command) => _baseAlgorithm.RunCommand(command); + } } diff --git a/Api/Api.cs b/Api/Api.cs index 4b7eed02bae6..88f84aa87336 100644 --- a/Api/Api.cs +++ b/Api/Api.cs @@ -1023,7 +1023,6 @@ public RestResponse LiquidateLiveAlgorithm(int projectId) /// /// Project for the live instance we want to stop /// - public RestResponse StopLiveAlgorithm(int projectId) { var request = new RestRequest("live/update/stop", Method.POST) @@ -1040,6 +1039,29 @@ public RestResponse StopLiveAlgorithm(int projectId) return result; } + /// + /// Create a live command + /// + /// Project for the live instance we want to run the command against + /// The command to run + /// + public RestResponse CreateLiveCommand(int projectId, object command) + { + var request = new RestRequest("live/commands/create", Method.POST) + { + RequestFormat = DataFormat.Json + }; + + request.AddParameter("application/json", JsonConvert.SerializeObject(new + { + projectId, + command + }), ParameterType.RequestBody); + + ApiConnection.TryRequest(request, out RestResponse result); + return result; + } + /// /// Gets the logs of a specific live algorithm /// diff --git a/Common/AlgorithmImports.py b/Common/AlgorithmImports.py index ede61a93b7a0..198cd457521e 100644 --- a/Common/AlgorithmImports.py +++ b/Common/AlgorithmImports.py @@ -39,6 +39,7 @@ from QuantConnect.Python import * from QuantConnect.Storage import * from QuantConnect.Research import * +from QuantConnect.Commands import * from QuantConnect.Algorithm import * from QuantConnect.Statistics import * from QuantConnect.Parameters import * diff --git a/Common/Commands/BaseCommandHandler.cs b/Common/Commands/BaseCommandHandler.cs index a06a391f22c1..20daceec3d19 100644 --- a/Common/Commands/BaseCommandHandler.cs +++ b/Common/Commands/BaseCommandHandler.cs @@ -15,8 +15,10 @@ using System; using System.Linq; +using Newtonsoft.Json; using QuantConnect.Logging; using QuantConnect.Packets; +using Newtonsoft.Json.Linq; using QuantConnect.Interfaces; using System.Collections.Generic; @@ -27,6 +29,8 @@ namespace QuantConnect.Commands /// public abstract class BaseCommandHandler : ICommandHandler { + protected static readonly JsonSerializerSettings Settings = new() { TypeNameHandling = TypeNameHandling.All }; + /// /// The algorithm instance /// @@ -104,5 +108,41 @@ public virtual void Dispose() { // nop } + + /// + /// Helper method to create a callback command + /// + protected ICommand TryGetCallbackCommand(string payload) + { + Dictionary deserialized = new(StringComparer.InvariantCultureIgnoreCase); + try + { + if (!string.IsNullOrEmpty(payload)) + { + var jobject = JObject.Parse(payload); + foreach (var kv in jobject) + { + deserialized[kv.Key] = kv.Value; + } + } + } + catch (Exception err) + { + Log.Error(err, $"Payload: '{payload}'"); + return null; + } + + if (!deserialized.TryGetValue("id", out var id) || id == null) + { + id = string.Empty; + } + + if (!deserialized.TryGetValue("$type", out var type) || type == null) + { + type = string.Empty; + } + + return new CallbackCommand { Id = id.ToString(), Type = type.ToString(), Payload = payload }; + } } } diff --git a/Common/Commands/CallbackCommand.cs b/Common/Commands/CallbackCommand.cs new file mode 100644 index 000000000000..29d59ed36001 --- /dev/null +++ b/Common/Commands/CallbackCommand.cs @@ -0,0 +1,63 @@ +/* + * 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; +using QuantConnect.Interfaces; + +namespace QuantConnect.Commands +{ + /// + /// Algorithm callback command type + /// + public class CallbackCommand : BaseCommand + { + /// + /// The target command type to run, if empty or null will be the generic untyped command handler + /// + public string Type { get; set; } + + /// + /// The command payload + /// + public string Payload { get; set; } + + /// + /// Runs this command against the specified algorithm instance + /// + /// The algorithm to run this command against + public override CommandResultPacket Run(IAlgorithm algorithm) + { + if (string.IsNullOrEmpty(Type)) + { + // target is the untyped algorithm handler + var result = algorithm.OnCommand(string.IsNullOrEmpty(Payload) ? null : JsonConvert.DeserializeObject(Payload)); + return new CommandResultPacket(this, result); + } + return algorithm.RunCommand(this); + } + + /// + /// The command string representation + /// + public override string ToString() + { + if (!string.IsNullOrEmpty(Type)) + { + return Type; + } + return "OnCommand"; + } + } +} diff --git a/Common/Commands/Command.cs b/Common/Commands/Command.cs new file mode 100644 index 000000000000..d5e862058e08 --- /dev/null +++ b/Common/Commands/Command.cs @@ -0,0 +1,91 @@ +/* + * 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.Dynamic; +using QuantConnect.Data; +using System.Reflection; +using System.Linq.Expressions; +using QuantConnect.Interfaces; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace QuantConnect.Commands +{ + /// + /// Base generic dynamic command class + /// + public class Command : DynamicObject + { + private static readonly MethodInfo SetPropertyMethodInfo = typeof(Command).GetMethod("SetProperty"); + private static readonly MethodInfo GetPropertyMethodInfo = typeof(Command).GetMethod("GetProperty"); + + private readonly Dictionary _storage = new(StringComparer.InvariantCultureIgnoreCase); + + /// + /// Get the metaObject required for Dynamism. + /// + public sealed override DynamicMetaObject GetMetaObject(Expression parameter) + { + return new GetSetPropertyDynamicMetaObject(parameter, this, SetPropertyMethodInfo, GetPropertyMethodInfo); + } + + /// + /// Sets the property with the specified name to the value. This is a case-insensitve search. + /// + /// The property name to set + /// The new property value + /// Returns the input value back to the caller + public object SetProperty(string name, object value) + { + if (value is JArray jArray) + { + return _storage[name] = jArray.ToObject>(); + } + else if (value is JObject jobject) + { + return _storage[name] = jobject.ToObject>(); + } + else + { + return _storage[name] = value; + } + } + + /// + /// Gets the property's value with the specified name. This is a case-insensitve search. + /// + /// The property name to access + /// object value of BaseData + public object GetProperty(string name) + { + if (!_storage.TryGetValue(name, out var value)) + { + throw new KeyNotFoundException($"Property with name \'{name}\' does not exist. Properties: {string.Join(", ", _storage.Keys)}"); + } + return value; + } + + /// + /// Run this command using the target algorithm + /// + /// The algorithm instance + /// True if success, false otherwise. Returning null will disable command feedback + public virtual bool? Run(IAlgorithm algorithm) + { + throw new NotImplementedException($"Please implement the 'def run(algorithm) -> bool | None:' method"); + } + } +} diff --git a/Common/Commands/CommandResultsPacket.cs b/Common/Commands/CommandResultsPacket.cs index 9c260eceab69..07a8a3859225 100644 --- a/Common/Commands/CommandResultsPacket.cs +++ b/Common/Commands/CommandResultsPacket.cs @@ -31,12 +31,12 @@ public class CommandResultPacket : Packet /// /// Gets or sets whether or not the /// - public bool Success { get; set; } + public bool? Success { get; set; } /// /// Initializes a new instance of the class /// - public CommandResultPacket(ICommand command, bool success) + public CommandResultPacket(ICommand command, bool? success) : base(PacketType.CommandResult) { Success = success; diff --git a/Common/Commands/FileCommandHandler.cs b/Common/Commands/FileCommandHandler.cs index c01549612f11..31be9608efb5 100644 --- a/Common/Commands/FileCommandHandler.cs +++ b/Common/Commands/FileCommandHandler.cs @@ -19,6 +19,7 @@ using QuantConnect.Logging; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json.Linq; namespace QuantConnect.Commands { @@ -90,7 +91,9 @@ protected override void Acknowledge(ICommand command, CommandResultPacket comman private void ReadCommandFile(string commandFilePath) { Log.Trace($"FileCommandHandler.ReadCommandFile(): {Messages.FileCommandHandler.ReadingCommandFile(commandFilePath)}"); - object deserialized; + string contents = null; + Exception exception = null; + object deserialized = null; try { if (!File.Exists(commandFilePath)) @@ -98,13 +101,12 @@ private void ReadCommandFile(string commandFilePath) Log.Error($"FileCommandHandler.ReadCommandFile(): {Messages.FileCommandHandler.CommandFileDoesNotExist(commandFilePath)}"); return; } - var contents = File.ReadAllText(commandFilePath); - deserialized = JsonConvert.DeserializeObject(contents, new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }); + contents = File.ReadAllText(commandFilePath); + deserialized = JsonConvert.DeserializeObject(contents, Settings); } catch (Exception err) { - Log.Error(err); - deserialized = null; + exception = err; } // remove the file when we're done reading it @@ -126,6 +128,20 @@ private void ReadCommandFile(string commandFilePath) if (item != null) { _commands.Enqueue(item); + return; + } + + var callbackCommand = TryGetCallbackCommand(contents); + if (callbackCommand != null) + { + _commands.Enqueue(callbackCommand); + return; + } + + if (exception != null) + { + // if we are here we failed + Log.Error(exception); } } } diff --git a/Common/Interfaces/IAlgorithm.cs b/Common/Interfaces/IAlgorithm.cs index b1d37fe867ca..fb4f2c8b6ca2 100644 --- a/Common/Interfaces/IAlgorithm.cs +++ b/Common/Interfaces/IAlgorithm.cs @@ -32,6 +32,7 @@ using QuantConnect.Data.UniverseSelection; using QuantConnect.Algorithm.Framework.Alphas; using QuantConnect.Algorithm.Framework.Alphas.Analysis; +using QuantConnect.Commands; namespace QuantConnect.Interfaces { @@ -608,6 +609,13 @@ InsightManager Insights /// Event information void OnOrderEvent(OrderEvent newEvent); + /// + /// Generic untyped command call handler + /// + /// The associated data + /// True if success, false otherwise. Returning null will disable command feedback + bool? OnCommand(dynamic data); + /// /// Will submit an order request to the algorithm /// @@ -919,5 +927,12 @@ Security AddSecurity(Symbol symbol, Resolution? resolution = null, bool fillForw /// /// The tags void SetTags(HashSet tags); + + /// + /// Run a callback command instance + /// + /// The callback command instance + /// The command result + CommandResultPacket RunCommand(CallbackCommand command); } } diff --git a/Common/Python/CommandPythonWrapper.cs b/Common/Python/CommandPythonWrapper.cs new file mode 100644 index 000000000000..4554725459ed --- /dev/null +++ b/Common/Python/CommandPythonWrapper.cs @@ -0,0 +1,74 @@ +/* + * 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 Python.Runtime; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using QuantConnect.Commands; +using QuantConnect.Interfaces; +using System.Collections.Generic; + +namespace QuantConnect.Python +{ + /// + /// Python wrapper for a python defined command type + /// + public class CommandPythonWrapper : BasePythonWrapper + { + /// + /// Constructor for initialising the class with wrapped object + /// + /// Python command type + /// Command data + public CommandPythonWrapper(PyObject type, string data = null) + : base() + { + using var _ = Py.GIL(); + + var instance = type.Invoke(); + + SetPythonInstance(instance); + if (data != null) + { + foreach (var kvp in JsonConvert.DeserializeObject>(data)) + { + if (kvp.Value is JArray jArray) + { + SetProperty(kvp.Key, jArray.ToObject>()); + } + else if (kvp.Value is JObject jobject) + { + SetProperty(kvp.Key, jobject.ToObject>()); + } + else + { + SetProperty(kvp.Key, kvp.Value); + } + } + } + } + + /// + /// Run this command using the target algorithm + /// + /// The algorithm instance + /// True if success, false otherwise. Returning null will disable command feedback + public bool? Run(IAlgorithm algorithm) + { + var result = InvokeMethod(nameof(Run), algorithm); + return result.GetAndDispose(); + } + } +} diff --git a/Common/Python/PythonWrapper.cs b/Common/Python/PythonWrapper.cs index 7f878977f5b3..fc20aac09af5 100644 --- a/Common/Python/PythonWrapper.cs +++ b/Common/Python/PythonWrapper.cs @@ -34,21 +34,28 @@ public static class PythonWrapper /// The model implementing the interface type public static PyObject ValidateImplementationOf(this PyObject model) { - if (!typeof(TInterface).IsInterface) - { - throw new ArgumentException( - $"{nameof(PythonWrapper)}.{nameof(ValidateImplementationOf)}(): {Messages.PythonWrapper.ExpectedInterfaceTypeParameter}"); - } - + var notInterface = !typeof(TInterface).IsInterface; var missingMembers = new List(); var members = typeof(TInterface).GetMembers(BindingFlags.Public | BindingFlags.Instance); using (Py.GIL()) { foreach (var member in members) { - if ((member is not MethodInfo method || !method.IsSpecialName) && + var method = member as MethodInfo; + if ((method == null || !method.IsSpecialName) && !model.HasAttr(member.Name) && !model.HasAttr(member.Name.ToSnakeCase())) { + if (notInterface) + { + if (method != null && !method.IsAbstract && (method.IsFinal || !method.IsVirtual || method.DeclaringType != typeof(TInterface))) + { + continue; + } + else if (member is ConstructorInfo) + { + continue; + } + } missingMembers.Add(member.Name); } } diff --git a/Engine/Server/LocalLeanManager.cs b/Engine/Server/LocalLeanManager.cs index c771176ea782..9a7193910dbd 100644 --- a/Engine/Server/LocalLeanManager.cs +++ b/Engine/Server/LocalLeanManager.cs @@ -91,8 +91,7 @@ public virtual void OnAlgorithmStart() { if (Algorithm.LiveMode) { - _commandHandler = new FileCommandHandler(); - _commandHandler.Initialize(_job, Algorithm); + SetCommandHandler(); } } @@ -119,5 +118,14 @@ public virtual void Dispose() { _commandHandler.DisposeSafely(); } + + /// + /// Set the command handler to use, protected for testing purposes + /// + protected virtual void SetCommandHandler() + { + _commandHandler = new FileCommandHandler(); + _commandHandler.Initialize(_job, Algorithm); + } } } diff --git a/Tests/Api/ApiTestBase.cs b/Tests/Api/ApiTestBase.cs index 5a337cf5dd32..da581619227e 100644 --- a/Tests/Api/ApiTestBase.cs +++ b/Tests/Api/ApiTestBase.cs @@ -86,7 +86,7 @@ private void CreateTestProjectAndBacktest() return; } Log.Debug("ApiTestBase.Setup(): Waiting for test compile to complete"); - compile = WaitForCompilerResponse(TestProject.ProjectId, compile.CompileId); + compile = WaitForCompilerResponse(ApiClient, TestProject.ProjectId, compile.CompileId); if (!compile.Success) { Assert.Warn("Could not create compile for the test project, tests using it will fail."); @@ -134,14 +134,14 @@ private void DeleteTestProjectAndBacktest() /// Id of the project /// Id of the compilation of the project /// - protected Compile WaitForCompilerResponse(int projectId, string compileId) + protected static Compile WaitForCompilerResponse(Api.Api apiClient, int projectId, string compileId, int seconds = 60) { - Compile compile; - var finish = DateTime.UtcNow.AddSeconds(60); + var compile = new Compile(); + var finish = DateTime.UtcNow.AddSeconds(seconds); do { - Thread.Sleep(1000); - compile = ApiClient.ReadCompile(projectId, compileId); + Thread.Sleep(100); + compile = apiClient.ReadCompile(projectId, compileId); } while (compile.State != CompileState.BuildSuccess && DateTime.UtcNow < finish); return compile; @@ -169,7 +169,7 @@ protected Backtest WaitForBacktestCompletion(int projectId, string backtestId) /// /// Reload configuration, making sure environment variables are loaded into the config /// - private static void ReloadConfiguration() + internal static void ReloadConfiguration() { // nunit 3 sets the current folder to a temp folder we need it to be the test bin output folder var dir = TestContext.CurrentContext.TestDirectory; diff --git a/Tests/Api/CommandTests.cs b/Tests/Api/CommandTests.cs new file mode 100644 index 000000000000..08616924837a --- /dev/null +++ b/Tests/Api/CommandTests.cs @@ -0,0 +1,120 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Api; +using System.Threading; +using System.Collections.Generic; + +namespace QuantConnect.Tests.API +{ + [TestFixture, Explicit("Requires configured api access, a live node to run on, and brokerage configurations.")] + public class CommandTests + { + private Api.Api _apiClient; + + [OneTimeSetUp] + public void Setup() + { + ApiTestBase.ReloadConfiguration(); + + _apiClient = new Api.Api(); + _apiClient.Initialize(Globals.UserId, Globals.UserToken, Globals.DataFolder); + } + + [TestCase("MyCommand")] + [TestCase("MyCommand2")] + [TestCase("MyCommand3")] + [TestCase("")] + public void LiveCommand(string commandType) + { + var command = new Dictionary + { + { "quantity", 0.1 }, + { "target", "BTCUSD" }, + { "$type", commandType } + }; + + var projectId = RunLiveAlgorithm(); + try + { + // allow algo to be deployed and prices to be set so we can trade + Thread.Sleep(TimeSpan.FromSeconds(10)); + var result = _apiClient.CreateLiveCommand(projectId, command); + Assert.IsTrue(result.Success); + } + finally + { + _apiClient.StopLiveAlgorithm(projectId); + _apiClient.DeleteProject(projectId); + } + } + + private int RunLiveAlgorithm() + { + var settings = new Dictionary() + { + { "id", "QuantConnectBrokerage" }, + { "environment", "paper" }, + { "user", "" }, + { "password", "" }, + { "account", "" } + }; + + var file = new ProjectFile + { + Name = "Main.cs", + Code = @"from AlgorithmImports import * + +class MyCommand(): + quantity = 0 + target = '' + def run(self, algo: QCAlgorithm) -> bool | None: + self.execute_order(algo) + def execute_order(self, algo): + algo.order(self.target, self.quantity) + +class MyCommand2(): + quantity = 0 + target = '' + def run(self, algo: QCAlgorithm) -> bool | None: + algo.order(self.target, self.quantity) + return True + +class MyCommand3(): + quantity = 0 + target = '' + def run(self, algo: QCAlgorithm) -> bool | None: + algo.order(self.target, self.quantity) + return False + +class DeterminedSkyBlueGorilla(QCAlgorithm): + def initialize(self): + self.set_start_date(2023, 3, 17) + self.add_crypto(""BTCUSD"", Resolution.SECOND) + self.add_command(MyCommand) + self.add_command(MyCommand2) + self.add_command(MyCommand3) + + def on_command(self, data): + self.order(data.target, data.quantity)" + }; + + // Run the live algorithm + return LiveTradingTests.RunLiveAlgorithm(_apiClient, settings, file, stopLiveAlgos: false, language: Language.Python); + } + } +} diff --git a/Tests/Api/LiveTradingTests.cs b/Tests/Api/LiveTradingTests.cs index b6da89a6ee9c..12dbdd7249d4 100644 --- a/Tests/Api/LiveTradingTests.cs +++ b/Tests/Api/LiveTradingTests.cs @@ -721,42 +721,48 @@ public void LiveAlgorithmsAndLiveLogs_CanBeRead_Successfully() /// Dictionary with the data providers and their corresponding credentials /// The id of the project created with the algorithm in private int RunLiveAlgorithm(Dictionary settings, ProjectFile file, bool stopLiveAlgos, Dictionary dataProviders = null) + { + return RunLiveAlgorithm(ApiClient, settings, file, stopLiveAlgos, dataProviders); + } + + internal static int RunLiveAlgorithm(Api.Api apiClient, Dictionary settings, ProjectFile file, bool stopLiveAlgos, + Dictionary dataProviders = null, Language language = Language.CSharp) { // Create a new project - var project = ApiClient.CreateProject($"Test project - {DateTime.Now.ToStringInvariant()}", Language.CSharp, TestOrganization); + var project = apiClient.CreateProject($"Test project - {DateTime.Now.ToStringInvariant()}", language, Globals.OrganizationID); var projectId = project.Projects.First().ProjectId; // Update Project Files - var updateProjectFileContent = ApiClient.UpdateProjectFileContent(projectId, "Main.cs", file.Code); + var updateProjectFileContent = apiClient.UpdateProjectFileContent(projectId, language == Language.CSharp ? "Main.cs" : "main.py", file.Code); Assert.IsTrue(updateProjectFileContent.Success); // Create compile - var compile = ApiClient.CreateCompile(projectId); + var compile = apiClient.CreateCompile(projectId); Assert.IsTrue(compile.Success); // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId, 30); + var compileCheck = WaitForCompilerResponse(apiClient, projectId, compile.CompileId, 30); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); // Get a live node to launch the algorithm on - var nodesResponse = ApiClient.ReadProjectNodes(projectId); + var nodesResponse = apiClient.ReadProjectNodes(projectId); Assert.IsTrue(nodesResponse.Success); var freeNode = nodesResponse.Nodes.LiveNodes.Where(x => x.Busy == false); Assert.IsNotEmpty(freeNode, "No free Live Nodes found"); // Create live default algorithm - var createLiveAlgorithm = ApiClient.CreateLiveAlgorithm(projectId, compile.CompileId, freeNode.FirstOrDefault().Id, settings, dataProviders: dataProviders); + var createLiveAlgorithm = apiClient.CreateLiveAlgorithm(projectId, compile.CompileId, freeNode.FirstOrDefault().Id, settings, dataProviders: dataProviders); Assert.IsTrue(createLiveAlgorithm.Success); if (stopLiveAlgos) { // Liquidate live algorithm; will also stop algorithm - var liquidateLive = ApiClient.LiquidateLiveAlgorithm(projectId); + var liquidateLive = apiClient.LiquidateLiveAlgorithm(projectId); Assert.IsTrue(liquidateLive.Success); // Delete the project - var deleteProject = ApiClient.DeleteProject(projectId); + var deleteProject = apiClient.DeleteProject(projectId); Assert.IsTrue(deleteProject.Success); } @@ -820,7 +826,7 @@ public void RunLiveAlgorithmsFromPython() Assert.IsTrue(compile.Success); // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId, 30); + var compileCheck = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId, 30); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); @@ -863,26 +869,6 @@ def CreateLiveAlgorithmFromPython(apiClient, projectId, compileId, nodeId): } } - /// - /// Wait for the compiler to respond to a specified compile request - /// - /// Id of the project - /// Id of the compilation of the project - /// Seconds to allow for compile time - /// - private Compile WaitForCompilerResponse(int projectId, string compileId, int seconds) - { - var compile = new Compile(); - var finish = DateTime.Now.AddSeconds(seconds); - while (DateTime.Now < finish) - { - Thread.Sleep(1000); - compile = ApiClient.ReadCompile(projectId, compileId); - if (compile.State == CompileState.BuildSuccess) break; - } - return compile; - } - /// /// Wait to receive at least one order /// diff --git a/Tests/Api/OptimizationTests.cs b/Tests/Api/OptimizationTests.cs index 6f68e1fde999..8be4995fff55 100644 --- a/Tests/Api/OptimizationTests.cs +++ b/Tests/Api/OptimizationTests.cs @@ -220,7 +220,7 @@ private int GetProjectCompiledAndWithBacktest(out Compile compile) Assert.IsTrue(compile.Success); // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId); + var compileCheck = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); diff --git a/Tests/Api/ProjectTests.cs b/Tests/Api/ProjectTests.cs index bd3e42f5ed90..d8c46b18594c 100644 --- a/Tests/Api/ProjectTests.cs +++ b/Tests/Api/ProjectTests.cs @@ -257,7 +257,7 @@ private void Perform_CreateCompileBackTest_Tests(string projectName, Language la Assert.AreEqual(CompileState.InQueue, compileCreate.State); // Read out the compile - var compileSuccess = WaitForCompilerResponse(project.Projects.First().ProjectId, compileCreate.CompileId); + var compileSuccess = WaitForCompilerResponse(ApiClient, project.Projects.First().ProjectId, compileCreate.CompileId); Assert.IsTrue(compileSuccess.Success); Assert.AreEqual(CompileState.BuildSuccess, compileSuccess.State); @@ -265,7 +265,7 @@ private void Perform_CreateCompileBackTest_Tests(string projectName, Language la file.Code += "[Jibberish at end of the file to cause a build error]"; ApiClient.UpdateProjectFileContent(project.Projects.First().ProjectId, file.Name, file.Code); var compileError = ApiClient.CreateCompile(project.Projects.First().ProjectId); - compileError = WaitForCompilerResponse(project.Projects.First().ProjectId, compileError.CompileId); + compileError = WaitForCompilerResponse(ApiClient, project.Projects.First().ProjectId, compileError.CompileId); Assert.IsTrue(compileError.Success); // Successfully processed rest request. Assert.AreEqual(CompileState.BuildError, compileError.State); //Resulting in build fail. @@ -336,7 +336,7 @@ public void ReadBacktestOrdersReportAndChart() $"Error updating project file:\n {string.Join("\n ", updateProjectFileContent.Errors)}"); var compileCreate = ApiClient.CreateCompile(project.ProjectId); - var compileSuccess = WaitForCompilerResponse(project.ProjectId, compileCreate.CompileId); + var compileSuccess = WaitForCompilerResponse(ApiClient, project.ProjectId, compileCreate.CompileId); Assert.IsTrue(compileSuccess.Success, $"Error compiling project:\n {string.Join("\n ", compileSuccess.Errors)}"); var backtestName = $"ReadBacktestOrders Backtest {GetTimestamp()}"; @@ -559,7 +559,7 @@ public void CreatesLiveAlgorithm() Assert.IsTrue(compile.Success); // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId); + var compileCheck = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); @@ -642,7 +642,7 @@ public void CreatesOptimization() Assert.IsTrue(compile.Success); // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId); + var compileCheck = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); @@ -726,7 +726,7 @@ private void GetProjectAndCompileIdToReadInsights(out int projectId, out string compileId = compile.CompileId; // Wait at max 30 seconds for project to compile - var compileCheck = WaitForCompilerResponse(projectId, compile.CompileId); + var compileCheck = WaitForCompilerResponse(ApiClient, projectId, compile.CompileId); Assert.IsTrue(compileCheck.Success); Assert.IsTrue(compileCheck.State == CompileState.BuildSuccess); } diff --git a/Tests/Common/Commands/CallbackCommandTests.cs b/Tests/Common/Commands/CallbackCommandTests.cs new file mode 100644 index 000000000000..319109748574 --- /dev/null +++ b/Tests/Common/Commands/CallbackCommandTests.cs @@ -0,0 +1,129 @@ +/* + * 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.IO; +using NUnit.Framework; +using Newtonsoft.Json; +using QuantConnect.Statistics; +using QuantConnect.Configuration; +using System.Collections.Generic; +using QuantConnect.Algorithm.CSharp; +using QuantConnect.Lean.Engine.Server; +using System; + +namespace QuantConnect.Tests.Common.Commands +{ + [TestFixture] + public class CallbackCommandTests + { + [TestCase(Language.CSharp)] + [TestCase(Language.Python)] + public void CommanCallback(Language language) + { + var parameter = new RegressionTests.AlgorithmStatisticsTestParameters(typeof(CallbackCommandRegressionAlgorithm).Name, + new Dictionary { + {PerformanceMetrics.TotalOrders, "3"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0.212%"}, + {"Drawdown", "0.000%"}, + {"Expectancy", "0"}, + {"Net Profit", "0.003%"}, + {"Sharpe Ratio", "-5.552"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "66.765%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "-0.01"}, + {"Beta", "0.003"}, + {"Annual Standard Deviation", "0.001"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.919"}, + {"Tracking Error", "0.222"}, + {"Treynor Ratio", "-1.292"}, + {"Total Fees", "$3.00"}, + {"Estimated Strategy Capacity", "$670000000.00"}, + {"Lowest Capacity Asset", "IBM R735QTJ8XC9X"}, + {"Portfolio Turnover", "0.06%"} + }, + language, + AlgorithmStatus.Completed); + + Config.Set("lean-manager-type", typeof(TestLocalLeanManager).Name); + + var result = AlgorithmRunner.RunLocalBacktest(parameter.Algorithm, + parameter.Statistics, + parameter.Language, + parameter.ExpectedFinalStatus); + } + + internal class TestLocalLeanManager : LocalLeanManager + { + private bool _sentCommands; + public override void Update() + { + if (!_sentCommands && Algorithm.Time.TimeOfDay > TimeSpan.FromHours(9.50)) + { + _sentCommands = true; + var commands = new List> + { + new() + { + { "$type", "" }, + { "id", 1 }, + { "Symbol", "SPY" }, + { "Parameters", new Dictionary { { "quantity", 1 } } }, + { "unused", 99 } + }, + new() + { + { "$type", "VoidCommand" }, + { "id", null }, + { "Quantity", 1 }, + { "targettime", Algorithm.Time }, + { "target", new [] { "BAC" } }, + { "Parameters", new Dictionary { { "tag", "a tag" }, { "something", "else" } } }, + }, + new() + { + { "id", "2" }, + { "$type", "BoolCommand" }, + { "Result", true }, + { "unused", new [] { 99 } } + }, + new() + { + { "$type", "BoolCommand" }, + { "Result", null }, + } + }; + + for (var i = 1; i <= commands.Count; i++) + { + var command = commands[i - 1]; + command["id"] = i; + File.WriteAllText($"command-{i}.json", JsonConvert.SerializeObject(command)); + } + base.Update(); + } + } + public override void OnAlgorithmStart() + { + SetCommandHandler(); + } + } + } +}