Skip to content

Commit 84d548b

Browse files
committed
Adds Custom Python Indicator Support for QCAlgorithm.PlotIndicator
In order to add support custom python indicators for `QCAlgorithm.PlotIndicator`, we created a `PythonIndicator` class that wraps the custom python indicator. In `QCAlgorithm`, the reference of the wrapper is saved into a dictionary keyed by the python indicator handle.
1 parent 26e4172 commit 84d548b

5 files changed

Lines changed: 159 additions & 34 deletions

File tree

Algorithm.Python/CustomIndicatorAlgorithm.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,20 @@ def Initialize(self):
4141
self.sma = self.SMA("SPY", 60, Resolution.Minute)
4242
self.custom = CustomSimpleMovingAverage('custom', 60)
4343
self.RegisterIndicator("SPY", self.custom, Resolution.Minute)
44+
self.PlotIndicator('cSMA', self.custom)
4445

4546
def OnData(self, data):
4647
if not self.Portfolio.Invested:
4748
self.SetHoldings("SPY", 1)
4849

4950
if self.Time.second == 0:
50-
self.Log(" sma -> IsReady: {0}. Time: {1}. Value: {2}".format(self.sma.IsReady, self.sma.Current.Time, self.sma.Current.Value))
51+
self.Log(f" sma -> IsReady: {self.sma.IsReady}. Time: {self.sma.Current.Time}. Value: {self.sma.Current.Value}")
5152
self.Log(str(self.custom))
5253

5354
# Regression test: test fails with an early quit
5455
diff = abs(self.custom.Value - self.sma.Current.Value)
55-
if diff > 1e-25:
56-
self.Quit("Quit: indicators difference is {0}".format(diff))
56+
if diff > 1e-10:
57+
self.Quit(f"Quit: indicators difference is {diff}")
5758

5859

5960
# Python implementation of SimpleMovingAverage.
@@ -67,7 +68,7 @@ def __init__(self, name, period):
6768
self.queue = deque(maxlen=period)
6869

6970
def __repr__(self):
70-
return "{0} -> IsReady: {1}. Time: {2}. Value: {3}".format(self.Name, self.IsReady, self.Time, self.Value)
71+
return f"{self.Name} -> IsReady: {self.IsReady}. Time: {self.Time}. Value: {self.Value}"
7172

7273
# Update method is mandatory
7374
def Update(self, input):

Algorithm/QCAlgorithm.Python.cs

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ namespace QuantConnect.Algorithm
3535
public partial class QCAlgorithm
3636
{
3737
private readonly Dictionary<IntPtr, PythonActivator> _pythonActivators = new Dictionary<IntPtr, PythonActivator>();
38+
private readonly Dictionary<IntPtr, PythonIndicator> _pythonIndicators = new Dictionary<IntPtr, PythonIndicator>();
3839

3940
public PandasConverter PandasConverter { get; private set; }
4041

@@ -367,27 +368,7 @@ public void RegisterIndicator(Symbol symbol, PyObject indicator, IDataConsolidat
367368
return;
368369
}
369370

370-
using (Py.GIL())
371-
{
372-
if (!indicator.HasAttr("Update"))
373-
{
374-
throw new ArgumentException($"QCAlgorithm.RegisterIndicator(): Update method must be defined. Please checkout {indicator}");
375-
}
376-
}
377-
378-
// register the consolidator for automatic updates via SubscriptionManager
379-
SubscriptionManager.AddConsolidator(symbol, consolidator);
380-
381-
// attach to the DataConsolidated event so it updates our indicator
382-
consolidator.DataConsolidated += (sender, consolidated) =>
383-
{
384-
using (Py.GIL())
385-
{
386-
var data = consolidated.ToPython();
387-
indicator.InvokeMethod("Update", new[] { data });
388-
data.Dispose();
389-
}
390-
};
371+
RegisterIndicator(symbol, WrapPythonIndicator(indicator), consolidator);
391372
}
392373

