Skip to content

Call the python reset from PythonIndicator.Reset - #9698

Open
mkzung wants to merge 2 commits into
QuantConnect:masterfrom
mkzung:bug-9697-python-indicator-reset
Open

Call the python reset from PythonIndicator.Reset#9698
mkzung wants to merge 2 commits into
QuantConnect:masterfrom
mkzung:bug-9697-python-indicator-reset

Conversation

@mkzung

@mkzung mkzung commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

PythonIndicator never overrode Reset(), so a reset defined in python was never called and _isReady stayed set.

Related Issue

#9697

Motivation and Context

indicator_history resets before it replays, so it returned values mixed with whatever the indicator had already seen. Same class as #9686, #9687, #9688 and #9694, one level up.

Requires Documentation Change

No.

How Has This Been Tested?

Added ResetClearsTheStateHeldInPython to PythonIndicatorTests. It runs in all six python fixtures, both wrapping paths and both naming conventions, and fails on master at 100.75 against 100. The two duck-typed fixtures gained the reset they were missing.

Full suite, --filter "TestCategory!=TravisExclude&TestCategory!=ResearchRegressionTests", run on pristine master and on this branch:

master      36614 tests, 3 failed
this branch 36620 tests, 3 failed

Same three, and they fail on master as well: CommanCallback(Python), ThreadSafety, ZipBytesReturnsByteArrayWithCorrectLength. The extra six are the new test in its six fixtures.

One note on the guard: GetMethod throws when the attribute is missing altogether and returns null only when it resolves to C#, so an unguarded call breaks every python indicator that has no reset. The first version of this did exactly that, and only the full run caught it.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description>

@Martin-Molinero Martin-Molinero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @mkzung! Thanks, leaving a few comments

Fix works for the plain non-inheriting case, but two confirmed regressions: segfault via super().reset() in inheriting classes, and uncaught AttributeError from the HasAttr/GetPythonMethod name asymmetry. Resolving the reset method once in SetIndicator + a reentrancy guard would cover most findings — details inline.

Comment thread Indicators/PythonIndicator.cs Outdated
{
using (Py.GIL())
{
_indicatorWrapper.GetMethod(nameof(Reset), pythonOnly: true)?.Invoke().Dispose();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For inheriting classes _indicatorWrapper wraps the instance itself, so a subclass reset() calling super().reset() (the pre-PR correct pattern) recurses: C# Reset() → python reset() → CLR binding → C# Reset()... Reproduced on this branch: ~995 frames, fatal 0xC0000005. A reentrancy guard is needed here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and it is on the normal path. WrapPythonIndicator calls TryConvert, which for an inheriting class hands back that object's own C# part, then points SetIndicator at the same object.

I counted the depth in python rather than reading the crash. The test increments a counter on entry to reset() and stops calling super() at 200, so re-entry arrives as a number instead of taking the host down. On the previous commit it reaches the cap. It is 1 now, and zero would mean the python reset was never reached, so one assertion covers both directions.

The ~995 frames you saw are that same loop without the cap.

Reset invokes the python side once and lets the re-entrant call fall through to the base. The flag is saved and restored rather than cleared, so a reset() calling super() twice cannot re-arm it.

Comment thread Indicators/PythonIndicator.cs Outdated
public override void Reset()
{
// GetMethod throws when the attribute is absent, and returns null when it is CSharp
if (_indicatorWrapper != null && _indicatorWrapper.HasAttr(nameof(Reset)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

HasAttr is snake-case tolerant but GetPythonMethod falls back to PascalCase-only GetAttr("Reset") — so self.reset = False (non-method, no Reset) passes the guard then throws an uncaught AttributeError; worked before this PR. Also, a non-bound-method callable (self.Reset = lambda: ...) is silently skipped. Suggest resolving the reset method once in SetIndicator, like _pythonIsReadyProperty — also removes the per-call HasAttr GIL round-trip and avoids caching null under "Reset" in _pythonMethods (keyed by name only, ignores pythonOnly).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the way you suggested, and the shape is not new here: AlgorithmPythonWrapper resolves OnData and OnMarginCall into fields at construction.

Resolution happens once in SetIndicator through GetPythonMethodWithChecks, snake-case first and PascalCase second, both behind HasAttr. So self.reset = False now resolves to null rather than reaching GetAttr("Reset"), and null no longer lands in _pythonMethods under a key that ignores pythonOnly. The per-call round trip goes with it.

The non-bound callable I have left alone, deliberately.

That <class 'method'> test inside GetPythonMethod is doing real work: it is what separates a python override from the inherited C# binding. Accept any callable and every inheriting class starts looking like it overrides reset. Two wrappers call the helper directly and BasePythonWrapper routes every InvokeMethod through it, so I would rather widen it on its own.

One consequence of resolving once, since you were the one who flagged the caching: repeated SetIndicator now has something to release. GetIndicatorAsManagedObject calls it from twenty sites in IndicatorExtensions with no caching, unlike WrapPythonIndicator which keys off the handle, so the field is disposed before it is replaced. I stopped there. AlgorithmPythonWrapper releases its equivalents in Dispose, but PythonIndicator has no Dispose and does not release _instance or _indicatorWrapper either, and adding one is a lifetime contract rather than a fix.

Comment thread Indicators/PythonIndicator.cs Outdated
_indicatorWrapper.GetMethod(nameof(Reset), pythonOnly: true)?.Invoke().Dispose();
}
}
_isReady = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the python reset() raises, _isReady = false / base.Reset() are skipped → half-reset indicator. Run them before the invoke or in a finally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed. Both are in a finally now.

indicator.Reset();
indicator.Update(new IndicatorDataPoint(reference, 100m));

Assert.AreEqual(100m, indicator.Current.Value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also assert IsReady is false (or Samples == 0) after Reset() — dropping _isReady = false wouldn't fail this test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sharper than it first looked, because the assertion would have been vacuous as well as missing. The indicator has period 14 and the test fed it three points, so it was never ready, and asserting IsReady is false after a reset would have passed against any implementation at all.

It feeds 20 now and asserts ready first.

Dropping _isReady = false turns it red in all six fixtures that inherit PythonIndicatorTests.

A subclass reset() calling super().reset() came back through the CLR binding and
recursed. Resolving the method in SetIndicator also stops a non-method reset
attribute reaching GetAttr("Reset"), and the base reset now runs even when the
python side raises.
@mkzung

mkzung commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

All four reproduced and fixed. Second commit rather than an amend, so these threads stay attached.

The three new cases build their own python classes and never touch CreateIndicator, so they sit in their own PythonIndicatorResetTests pair. Inheriting them from PythonIndicatorTests put a test about an inheriting class inside PythonIndicatorNoinheritanceTests, and ran each of them in four more fixtures without changing a thing they exercised. The strengthened ResetClearsTheStateHeldInPython stays where it is, since it does use CreateIndicator.

Five runs fail on the previous commit, none on this one. The new file adds no analyzer warning beyond the two CA1515 that every public fixture here already emits.

A caveat instead of a clean number for the wider suite. --filter FullyQualifiedName~QuantConnect.Tests.Indicators aborts partway on my machine, because a host wants Data/equity/oanda/map_files and my clone does not have it. It aborts identically on untouched master, so it is not from this change, but it does mean both runs are partial rather than a full sweep. Master gives 2771 passed and 0 failed, this branch 2783 and 0 failed, and the difference of 12 is the four reset tests: three in two fixtures, one in six.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants