Skip to content

Bind extension methods and emit the number protocol for implicit numeric conversions - #104

Merged
jhonabreul merged 5 commits into
QuantConnect:masterfrom
jhonabreul:feature-pythonnet-extension-methods-and-numeric-operators
Oct 29, 2026
Merged

jhonabreul merged 5 commits into
QuantConnect:masterfrom
jhonabreul:feature-pythonnet-extension-methods-and-numeric-operators

Conversation

@jhonabreul

@jhonabreul jhonabreul commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Python.NET (QuantConnect/pythonnet#150 and QuantConnect/pythonnet#151) binds C# extension methods to the type they extend and gives types with an implicit numeric conversion the Python number protocol. The stubs describe neither, so editors flag order_event.status.is_open() or fee + 1 as errors.

The change:

  • MethodParser records the extended type of static methods whose first parameter is this.
  • Generator.BindExtensionMethods clones each one onto the extended class as an instance method, dropping the this parameter. The static form stays.
  • Like Python.NET, only targets involving a non-framework type are bound: extensions declared in or targeting System and Microsoft namespaces, generic targets, targets without a stub class and names the target or its bases already declare are skipped.
  • Extension methods marked [EditorBrowsable(EditorBrowsableState.Never)], or declared in a class marked so, keep their static form only.
  • ClassParser records the Python number a class's implicit conversion maps to, int for integral conversions and float for the others, unless the operator is marked [StubsIgnore].
  • Generator.AddImplicitNumericOperators emits __add__, __sub__ and __mul__ with Self and float overloads, plus int overloads returning int for integral conversions, their reflected forms, __truediv__ returning float in every case, comparisons, __neg__, __float__ and __int__.
  • An operator the class declares or inherits is kept and not synthesized in either direction, tracked in Class.DeclaredOperators.

QuantConnect/pythonnet#150 and #151 shipped in 2.0.67, and Lean is on it since QuantConnect/Lean#9964. It still depends on QuantConnect/Lean#9988, which marks OrderTicket's implicit operator int with StubsIgnore and hides engine-only extensions with EditorBrowsable: until it lands, tickets get the full number protocol and Symbol, Order and IAlgorithm show helpers like requires_mapping or process_security_changes.

It also carries b4c9903, which keeps an IReadOnlyDictionary base as the .NET interface instead of typing.Dict. That repairs master's pyright check, red since QuantConnect/Lean#9914; the details are in the comment below.

Known gap: Python.NET also binds extensions whose target is a framework container of a Lean type, such as IEnumerable<Slice>, onto the matching closed generic class. Those targets render as typing.Iterable[Slice], which has no stub class to attach to, so the stubs keep only the static form. Editors flag a few calls that work, never the reverse.

Tests:

  • GeneratorTests.ExtensionMethodsAreBoundToTheExtendedType: enum, interface, name collision, generic and native targets, static form kept.
  • GeneratorTests.FrameworkExtensionMethodsAreNotBound and FrameworkTargetsAreNotBound: extensions declared in a framework namespace or targeting object, IEnumerable and the like are not bound, their static forms stay.
  • GeneratorTests.EditorBrowsableNeverExtensionMethodsKeepTheStaticFormOnly: hidden methods and hidden classes stay static.
  • GeneratorTests.ImplicitNumericConversionAddsTheNumberProtocol: full dunder set for a decimal and an integral conversion, overload order, conversions from other types or marked StubsIgnore add nothing.
  • GeneratorTests.DeclaredOrInheritedOperatorsAreNotSynthesizedInEitherDirection: an operator declared in one direction, or inherited, suppresses both synthesized directions.
  • GeneratorTests.ReadOnlyDictionaryBaseIsNotNormalizedToDict: the base stays IReadOnlyDictionary, a property of that type still renders as typing.Dict.
  • RendererTests case RendersBoundExtensionMethodsAndNumericOperators: rendered output of a bound enum method and the operators.
  • Generated from current Lean master: pyright 1.1.338 reports 0 errors, OrderStatus gains is_open, OrderFee the number protocol, convert_invariant stays static. Lean's syntax check is unchanged at 465/470 locally.
  • End to end: a Python algorithm on Lean with pythonnet 2.0.67 calls the bound enum, class, interface and generic methods and the OrderFee and InitialMargin operators in both directions. Every call returns the expected value, and mypy accepts the algorithm with these stubs (see the comment below).

@Martin-Molinero

Copy link
Copy Markdown
Member

Review follow-up: pushed e305f27

Where: Generator.cs, Model/Method.cs, Parser/BaseParser.cs, Parser/MethodParser.cs, GeneratorTests.cs (commit e305f27, Match Python.NET's rules for framework targets, hidden extensions and declared operators)

Why

I generated the stubs from Lean master 154e50720c with this PR and compared them with what pythonnet actually binds after QuantConnect/pythonnet#150 and #151. In three places the stubs accepted calls that fail at runtime, which is the direction the PR description warns about:

  1. Lean extensions landed on .NET types. The namespace filter checked where the extension is declared, not the type it extends. The generator parses .NET's own sources, so those types have stub classes to land on:

    • System.Object.convert_invariant. Every stub class inherits System.Object, so every class showed it.
    • IEnumerable.to_py_list and to_py_list_un_safe
    • IDisposable.dispose_safely
    • IConvertible and IFormattable.to_string_invariant
    • StreamReader.get_decimal, get_int_32 and friends, and Stream.deserialize_json

    Pythonnet binds none of these: since #150 it skips targets made only of framework types.

  2. [EditorBrowsable(EditorBrowsableState.Never)] wasn't honored. #150 keeps such extension methods, and all extension methods of a class marked so, in their static form only. It's the standard .NET way to say "callable, but don't advertise it", so Lean can hide plumbing helpers like its IAlgorithm extensions, which would otherwise show up as self.set_runtime_error() and similar.

  3. Reflected operators on types that declare their own. Pythonnet synthesizes an operator in both directions or not at all: if the type declares or inherits any +, no float overloads are added in either direction. The stubs only skipped the forward form. For example, MultiPeriodField got __radd__, __rsub__, __rmul__ and __rtruediv__ taking a float, but 1.0 + field raises TypeError at runtime.

How

  1. BindExtensionMethods also skips extended types in System/Microsoft namespaces. One IsFrameworkNamespace helper is shared with the existing filter on the declaring namespace.
  2. The parser flags Method.EditorBrowsableNever when the extension method, or its declaring class, carries the attribute. It matches the short, Attribute-suffixed and qualified names, with Never, EditorBrowsableState.Never or a qualified state. Advanced is not affected. BindExtensionMethods skips flagged methods, and the static form stays.
  3. AddImplicitNumericOperators checks declared operators on the class and its bases. A declared arithmetic operator suppresses both its forward and reflected synthesized forms.

Result on Lean master

  • The generated stubs only lose members, with nothing added:
    • the Lean extensions listed in item 1, which were bound onto .NET types
    • the reflected float operators of MultiPeriodField, and of System.Runtime.InteropServices.NFloat, which also declares its operators
    • the now-unused generic helper classes and import QuantConnect in the System stubs
  • OrderStatus.is_open, the full IndicatorBase number protocol and the other bound methods are unchanged.
  • pyright 1.1.338 with the integration test's config: 0 errors, both before and after.

Tests

  • FrameworkTargetsAreNotBound: extensions on a System.* class and interface aren't bound to them or to a QuantConnect class implementing the interface, and the static forms stay.
  • EditorBrowsableNeverExtensionMethodsKeepTheStaticFormOnly: covers method-level, qualified and class-level Never, checks that Advanced stays bound, and that the static forms stay.
  • DeclaredOrInheritedOperatorsAreNotSynthesizedInEitherDirection: a class declaring + in one direction gets no __radd__, and a class inheriting - gets neither __sub__ nor __rsub__.
  • All three fail without the change. The suite runs 82/82.

Note: found while reviewing: a CLR type declaring an operator in one direction only made 1.0 + x recurse until RecursionError in pythonnet itself. That's fixed separately in QuantConnect/pythonnet#152 and doesn't affect the stubs.

🤖 Generated with Claude Code

jhonabreul and others added 3 commits October 28, 2026 09:16
… declared operators

The stubs accepted calls that fail at runtime:
- Lean extensions on framework types were bound to them: every class got
  convert_invariant from System.Object, IEnumerable got to_py_list,
  IDisposable dispose_safely, StreamReader get_decimal... Python.NET only
  binds targets involving a non-framework type, so targets in System or
  Microsoft namespaces are skipped too.
- Extension methods marked [EditorBrowsable(EditorBrowsableState.Never)],
  or declared in a class marked so, keep their static form only, like in
  Python.NET.
- An operator the class declares suppressed only its forward synthesized
  form: MultiPeriodField got __radd__(float) and friends while Python.NET
  synthesizes an operator in both directions or not at all. Declared or
  inherited operators now win in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jhonabreul
jhonabreul force-pushed the feature-pythonnet-extension-methods-and-numeric-operators branch from e305f27 to 0275603 Compare October 28, 2026 13:16
@jhonabreul

Copy link
Copy Markdown
Contributor Author

CI was failing on master too, since QuantConnect/Lean#9914 moved Common.Util to QuantConnect.Util. That turned on base type normalization for ReadOnlyExtendedDictionary: its IReadOnlyDictionary base became typing.Dict while the GetEnumerator it inherits adds typing.Iterable[KeyValuePair]. A dict iterates keys and the enumerator yields pairs, so pyright reports Cannot create consistent method ordering.

b4c9903 fixes it in ClassParser.ParseInheritedTypes: an IReadOnlyDictionary base is kept as the .NET interface, like IDictionary already was. This is the only class in the stubs with a typing.Dict base, so the generated output changes in that one line.

The typing.Dict rule stays in NormalizeType for members. A property typed IReadOnlyDictionary still renders as typing.Dict, which is what lets a Python subclass override default_markets with a dict literal (#94). Removing the rule instead fails CustomBrokerageModelRegressionAlgorithm.py in Lean's syntax check with Incompatible types in assignment and takes master below the 99.1% threshold. Only a base list claims the class iterates keys, so only that use is excluded.

Test: GeneratorTests.ReadOnlyDictionaryBaseIsNotNormalizedToDict, the base stays IReadOnlyDictionary next to the Iterable from GetEnumerator, and a property of that type still renders as typing.Dict. Pyright with the integration test's config reports 0 errors on stubs generated from current Lean master, and Lean's syntax check is unchanged at 465/470 locally.

@jhonabreul jhonabreul added the kronos:review Request review label Oct 28, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued kronos:review:working review: in progress and removed kronos:review Request review kronos:review:queued review: queued labels Oct 28, 2026

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The code looks right, but this can't merge until its runtime dependencies ship, and the PR also carries an unrelated master CI fix.

Blocking

  1. The runtime support hasn't shipped yet. QuantConnect/pythonnet#150 and #151 are still open, and Lean still references QuantConnect.pythonnet 2.0.66. If this merged now, the stubs would advertise members that fail at runtime for every user: order_status.is_open(), fee + 1, float(margin), order.is_contingent(), symbol.requires_mapping() and the rest. The description says as much. Please keep this open until those PRs are released and Lean is on that version.
  2. End-to-end run on the updated Lean. Pyright and the unit tests show that the stubs are consistent with themselves, not that each member exists at runtime. Once Lean is bumped, please run a Python algorithm that calls a sample of what this PR adds and post the outcome. The sample should cover: a bound enum method (OrderStatus.FILLED.is_open()), a bound class method (order.is_contingent()), a bound interface method (IPositionGroup.is_empty() on a position group), a generic one (slice.try_get[TradeBar](symbol, None)), and the operators on OrderFee and InitialMargin in both directions (fee + 1, 1 + fee, fee < 2, float(fee), -fee). I checked the generic case against #151's branch myself, with a test of my own: target.kind[String]() and target.try_get[String](1, None) work through the instance, so _Slice_TryGet and _Universe_TryGetUniverse match the runtime.
  3. Move b4c9903 into its own PR. The IReadOnlyDictionary base fix has nothing to do with this feature. It repairs master's CI, which has been red since bc8c62d (run of 2026-10-16). While it rides here, master stays red for as long as this PR waits on pythonnet. On its own it's a small, separately revertable fix.
  4. The description is out of date. It doesn't cover 0275603 (the extended type's namespace is now checked as well as the declaring one, [EditorBrowsable(Never)] extensions aren't bound, and a declared or inherited operator suppresses both directions). Its test list is also missing FrameworkTargetsAreNotBound, EditorBrowsableNeverExtensionMethodsKeepTheStaticFormOnly and DeclaredOrInheritedOperatorsAreNotSynthesizedInEitherDirection. Please also note that it depends on Lean#9896: until that lands, OrderTicket gets the full number protocol.

What I checked

  • CI is green, and dotnet test passes 93/93 locally.
  • I generated stubs from current Lean master with master and with this branch, then diffed the members. Both runs used the same partial dotnet/runtime checkout, which only affects System stubs. Nothing on the Lean side was removed or changed beyond reordering. The PR adds about 150 bound extension methods and the dunder set on the 11 classes with an implicit numeric conversion. System.Object, IEnumerable and the other framework types get nothing, and MultiPeriodField only gets the comparisons, __neg__, __float__ and __int__, as expected.
  • I compared the rules with #150/#151:
    • Framework-only targets, open-generic targets, EditorBrowsable(Never) on the method or its class, real members winning, and the conversion operator's parameter having to be the type itself all match.
    • Declared operators: pythonnet skips any op_* name the type declares or inherits, and the stubs do the same through DeclaredOperators on the class and its bases.
    • The non-generic IndicatorBase gets no synthesized dunders in the output because it renders as a typing.Any alias, so this PR doesn't change it.
  • Known gaps, all non-blocking:
    • pythonnet also binds closed generic and array targets over Lean types, such as List<Symbol> and Symbol[], and the stubs skip them. That leaves editors flagging calls that would work, which is the safe direction.
    • For an integral conversion, pythonnet returns int from + - * neg, while the stubs always say float. OrderTicket is the only such type in Lean today, and Lean#9896 excludes it.
    • Lean's IAlgorithm plumbing extensions (set_runtime_error, process_security_changes, get_or_add_unrequested_security...) and many internal helpers on Symbol, Order and SubscriptionDataConfig now show up on instances. That matches the runtime, but hiding them is up to Lean (EditorBrowsable(Never)) and should ideally happen before both changes ship.

Affected repositories

Risk

I set risk:high. The PR adds about 150 members and the number protocol to user-facing stubs, and it depends on runtime changes that haven't shipped. The end-to-end run above on a Lean with the new pythonnet is the evidence it still lacks.

Comment thread QuantConnectStubsGenerator/Parser/ClassParser.cs
Comment thread QuantConnectStubsGenerator/Parser/ClassParser.cs
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added risk:high High risk and removed kronos:review:working review: in progress labels Oct 28, 2026
@jhonabreul

Copy link
Copy Markdown
Contributor Author

Replies to the blocking items:

  1. Runtime shipped. Bind extension methods to the extended type so they are callable from the instance pythonnet#150, #151 and #152 are in QuantConnect.pythonnet 2.0.67, and Lean references it since Update QuantConnect.pythonnet to 2.0.67 Lean#9964. Add OrderEvent synonyms, OrderType and GroupId, OrderFee.Amount and combo ticket helpers Lean#9896 is still open; this PR waits for it.

  2. End-to-end run. The algorithm below ran as a backtest on Lean feature-order-event-synonyms at a0de170d2 (master a3bf4cc3e plus Add OrderEvent synonyms, OrderType and GroupId, OrderFee.Amount and combo ticket helpers Lean#9896), with QuantConnect.pythonnet 2.0.67 and Python 3.11, on SPY minute data for 2013-10-07 and 2013-10-08.

    It covers the sample you asked for: OrderStatus.FILLED.is_open(), order.is_contingent() on a plain limit order and on bracket orders, IPositionGroup.is_empty() on an empty group and on the portfolio's group, slice.try_get[TradeBar](symbol, None) for a present and a missing symbol, and the OrderFee and InitialMargin operators in both directions. Every check compares the call with its expected value and raises on a mismatch, so the run only reaches ALL CHECKS PASSED when all of them hold.

    The same file type-checks with the stubs generated from this branch (524a153) against that Lean, using Lean's mypy.ini and the syntax check's mypy: Success: no issues found in 1 source file. With stubs generated from master, mypy reports 32 errors on the same file, all of them the members this PR adds: "OrderStatus" has no attribute "is_open", "Order" has no attribute "is_contingent", "IPositionGroup" has no attribute "is_empty", "Slice" has no attribute "try_get" and Unsupported operand types for + ("OrderFee" and "int") and the like.

    Algorithm log
    2013-10-07 09:31:00 slice.try_get[TradeBar](spy, None) found -> True (bool)
    2013-10-07 09:31:00 slice.try_get[TradeBar](spy, None) bar -> 144.773077904 (float)
    2013-10-07 09:31:00 slice.try_get[TradeBar](ibm, None) found -> False (bool)
    2013-10-07 09:31:00 PositionGroup.empty(...).is_empty() -> True (bool)
    2013-10-07 09:31:00 InitialMargin(10) + 1 -> 11.0 (float)
    2013-10-07 09:31:00 1 + InitialMargin(10) -> 11.0 (float)
    2013-10-07 09:31:00 InitialMargin(10) < 2 -> False (bool)
    2013-10-07 09:31:00 InitialMargin(10) > 2 -> True (bool)
    2013-10-07 09:31:00 float(InitialMargin(10)) -> 10.0 (float)
    2013-10-07 09:31:00 -InitialMargin(10) -> -10.0 (float)
    2013-10-07 09:31:00 InitialMargin(10) * 2.5 -> 25.0 (float)
    2013-10-07 09:31:00 InitialMargin(10) / 4 -> 2.5 (float)
    2013-10-07 09:31:00 get_initial_margin_requirement(...).value -> 7238.6538952 (float)
    2013-10-07 09:31:00 get_initial_margin_requirement(...) + 1 -> 7239.6538952 (float)
    2013-10-07 09:31:00 1 + get_initial_margin_requirement(...) -> 7239.6538952 (float)
    2013-10-07 09:31:00 float(get_initial_margin_requirement(...)) -> 7238.6538952 (float)
    2013-10-07 09:31:00 -get_initial_margin_requirement(...) -> -7238.6538952 (float)
    2013-10-07 09:31:00 order 1 event status.is_open() -> True (bool)
    2013-10-07 09:31:00 order 2 event status.is_open() -> True (bool)
    2013-10-07 09:31:00 order 3 event status.is_open() -> True (bool)
    2013-10-07 09:31:00 order 4 event status.is_open() -> True (bool)
    2013-10-07 09:31:00 order 2 event status.is_open() -> False (bool)
    2013-10-07 09:31:00 order_event.order_fee.value.amount -> 1.0 (float)
    2013-10-07 09:31:00 fee + 1 -> 2.0 (float)
    2013-10-07 09:31:00 1 + fee -> 2.0 (float)
    2013-10-07 09:31:00 fee < 2 -> True (bool)
    2013-10-07 09:31:00 fee > 0 -> True (bool)
    2013-10-07 09:31:00 float(fee) -> 1.0 (float)
    2013-10-07 09:31:00 -fee -> -1.0 (float)
    2013-10-07 09:31:00 sum([fee, fee]) -> 2.0 (float)
    2013-10-07 09:31:00 max([fee, OrderFee.ZERO]) is fee -> True (bool)
    2013-10-07 09:31:00 limit order .is_contingent() -> False (bool)
    2013-10-07 09:31:00 bracket entry order .is_contingent() -> True (bool)
    2013-10-07 09:31:00 bracket take profit order .is_contingent() -> True (bool)
    2013-10-07 09:31:00 OrderStatus.FILLED.is_open() -> False (bool)
    2013-10-07 09:31:00 OrderStatus.SUBMITTED.is_open() -> True (bool)
    2013-10-07 09:31:00 entry.status -> <QuantConnect.Orders.OrderStatus object at 0x000001B4A9571A80> (OrderStatus)
    2013-10-07 09:31:00 entry.status.is_open() -> False (bool)
    2013-10-07 09:31:00 take_profit.status -> <QuantConnect.Orders.OrderStatus object at 0x000001B4A94B5B40> (OrderStatus)
    2013-10-07 09:31:00 take_profit.status.is_open() -> True (bool)
    2013-10-07 09:31:00 OrderStatus.CANCELED.is_closed() -> True (bool)
    2013-10-07 09:31:00 len(portfolio.positions.groups) -> 1 (int)
    2013-10-07 09:31:00 portfolio.positions.groups[0].is_empty() -> False (bool)
    2013-10-08 16:00:00 ALL CHECKS PASSED
    
    ExtensionMethodsAndOperatorsAlgorithm.py
    from AlgorithmImports import *
    
    
    class ExtensionMethodsAndOperatorsAlgorithm(QCAlgorithm):
        """Calls a sample of the members quantconnect-stubs-generator#104 adds, on Lean with QuantConnect.pythonnet 2.0.67."""
    
        def initialize(self) -> None:
            self.set_start_date(2013, 10, 7)
            self.set_end_date(2013, 10, 8)
            self.set_cash(100000)
            self._spy = self.add_equity("SPY", Resolution.MINUTE).symbol
            self._tickets: List[OrderTicket] = []
            self._fee_checked = False
    
        def check(self, label: str, value: Any, expected: Any = None) -> None:
            self.log(f"{label} -> {value!r} ({type(value).__name__})")
            if expected is not None and value != expected:
                raise ValueError(f"{label}: expected {expected!r}, got {value!r}")
    
        def on_data(self, slice: Slice) -> None:
            if self._tickets:
                return
    
            # generic extension method: SliceExtensions.TryGet<T>(this Slice, Symbol, out T)
            found, bar = slice.try_get[TradeBar](self._spy, None)
            self.check("slice.try_get[TradeBar](spy, None) found", found, True)
            self.check("slice.try_get[TradeBar](spy, None) bar", bar.close)
            ibm = Symbol.create("IBM", SecurityType.EQUITY, Market.USA)
            found, bar = slice.try_get[TradeBar](ibm, None)
            self.check("slice.try_get[TradeBar](ibm, None) found", found, False)
    
            # interface extension method: PositionGroupExtensions.IsEmpty(this IPositionGroup)
            empty_group = PositionGroup.empty(SecurityPositionGroupBuyingPowerModel())
            self.check("PositionGroup.empty(...).is_empty()", empty_group.is_empty(), True)
    
            # implicit decimal conversion: InitialMargin gets the number protocol, both directions
            margin = InitialMargin(10)
            self.check("InitialMargin(10) + 1", margin + 1, 11)
            self.check("1 + InitialMargin(10)", 1 + margin, 11)
            self.check("InitialMargin(10) < 2", margin < 2, False)
            self.check("InitialMargin(10) > 2", margin > 2, True)
            self.check("float(InitialMargin(10))", float(margin), 10.0)
            self.check("-InitialMargin(10)", -margin, -10)
            self.check("InitialMargin(10) * 2.5", margin * 2.5, 25)
            self.check("InitialMargin(10) / 4", margin / 4, 2.5)
    
            security = self.securities[self._spy]
            requirement = security.buying_power_model.get_initial_margin_requirement(InitialMarginParameters(security, 100))
            self.check("get_initial_margin_requirement(...).value", requirement.value)
            self.check("get_initial_margin_requirement(...) + 1", requirement + 1, requirement.value + 1)
            self.check("1 + get_initial_margin_requirement(...)", 1 + requirement, requirement.value + 1)
            self.check("float(get_initial_margin_requirement(...))", float(requirement), float(requirement.value))
            self.check("-get_initial_margin_requirement(...)", -requirement, -requirement.value)
    
            # class extension method: ContingentOrderExtensions.IsContingent(this Order)
            price = security.price
            plain = self.limit_order(self._spy, 10, price * 0.9)
            self._tickets = list(self.bracket_order(self._spy, 100, take_profit_price=price * 1.05, stop_loss_price=price * 0.95))
            entry, take_profit, stop_loss = self._tickets
            self.check("limit order .is_contingent()", self.transactions.get_order_by_id(plain.order_id).is_contingent(), False)
            self.check("bracket entry order .is_contingent()", self.transactions.get_order_by_id(entry.order_id).is_contingent(), True)
            self.check("bracket take profit order .is_contingent()", self.transactions.get_order_by_id(take_profit.order_id).is_contingent(), True)
    
            # enum extension method: OrderExtensions.IsOpen(this OrderStatus)
            self.check("OrderStatus.FILLED.is_open()", OrderStatus.FILLED.is_open(), False)
            self.check("OrderStatus.SUBMITTED.is_open()", OrderStatus.SUBMITTED.is_open(), True)
            self.check("entry.status", entry.status, OrderStatus.FILLED)
            self.check("entry.status.is_open()", entry.status.is_open(), False)
            self.check("take_profit.status", take_profit.status, OrderStatus.SUBMITTED)
            self.check("take_profit.status.is_open()", take_profit.status.is_open(), True)
            self.check("OrderStatus.CANCELED.is_closed()", OrderStatus.CANCELED.is_closed(), True)
    
            # interface extension on the live groups, now that the entry filled
            groups = list(self.portfolio.positions.groups)
            self.check("len(portfolio.positions.groups)", len(groups), 1)
            self.check("portfolio.positions.groups[0].is_empty()", groups[0].is_empty(), False)
    
        def on_order_event(self, order_event: OrderEvent) -> None:
            self.check(f"order {order_event.order_id} event status.is_open()", order_event.status.is_open(), order_event.status in (OrderStatus.SUBMITTED, OrderStatus.PARTIALLY_FILLED))
            if order_event.status != OrderStatus.FILLED or self._fee_checked:
                return
            self._fee_checked = True
    
            # implicit decimal conversion: OrderFee gets the number protocol, both directions
            fee = order_event.order_fee
            self.check("order_event.order_fee.value.amount", fee.value.amount)
            self.check("fee + 1", fee + 1, fee.value.amount + 1)
            self.check("1 + fee", 1 + fee, fee.value.amount + 1)
            self.check("fee < 2", fee < 2, fee.value.amount < 2)
            self.check("fee > 0", fee > 0, True)
            self.check("float(fee)", float(fee), float(fee.value.amount))
            self.check("-fee", -fee, -fee.value.amount)
            self.check("sum([fee, fee])", sum([fee, fee]), 2 * fee.value.amount)
            self.check("max([fee, OrderFee.ZERO]) is fee", max([fee, OrderFee.ZERO]) is fee, True)
    
        def on_end_of_algorithm(self) -> None:
            if not self._tickets or not self._fee_checked:
                raise ValueError("Not every check ran")
            self.log("ALL CHECKS PASSED")

    On the known gaps: 524a153 types integral conversions as Python ints, so + - * neg on an integral conversion now return int like pythonnet does. Lean#9896 marks the engine-only extensions (set_runtime_error, process_security_changes, requires_mapping and the rest) with EditorBrowsable(Never), so they keep their static form only. The closed generic and array targets stay a documented gap.

  3. b4c9903 stays in this PR. It is small enough: one condition in ClassParser.ParseInheritedTypes, its own test, and a one-line change in the generated output, explained in the comment above.

  4. Description updated. It now covers 0275603, the test list, the IReadOnlyDictionary fix, the end-to-end run and the current state of the dependencies.

@jhonabreul jhonabreul added the kronos:review Request review label Oct 28, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued and removed kronos:review Request review labels Oct 28, 2026
@jhonabreul jhonabreul added kronos:review Request review and removed kronos:review:queued review: queued labels Oct 29, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued kronos:review:working review: in progress and removed kronos:review Request review kronos:review:queued review: queued labels Oct 29, 2026

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Everything I blocked on last time has been addressed, and the new commit (524a153) matches the runtime.

Since my last review

  1. Runtime support has shipped. I confirmed that QuantConnect/pythonnet#150, #151 and #152 are on pythonnet's master, and that QuantConnect/Lean#9964 (pythonnet 2.0.67) is merged.
  2. End-to-end run. The algorithm in the comment above covers every sample I asked for: a bound enum, class, interface and generic method, plus the OrderFee and InitialMargin operators in both directions. Each check raises on a mismatch, so the run can only reach ALL CHECKS PASSED if they all hold. mypy also goes from 32 errors with master's stubs to 0 with this branch's. That's convincing.
  3. b4c9903 stays in this PR. I accept the reasoning: it's one condition with its own test and a one-line change in the output, and this PR's integration check needs it to go green.
  4. The description is up to date.
  5. I'm withdrawing my inline comment on HasImplicitConversionToString. The method already exists on master, so keeping it keeps the diff smaller.

524a153: integral conversions typed as int

  • I checked it against ImplicitNumericOps<T> in pythonnet's master, and every case matches:
    • It picks the first op_Implicit to a numeric type whose parameter is the type itself.
    • Integral conversions give exact ints for + - * and neg when the other operand is an int (or an instance of the type), and floats otherwise.
    • / always gives a float.
    • Reversed comparisons are left to Python's reflection, which needs no stub overloads.
    • Declared or inherited op_* names win.
  • The shared PythonType.Int, Float and Bool instances are safe. The only later pass that changes types, TryReplaceGenericTypes, copies them first (new PythonType(type)).
  • dotnet test: 93/93 pass locally. CI is green.
  • I generated stubs from current Lean master. OrderTicket (integral, because Lean#9896 isn't merged yet) and OrderFee (decimal) come out as described. With pyright 1.1.338, t + 1, 1 + t, -t and t * t give int, t + 1.5 and t / 2 give float, fee + 1 and 2 - fee give float, and there are 0 errors.

Merge timing

Lean#9896 is still open. Merging before it lands doesn't advertise anything that fails at runtime: OrderTicket really does get the number protocol, and the engine helpers really are callable. But the published stubs would show those helpers (requires_mapping, process_security_changes, ...) on Symbol, Order and IAlgorithm. As the description says, merging after Lean#9896 avoids that.

Affected repositories

  • Lean: QuantConnect/Lean#9896 (the StubsIgnore on OrderTicket and EditorBrowsable(Never) on engine-only extensions). Nothing else needs an update.

Risk

I kept risk:high. This adds about 150 members and the number protocol to the user-facing stubs, which counts as a user-facing API addition however good the evidence is. A human should merge it, ideally after Lean#9896.

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot removed the kronos:review:working review: in progress label Oct 29, 2026
@jhonabreul

Copy link
Copy Markdown
Contributor Author

The Lean-side attributes moved from QuantConnect/Lean#9896 to QuantConnect/Lean#9988, which only carries the EditorBrowsable(Never) and StubsIgnore changes plus a test that the hidden extensions are not bound to Python instances. This PR now pairs with #9988; the description is updated.

@jhonabreul
jhonabreul merged commit 632ba84 into QuantConnect:master Oct 29, 2026
1 check passed
@jhonabreul
jhonabreul deleted the feature-pythonnet-extension-methods-and-numeric-operators branch October 29, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:high High risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants