From 471889fdd1f3be4515b1695ffca554abb5ecc5c2 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Wed, 12 Aug 2026 14:04:16 +0100 Subject: [PATCH 1/3] feature: add TerminalLink IsCfdTrade order property Adds IsCfdTrade to TerminalLinkOrderProperties so an algorithm can book an EMSX order as a contract for differences instead of a regular trade. The companion Lean.Brokerages.TerminalLink PR maps it to EMSX_CFD_FLAG on the CreateOrderAndRouteEx request. On the EMSX trading ticket this is the "Booking Type" drop-down, whose options are Regular, CFD and TRS. Only the CFD booking has an element in the EMSX API (EMSX_CFD_FLAG, "0"/"1"); there is no booking-type element and nothing for TRS in Bloomberg's element reference, so the property is a bool rather than a three-valued enum. Defaults to false, which is the EMSX default and the value for which the brokerage sends no flag at all, leaving existing orders on the wire byte-identical. The property is a pass through over a new AdditionalProperties dictionary of EMSX element name to value, following BloombergFixOrderProperties, so the typed property and the raw element cannot disagree. The dictionary starts empty and is add/remove only - it has no setter, since a plain Python dict is not convertible to Dictionary and swapping the instance would detach the typed properties from their store. Callers reset it with clear(). Clone deep copies it so the locate cleanup in BrokerageExtensions, which edits a copy, cannot reach the algorithm's own instance. Co-Authored-By: Claude Opus 5 --- Common/Orders/TerminalLinkOrderProperties.cs | 45 ++++++ .../TerminalLinkOrderPropertiesTests.cs | 147 ++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/Common/Orders/TerminalLinkOrderProperties.cs b/Common/Orders/TerminalLinkOrderProperties.cs index 333133dc0fa0..9849c439eec1 100644 --- a/Common/Orders/TerminalLinkOrderProperties.cs +++ b/Common/Orders/TerminalLinkOrderProperties.cs @@ -15,6 +15,7 @@ */ using System.Collections.Generic; +using QuantConnect.Interfaces; namespace QuantConnect.Orders { @@ -23,6 +24,14 @@ 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 and is only ever added to or removed from; it cannot be replaced + /// wholesale, which keeps the dictionary the single store behind the typed properties below + public Dictionary AdditionalProperties { get; private set; } = []; + /// /// The EMSX Instructions is the free form instructions that may be sent to the broker /// @@ -88,6 +97,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?.GetValueOrDefault("EMSX_CFD_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 +125,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 Dictionary(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/TerminalLinkOrderPropertiesTests.cs b/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs index df86a02882d1..8fcbc20941d7 100644 --- a/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs +++ b/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs @@ -100,5 +100,152 @@ 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() is how a caller resets the dictionary, standing in for the setter it does + // not have; it takes 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 AdditionalPropertiesCannotBeReplacedFromPython() + { + using (Py.GIL()) + { + // The dictionary is add/remove only. A setter would invite assigning a plain Python + // dict, which pythonnet cannot convert to Dictionary, and would let + // a caller swap out the store the typed properties read from. + 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"); + + Assert.Throws(() => getOrderProperties()); + } + } + + [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); + } + } } } From 5ba3892b19a265c06a4d1a8f68ed7766a0cfe009 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Wed, 12 Aug 2026 23:24:50 +0100 Subject: [PATCH 2/3] refactor: back IsCfdTrade with a Python friendly AdditionalProperties Addresses the review suggestion to make IsCfdTrade a pass through like the Bloomberg FIX properties do, over a new AdditionalProperties dictionary keyed by EMSX element name, so the typed property and the raw element are one store and cannot disagree. Uses BaseExtendedDictionary rather than Dictionary so the dictionary behaves like a Python dict. pythonnet has no conversion from a plain dict, so properties.additional_properties = { "EMSX_CFD_FLAG": "1" } throws at runtime; update() takes a PyObject and gives callers the bulk load they reach for, alongside get(), pop(), setdefault() and clear(). Both the working idiom and the failing one are covered by tests. Clone deep copies the dictionary. OrderProperties.Clone is a MemberwiseClone, which would share it, and BrokerageExtensions.RemoveLocateFromNonShortOrder clones precisely so its edits never reach the instance the algorithm holds on to; there is a regression test. Setting IsCfdTrade to false removes the entry rather than writing "0", so the dictionary only ever carries what is explicitly on and an untouched order goes out exactly as before. Co-Authored-By: Claude Opus 5 --- Common/Orders/TerminalLinkOrderProperties.cs | 12 ++--- .../TerminalLinkOrderPropertiesTests.cs | 44 ++++++++++++++++--- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Common/Orders/TerminalLinkOrderProperties.cs b/Common/Orders/TerminalLinkOrderProperties.cs index 9849c439eec1..db0a52dffb1b 100644 --- a/Common/Orders/TerminalLinkOrderProperties.cs +++ b/Common/Orders/TerminalLinkOrderProperties.cs @@ -15,6 +15,7 @@ */ using System.Collections.Generic; +using Common.Util; using QuantConnect.Interfaces; namespace QuantConnect.Orders @@ -28,9 +29,10 @@ 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 and is only ever added to or removed from; it cannot be replaced - /// wholesale, which keeps the dictionary the single store behind the typed properties below - public Dictionary AdditionalProperties { get; private 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; } = []; /// /// The EMSX Instructions is the free form instructions that may be sent to the broker @@ -104,7 +106,7 @@ public class TerminalLinkOrderProperties : OrderProperties /// public bool IsCfdTrade { - get { return AdditionalProperties?.GetValueOrDefault("EMSX_CFD_FLAG") == "1"; } + get { return AdditionalProperties != null && AdditionalProperties.TryGetValue("EMSX_CFD_FLAG", out var flag) && flag == "1"; } set { SetTag("EMSX_CFD_FLAG", value ? "1" : null); } } @@ -134,7 +136,7 @@ public bool IsCfdTrade public override IOrderProperties Clone() { var clone = (TerminalLinkOrderProperties)MemberwiseClone(); - clone.AdditionalProperties = new Dictionary(AdditionalProperties); + clone.AdditionalProperties = new BaseExtendedDictionary(AdditionalProperties); return clone; } diff --git a/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs b/Tests/Common/Orders/TerminalLinkOrderPropertiesTests.cs index 8fcbc20941d7..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; @@ -179,8 +180,8 @@ public void ClearsAdditionalPropertiesFromPython() { using (Py.GIL()) { - // clear() is how a caller resets the dictionary, standing in for the setter it does - // not have; it takes the typed properties reading from it back to their defaults. + // clear() resets the dictionary, taking the typed properties reading from it back to + // their defaults. var module = PyModule.FromString("additionalPropertiesClearModule", @" from AlgorithmImports import * @@ -203,13 +204,40 @@ def getOrderProperties() -> TerminalLinkOrderProperties: } [Test] - public void AdditionalPropertiesCannotBeReplacedFromPython() + public void UpdatesAdditionalPropertiesFromPlainPythonDictionary() { using (Py.GIL()) { - // The dictionary is add/remove only. A setter would invite assigning a plain Python - // dict, which pythonnet cannot convert to Dictionary, and would let - // a caller swap out the store the typed properties read from. + // 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 * @@ -222,7 +250,9 @@ def getOrderProperties() -> TerminalLinkOrderProperties: dynamic getOrderProperties = module.GetAttr("getOrderProperties"); - Assert.Throws(() => getOrderProperties()); + var exception = Assert.Throws(() => getOrderProperties()); + Assert.IsTrue(exception.Message.Contains("cannot be converted", StringComparison.InvariantCulture), + $"Expected a conversion failure, got: {exception.Message}"); } } From 37c6e81fc982c7974954b34acb2c07107bf49e09 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Thu, 13 Aug 2026 00:01:02 +0100 Subject: [PATCH 3/3] refactor: use BaseExtendedDictionary for the FIX AdditionalProperties Gives the FIX custom tags the same Python dict semantics TerminalLink just got. update() takes a PyObject, so a Python algorithm can bulk load its tags from a plain dict, which plain assignment cannot do; get(), pop(), setdefault() and clear() come along with it. Clone copies into a new instance as before. BaseExtendedDictionary implements IDictionary but not IReadOnlyDictionary, so the GetValueOrDefault extension no longer resolves. The Bloomberg locate passthroughs read through a GetTag helper instead, mirroring SetTag, and the transaction handler test uses a local helper of its own. Note that the null conditional form cannot be used with out var - the call is conditional, so the variable is not definitely assigned. The subclasses BloombergFixOrderProperties, TradingTechnologiesOrderProperties and the obsolete FixOrderProperites inherit the property and Clone unchanged; InteractiveBrokersFixOrderProperties extends OrderProperties despite the name and is unaffected. Co-Authored-By: Claude Opus 5 --- Common/Orders/BloombergFixOrderProperties.cs | 11 +++-- Common/Orders/FixOrderProperties.cs | 9 ++-- .../Common/Orders/FixOrderPropertiesTests.cs | 44 +++++++++++++++++++ .../BrokerageTransactionHandlerTests.cs | 8 +++- 4 files changed, 64 insertions(+), 8 deletions(-) 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/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/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