393374
/// <summary>
@@ -988,14 +969,18 @@ private dynamic[] GetIndicatorArray(PyObject first, PyObject second = null, PyOb
988969
{
989970
using (Py.GIL())
990971
{
991-
var array = new[] { first, second, third, fourth }
992-
.Select(x =>
993-
{
994-
if (x == null) return null;
995-
var type = (Type)x.GetPythonType().AsManagedObject(typeof(Type));
996-
return (dynamic)x.AsManagedObject(type);
972+
var array = new[] {first, second, third, fourth}
973+
.Select(
974+
x =>
975+
{
976+
if (x == null) return null;
997977

998-
}).ToArray();
978+
Type type;
979+
return x.GetPythonType().TryConvert(out type)
980+
? x.AsManagedObject(type)
981+
: WrapPythonIndicator(x);
982+
}
983+
).ToArray();
999984

1000985
var types = array.Where(x => x != null).Select(x => GetIndicatorBaseType(x.GetType())).Distinct();
1001986

@@ -1008,6 +993,25 @@ private dynamic[] GetIndicatorArray(PyObject first, PyObject second = null, PyOb
1008993
}
1009994
}
1010995

996+
/// <summary>
997+
/// Wraps a custom python indicator and save its reference to _pythonIndicators dictionary
998+
/// </summary>
999+
/// <param name="pyObject">The python implementation of <see cref="IndicatorBase{IBaseDataBar}"/></param>
1000+
/// <returns><see cref="PythonIndicator"/> that wraps the python implementation</returns>
1001+
private PythonIndicator WrapPythonIndicator(PyObject pyObject)
1002+
{
1003+
PythonIndicator pythonIndicator;
1004+
if (!_pythonIndicators.TryGetValue(pyObject.Handle, out pythonIndicator))
1005+
{
1006+
pythonIndicator = new PythonIndicator(pyObject);
1007+
1008+
// Save to prevent future additions
1009+
_pythonIndicators.Add(pyObject.Handle, pythonIndicator);
1010+
}
1011+
1012+
return pythonIndicator;
1013+
}
1014+
10111015
/// <summary>
10121016
/// Creates a type with a given name, if PyObject is not a CLR type. Otherwise, convert it.
10131017
/// </summary>

Indicators/PythonIndicator.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3+
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
using Python.Runtime;
17+
using QuantConnect.Data;
18+
using QuantConnect.Data.Market;
19+
using System;
20+
21+
namespace QuantConnect.Indicators
22+
{
23+
/// <summary>
24+
/// Provides a wrapper for <see cref="IndicatorBase{IBaseDataBar}"/> implementations written in python
25+
/// </summary>
26+
public class PythonIndicator : IndicatorBase<IBaseDataBar>
27+
{
28+
private readonly dynamic _indicator;
29+
30+
/// <summary>
31+
/// Get the indicator Name. If not defined, use the class name
32+
/// </summary>
33+
/// <param name="indicator">The python implementation of <see cref="IndicatorBase{IBaseDataBar}"/></param>
34+
/// <returns>The indicator Name.</returns>
35+
private static string GetIndicatorName(PyObject indicator)
36+
{
37+
using (Py.GIL())
38+
{
39+
var name = indicator.HasAttr("Name")
40+
? indicator.GetAttr("Name")
41+
: indicator.GetAttr("__class__").GetAttr("__name__");
42+
43+
return name.GetAndDispose<string>();
44+
}
45+
}
46+
47+
/// <summary>
48+
/// Initializes a new instance of the PythonIndicator class using the specified name.
49+
/// </summary>
50+
/// <param name="indicator">The python implementation of <see cref="IndicatorBase{IBaseDataBar}"/></param>
51+
public PythonIndicator(PyObject indicator)
52+
: base(GetIndicatorName(indicator))
53+
{
54+
using (Py.GIL())
55+
{
56+
foreach (var attributeName in new[] {"Update", "IsReady", "Value"})
57+
{
58+
if (!indicator.HasAttr(attributeName))
59+
{
60+
throw new NotImplementedException(
61+
$"Indicator.{attributeName} must be implemented. Please implement this missing method on {indicator.GetPythonType()}"
62+
);
63+
}
64+
}
65+
}
66+
67+
_indicator = indicator;
68+
}
69+
70+
/// <summary>
71+
/// Updates the state of this indicator with the given value and returns true
72+
/// if this indicator is ready, false otherwise
73+
/// </summary>
74+
/// <param name="input">The value to use to update this indicator</param>
75+
/// <returns>True if this indicator is ready, false otherwise</returns>
76+
public new bool Update(IBaseData input)
77+
{
78+
using (Py.GIL())
79+
{
80+
_indicator.Update(input);
81+
}
82+
83+
return IsReady;
84+
}
85+
86+
/// <summary>
87+
/// Gets a flag indicating when this indicator is ready and fully initialized
88+
/// </summary>
89+
/// <remarks>If IsReady is not defined in the Python indicator, always returns false</remarks>
90+
public override bool IsReady
91+
{
92+
get
93+
{
94+
using (Py.GIL())
95+
{
96+
return _indicator.IsReady;
97+
}
98+
}
99+
}
100+
101+
/// <summary>
102+
/// Computes the next value of this indicator from the given state
103+
/// </summary>
104+
/// <param name="input">The input given to the indicator</param>
105+
/// <returns>A new value for this indicator</returns>
106+
protected override decimal ComputeNextValue(IBaseDataBar input)
107+
{
108+
Update(input);
109+
110+
using (Py.GIL())
111+
{
112+
return _indicator.Value;
113+
}
114+
}
115+
}
116+
}

Indicators/QuantConnect.Indicators.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
<Compile Include="DetrendedPriceOscillator.cs" />
166166
<Compile Include="DonchianChannel.cs" />
167167
<Compile Include="DoubleExponentialMovingAverage.cs" />
168+
<Compile Include="PythonIndicator.cs" />
168169
<Compile Include="IntradayVwap.cs" />
169170
<Compile Include="WilderMovingAverage.cs" />
170171
<Compile Include="FractalAdaptiveMovingAverage.cs" />

Tests/Algorithm/AlgorithmRegisterIndicatorTests.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,15 @@ public void RegisterPythonCustomIndicatorProperly()
127127
var module = PythonEngine.ModuleFromString(Guid.NewGuid().ToString(),
128128
"class GoodCustomIndicator:\n" +
129129
" def __init__(self):\n" +
130+
" self.IsReady = True\n" +
131+
" self.Value = 0\n" +
130132
" pass\n" +
131133
" def Update(self, input):\n" +
132134
" return input\n" +
133135
"class BadCustomIndicator:\n" +
134136
" def __init__(self):\n" +
135-
" pass\n" +
137+
" self.IsReady = True\n" +
138+
" self.Value = 0\n" +
136139
" def Updat(self, input):\n" +
137140
" return input");
138141

@@ -143,7 +146,7 @@ public void RegisterPythonCustomIndicatorProperly()
143146
Assert.AreEqual(1, actual);
144147

145148
var badIndicator = module.GetAttr("BadCustomIndicator").Invoke();
146-
Assert.Throws<ArgumentException>(() => _algorithm.RegisterIndicator(_spy, badIndicator, Resolution.Minute));
149+
Assert.Throws<NotImplementedException>(() => _algorithm.RegisterIndicator(_spy, badIndicator, Resolution.Minute));
147150
}
148151
}
149152

0 commit comments

Comments
 (0)