diff --git a/Common/Orders/BloombergFixOrderProperties.cs b/Common/Orders/BloombergFixOrderProperties.cs index 7a77f2d7423c..6565bf2259dd 100644 --- a/Common/Orders/BloombergFixOrderProperties.cs +++ b/Common/Orders/BloombergFixOrderProperties.cs @@ -13,8 +13,6 @@ * limitations under the License. */ -using System.Collections.Generic; - namespace QuantConnect.Orders { /// @@ -28,7 +26,7 @@ public class BloombergFixOrderProperties : FixOrderProperties /// public string LocateBroker { - get { return AdditionalProperties?.GetValueOrDefault("5700"); } + get { return GetTag("5700"); } set { SetTag("5700", value); } } @@ -38,10 +36,15 @@ public string LocateBroker /// public string LocateReqd { - get { return AdditionalProperties?.GetValueOrDefault("114"); } + get { return GetTag("114"); } set { SetTag("114", value); } } + private string GetTag(string tag) + { + return AdditionalProperties != null && AdditionalProperties.TryGetValue(tag, out var value) ? value : null; + } + private void SetTag(string tag, string value) { if (value == null) diff --git a/Common/Orders/FixOrderProperties.cs b/Common/Orders/FixOrderProperties.cs index 077ca5a75c20..1e9cd4770d00 100644 --- a/Common/Orders/FixOrderProperties.cs +++ b/Common/Orders/FixOrderProperties.cs @@ -13,7 +13,7 @@ * limitations under the License. */ -using System.Collections.Generic; +using Common.Util; using QuantConnect.Interfaces; namespace QuantConnect.Orders @@ -27,7 +27,10 @@ public class FixOrderProperties : OrderProperties /// Custom FIX tags to send with the order. The key is the FIX tag number /// and the value is the tag value, e.g. AdditionalProperties["9301"] = "1" /// - public Dictionary AdditionalProperties { get; set; } = []; + /// Starts empty. Python cannot assign a plain dict to it, since pythonnet has no + /// conversion for it; add the entries one by one, bulk load them from a dict with update(), + /// and reset with clear() + public BaseExtendedDictionary AdditionalProperties { get; set; } = []; /// /// Instruction for order handling on Broker floor @@ -60,7 +63,7 @@ public class FixOrderProperties : OrderProperties public override IOrderProperties Clone() { var clone = (FixOrderProperties)MemberwiseClone(); - clone.AdditionalProperties = new Dictionary(AdditionalProperties); + clone.AdditionalProperties = new BaseExtendedDictionary(AdditionalProperties); return clone; } } diff --git a/Common/Orders/TerminalLinkOrderProperties.cs b/Common/Orders/TerminalLinkOrderProperties.cs index 333133dc0fa0..db0a52dffb1b 100644 --- a/Common/Orders/TerminalLinkOrderProperties.cs +++ b/Common/Orders/TerminalLinkOrderProperties.cs @@ -15,6 +15,8 @@ */ using System.Collections.Generic; +using Common.Util; +using QuantConnect.Interfaces; namespace QuantConnect.Orders { @@ -23,6 +25,15 @@ namespace QuantConnect.Orders /// public class TerminalLinkOrderProperties : OrderProperties { + /// + /// Custom EMSX fields to send with the order. The key is the EMSX element name + /// and the value is the element value, e.g. AdditionalProperties["EMSX_CFD_FLAG"] = "1" + /// + /// Starts empty. Python cannot assign a plain dict to it, since pythonnet has no + /// conversion for it; add the entries one by one, bulk load them from a dict with update(), + /// and reset with clear() + public BaseExtendedDictionary AdditionalProperties { get; set; } = []; + /// /// The EMSX Instructions is the free form instructions that may be sent to the broker /// @@ -88,6 +99,17 @@ public class TerminalLinkOrderProperties : OrderProperties /// public string LocateId { get; set; } + /// + /// Indicates if the order is a contract for differences (CFD) trade (EMSX_CFD_FLAG). + /// This field is applicable to trades on an order level, and does not populate on a per + /// security basis. + /// + public bool IsCfdTrade + { + get { return AdditionalProperties != null && AdditionalProperties.TryGetValue("EMSX_CFD_FLAG", out var flag) && flag == "1"; } + set { SetTag("EMSX_CFD_FLAG", value ? "1" : null); } + } + /// /// The EMSX order strategy details. /// Strategy parameters must be appended in the correct order as expected by EMSX. @@ -105,6 +127,31 @@ public class TerminalLinkOrderProperties : OrderProperties /// Has precedence over public OrderPosition? PositionSide { get; set; } + /// + /// Returns a new instance clone of this object + /// + /// Deep copies so edits on the clone, e.g. the + /// locate cleanup in BrokerageExtensions.RemoveLocateFromNonShortOrder, never reach the + /// instance the algorithm holds on to + public override IOrderProperties Clone() + { + var clone = (TerminalLinkOrderProperties)MemberwiseClone(); + clone.AdditionalProperties = new BaseExtendedDictionary(AdditionalProperties); + return clone; + } + + private void SetTag(string tag, string value) + { + if (value == null) + { + AdditionalProperties?.Remove(tag); + } + else + { + AdditionalProperties[tag] = value; + } + } + /// /// Models an EMSX order strategy parameter /// diff --git a/Tests/Common/Orders/FixOrderPropertiesTests.cs b/Tests/Common/Orders/FixOrderPropertiesTests.cs index 9105fe89db3d..6991a5844c40 100644 --- a/Tests/Common/Orders/FixOrderPropertiesTests.cs +++ b/Tests/Common/Orders/FixOrderPropertiesTests.cs @@ -14,6 +14,7 @@ */ using NUnit.Framework; +using Python.Runtime; using QuantConnect.Interfaces; using QuantConnect.Orders; @@ -37,5 +38,48 @@ public void BloombergFixOrderPropertiesSupportsAdditionalPropertiesAndClone() properties.AdditionalProperties["9301"] = "2"; Assert.AreEqual("1", clone.AdditionalProperties["9301"]); } + + [Test] + public void LocateTagPassthroughsSurviveTheDictionarySwap() + { + // The tags are the store behind the properties, so writing either way must be visible + // from the other. + var properties = new BloombergFixOrderProperties { LocateBroker = "MLCO" }; + Assert.AreEqual("MLCO", properties.AdditionalProperties["5700"]); + + properties.AdditionalProperties["114"] = "Y"; + Assert.AreEqual("Y", properties.LocateReqd); + + properties.LocateBroker = null; + Assert.IsFalse(properties.AdditionalProperties.ContainsKey("5700")); + Assert.IsNull(properties.LocateBroker); + } + + [Test] + public void UpdatesAdditionalPropertiesFromPlainPythonDictionary() + { + using (Py.GIL()) + { + // pythonnet cannot convert a plain dict for assignment, so update() is what lets a + // Python algorithm bulk load its custom tags. + var module = PyModule.FromString("fixAdditionalPropertiesModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> BloombergFixOrderProperties: + properties = BloombergFixOrderProperties() + properties.additional_properties.update({ ""5700"": ""MLCO"", ""9301"": ""1"" }) + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + var properties = (BloombergFixOrderProperties)getOrderProperties(); + + Assert.IsNotNull(properties); + Assert.AreEqual(2, properties.AdditionalProperties.Count); + Assert.AreEqual("1", properties.AdditionalProperties["9301"]); + Assert.AreEqual("MLCO", properties.LocateBroker); + } + } } } diff --git a/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs b/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs index df86a02882d1..e3ea4e325d50 100644 --- a/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs +++ b/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using System; using NUnit.Framework; using Python.Runtime; using QuantConnect.Orders; @@ -100,5 +101,181 @@ def getOrderProperties() -> TerminalLinkOrderProperties: Assert.AreEqual("LOC-123", properties.LocateId); } } + + [Test] + public void IsCfdTradeDefaultsToFalse() + { + // A regular trade is the EMSX default, and the value for which the brokerage sends no + // EMSX_CFD_FLAG at all, so it must be what an untouched instance reports. + var properties = new TerminalLinkOrderProperties(); + Assert.IsFalse(properties.IsCfdTrade); + } + + [Test] + public void CloneDoesNotShareAdditionalProperties() + { + // Order properties are reused across orders and cloned before being edited, e.g. by + // BrokerageExtensions.RemoveLocateFromNonShortOrder; a shared dictionary would let an + // edit on the copy leak back into the caller's instance. + var properties = new TerminalLinkOrderProperties { IsCfdTrade = true }; + + var clone = (TerminalLinkOrderProperties)properties.Clone(); + clone.IsCfdTrade = false; + + Assert.IsTrue(properties.IsCfdTrade); + } + + [Test] + public void SetsIsCfdTradeFromPython() + { + using (Py.GIL()) + { + var module = PyModule.FromString("cfdTradeModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> TerminalLinkOrderProperties: + properties = TerminalLinkOrderProperties() + properties.is_cfd_trade = True + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + var properties = (TerminalLinkOrderProperties)getOrderProperties(); + + Assert.IsNotNull(properties); + Assert.IsTrue(properties.IsCfdTrade); + } + } + + [Test] + public void SetsAdditionalPropertiesEntryFromPython() + { + using (Py.GIL()) + { + var module = PyModule.FromString("additionalPropertiesEntryModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> TerminalLinkOrderProperties: + properties = TerminalLinkOrderProperties() + properties.additional_properties[""EMSX_CFD_FLAG""] = ""1"" + properties.additional_properties[""EMSX_ODD_LOT""] = ""0"" + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + var properties = (TerminalLinkOrderProperties)getOrderProperties(); + + Assert.IsNotNull(properties); + Assert.AreEqual(2, properties.AdditionalProperties.Count); + Assert.AreEqual("0", properties.AdditionalProperties["EMSX_ODD_LOT"]); + // an entry written through the dictionary is visible on the typed property + Assert.IsTrue(properties.IsCfdTrade); + } + } + + [Test] + public void ClearsAdditionalPropertiesFromPython() + { + using (Py.GIL()) + { + // clear() resets the dictionary, taking the typed properties reading from it back to + // their defaults. + var module = PyModule.FromString("additionalPropertiesClearModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> TerminalLinkOrderProperties: + properties = TerminalLinkOrderProperties() + properties.is_cfd_trade = True + properties.additional_properties[""EMSX_ODD_LOT""] = ""0"" + properties.additional_properties.clear() + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + var properties = (TerminalLinkOrderProperties)getOrderProperties(); + + Assert.IsNotNull(properties); + Assert.IsEmpty(properties.AdditionalProperties); + Assert.IsFalse(properties.IsCfdTrade); + } + } + + [Test] + public void UpdatesAdditionalPropertiesFromPlainPythonDictionary() + { + using (Py.GIL()) + { + // update() is the way to bulk load from a plain Python dict; it takes a PyObject, so + // it sidesteps the conversion that plain assignment cannot do. + var module = PyModule.FromString("additionalPropertiesUpdateModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> TerminalLinkOrderProperties: + properties = TerminalLinkOrderProperties() + properties.additional_properties.update({ ""EMSX_CFD_FLAG"": ""1"", ""EMSX_ODD_LOT"": ""0"" }) + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + var properties = (TerminalLinkOrderProperties)getOrderProperties(); + + Assert.IsNotNull(properties); + Assert.AreEqual(2, properties.AdditionalProperties.Count); + Assert.AreEqual("0", properties.AdditionalProperties["EMSX_ODD_LOT"]); + Assert.IsTrue(properties.IsCfdTrade); + } + } + + [Test] + public void AssigningPlainPythonDictionaryToAdditionalPropertiesThrows() + { + using (Py.GIL()) + { + // pythonnet has no conversion from a Python dict to Dictionary, so + // the natural looking assignment fails at runtime; the entries have to be added to + // the dictionary the properties already own. + var module = PyModule.FromString("additionalPropertiesReplacementModule", + @" +from AlgorithmImports import * + +def getOrderProperties() -> TerminalLinkOrderProperties: + properties = TerminalLinkOrderProperties() + properties.additional_properties = { ""EMSX_CFD_FLAG"": ""1"" } + return properties +"); + + dynamic getOrderProperties = module.GetAttr("getOrderProperties"); + + var exception = Assert.Throws(() => getOrderProperties()); + Assert.IsTrue(exception.Message.Contains("cannot be converted", StringComparison.InvariantCulture), + $"Expected a conversion failure, got: {exception.Message}"); + } + } + + [Test] + public void ReadsAdditionalPropertiesEntryWrittenByTypedPropertyFromPython() + { + using (Py.GIL()) + { + var module = PyModule.FromString("additionalPropertiesReadModule", + @" +from AlgorithmImports import * + +def getCfdFlag() -> str: + properties = TerminalLinkOrderProperties() + properties.is_cfd_trade = True + return properties.additional_properties[""EMSX_CFD_FLAG""] +"); + + dynamic getCfdFlag = module.GetAttr("getCfdFlag"); + var flag = (string)getCfdFlag(); + + Assert.AreEqual("1", flag); + } + } } } diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs index 35cf8275e617..a7c22d903ec1 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs @@ -396,11 +396,17 @@ private static (string LocateBroker, string LocateId, string LocateBrokerTag, st { TerminalLinkOrderProperties terminalLink => (terminalLink.LocateBroker, terminalLink.LocateId, null, null), WolverineOrderProperties wolverine => (wolverine.LocateBroker, null, null, null), - FixOrderProperties fix => (null, null, fix.AdditionalProperties.GetValueOrDefault("5700"), fix.AdditionalProperties.GetValueOrDefault("114")), + FixOrderProperties fix => (null, null, GetTag(fix, "5700"), GetTag(fix, "114")), _ => default }; } + private static string GetTag(FixOrderProperties properties, string tag) + { + properties.AdditionalProperties.TryGetValue(tag, out var value); + return value; + } + private static BloombergFixOrderProperties CreateLocateBrokerTagProperties() { // the property is a passthrough of the 5700 tag in AdditionalProperties