Skip to content

Commit d55e027

Browse files
committed
perf(stream): multi-output handles answer with a plain tuple
update() on a multi-output handle spent ~125 ns/bar in namedtuple.__new__ against a 6 ns C call. MACD update() goes from 182 to 57 ns/bar with every candidate measured in one binary, and from 170 to 37 ns/bar end to end; BBANDS, STOCH, AROON and MINMAXINDEX all land in the -73% to -75% band. The Function API already returns a plain tuple, so the two tiers now agree. tuple -> NamedTuple stays available later as a non-breaking upgrade, since every tuple idiom keeps working; the reverse would not be. Single-output handles are untouched: 178 of 178 single-output classes are byte-identical before and after. test_open_and_fill_matches_batch loses its _fields check and gains a type and arity check that reaches all 23 multi-output and 178 single-output functions; order stays pinned by the element-wise comparison above it. Claude-Session: https://claude.ai/code/session_01RQsaLFjtMXaVE4UHQKVaeu
1 parent 6fd5ecf commit d55e027

8 files changed

Lines changed: 25602 additions & 28212 deletions

File tree

‎CHANGELOG‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
that bar's value, ``handle.peek(bar)`` evaluates a forming bar without
1010
committing it, and ``handle.copy()`` forks it. ``stream.SMA.open_and_fill()``
1111
returns the handle and the Function API's series in one pass. A multi-output
12-
function answers with a named tuple. The old last-value functions --
13-
``talib.stream.SMA``, ``talib.stream_SMA``, and their ``_ta_lib.pyi`` stubs --
14-
are gone; ``talib/stream.pyi`` types the handles instead.
12+
function answers with the same tuple the Function API returns. The old
13+
last-value functions -- ``talib.stream.SMA``, ``talib.stream_SMA``, and their
14+
``_ta_lib.pyi`` stubs -- are gone; ``talib/stream.pyi`` types the handles
15+
instead.
1516

1617
Migrating is ``stream.X(...)`` -> ``stream.X(...).value``, and the compiler
1718
cannot find the sites for you: ``if stream.CDLDOJI(o, h, l, c):`` used to test

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,13 +557,14 @@ fork = s.copy() # an independent handle at the same bar
557557
558558
`stream.SMA` takes exactly the arguments `talib.SMA` takes. A single-output
559559
function answers with a `float` (an `int` where the Function API returns an
560-
integer array); a multi-output one with a named tuple that still unpacks like
561-
the Function API's tuple:
560+
integer array); a multi-output one with the same tuple the Function API returns:
562561
563562
```python
564563
m = stream.MACD(close)
565564
macd, macdsignal, macdhist = m.update(price)
566-
m.value.macdhist
565+
m.value[2] # the histogram, last bar
566+
567+
abstract.Function('MACD').output_names # ['macd', 'macdsignal', 'macdhist']
567568
```
568569
569570
Opening needs at least `lookback + 1` bars, which `abstract` knows, and a little

‎talib/_stream.pxi‎

Lines changed: 92 additions & 138 deletions
Large diffs are not rendered by default.

‎talib/_ta_lib.c‎

Lines changed: 25404 additions & 27871 deletions
Large diffs are not rendered by default.

‎talib/stream.py‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
4646
s, rsi = stream.RSI.open_and_fill(history, timeperiod=14)
4747
48-
A multi-output function answers with a named tuple whose fields are the output
49-
names in the function's docstring; a single-output one with a bare float (or
50-
int). Handles cannot be pickled.
48+
A multi-output function answers with a plain tuple, in the order the Function
49+
API returns and named by ``abstract.Function(name).output_names``; a
50+
single-output one with a bare float (or int). Handles cannot be pickled.
5151
"""
5252
import talib._ta_lib as _ta_lib
5353
from talib._ta_lib import OutRange, Stream, __TA_FUNCTION_NAMES__
@@ -57,9 +57,3 @@
5757
for func_name in __TA_FUNCTION_NAMES__:
5858
globals()[func_name] = getattr(_ta_lib, '%s_Stream' % func_name)
5959
__all__.append(func_name)
60-
# the named tuple a multi-output handle answers with
61-
value_name = '%s_Value' % func_name
62-
value_type = getattr(_ta_lib, value_name, None)
63-
if value_type is not None:
64-
globals()[value_name] = value_type
65-
__all__.append(value_name)

‎talib/stream.pyi‎

Lines changed: 69 additions & 171 deletions
Large diffs are not rendered by default.

‎tests/test_stream.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,14 @@ def test_open_and_fill_matches_batch(name, datasets):
204204
assert repr(got) == repr(want[-1].item())
205205
assert handle.out_range == (lookback(name), len(data['close']) - lookback(name))
206206
if len(expected) > 1:
207-
assert handle.value._fields == tuple(abstract.Function(name).output_names)
207+
# a multi-output handle answers with a plain tuple, the type and arity
208+
# the batch tier already returns; the loops above pin the order
209+
assert type(handle.value) is tuple
210+
assert len(handle.value) == len(expected)
211+
assert type(filled) is tuple
212+
assert len(filled) == len(expected)
213+
else:
214+
assert not isinstance(handle.value, tuple)
208215

209216

210217
@pytest.mark.parametrize('name', FUNCTIONS)
@@ -286,11 +293,19 @@ def test_the_corpus_is_what_the_library_says_streams():
286293
assert all(isinstance(getattr(stream, name), type) for name in FUNCTIONS)
287294

288295

289-
def test_multi_output_is_a_named_tuple(datasets):
290-
handle = stream.MACD(datasets[0]['close'])
291-
macd, macdsignal, macdhist = handle.value
292-
assert (handle.value.macd, handle.value.macdsignal, handle.value.macdhist) \
293-
== (macd, macdsignal, macdhist)
296+
def test_multi_output_is_a_plain_tuple(datasets):
297+
"""A handle answers with the tuple the Function API returns: same type, same
298+
arity, same order. Order is the whole contract of a positional result, so
299+
pin it against the batch tier rather than against a field name."""
300+
close = datasets[0]['close']
301+
value = stream.MACD(close).value
302+
assert type(value) is tuple
303+
macd, macdsignal, macdhist = value
304+
batch = talib.MACD(close)
305+
assert type(batch) is tuple
306+
assert len(value) == len(batch)
307+
for got, want in zip(value, batch):
308+
assert repr(got) == repr(want[-1].item())
294309

295310

296311
def test_single_output_is_a_scalar(datasets):

‎tools/generate_stream.py‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,8 @@ def emit(func, docstring):
265265
# An index output counts from the first bar the stream opened on; the batch
266266
# tier reports it in the caller's coordinates, so shift it back the same way.
267267
shift = ' + self._begidx' if 'INDEX' in name else ''
268-
value = (outputs[0][1] + shift if len(outputs) == 1 else '%s_Value(%s)' % (
269-
name, ', '.join(py + shift for _, py in outputs)))
268+
value = (outputs[0][1] + shift if len(outputs) == 1
269+
else '(%s)' % ', '.join(py + shift for _, py in outputs))
270270
live = '<%s*>self._handle' % handle
271271
lookback_args = ', '.join(py for _, py, _ in params)
272272
out = []
@@ -294,9 +294,6 @@ def opened(*tail):
294294
return (['&handle'] + ['<double*>a_%s.data + begidx' % py for py in inputs]
295295
+ ['historylen'] + [py for _, py, _ in params] + list(tail))
296296

297-
if len(outputs) > 1:
298-
out.append('%s_Value = namedtuple("%s_Value", "%s", module=__name__)\n'
299-
% (name, name, ' '.join(py[3:] for _, py in outputs)))
300297
out.append('cdef class %s(Stream):' % cls)
301298
out.append(' """%s"""' % docstring)
302299
out.append('')
@@ -345,8 +342,7 @@ def opened(*tail):
345342
out.append(' stream._begidx = begidx')
346343
filled = ['_stream_like((%s,), %s)' % (', '.join(inputs), py) for _, py in outputs]
347344
out.append(' return stream, %s' % (
348-
filled[0] if len(outputs) == 1
349-
else '%s_Value(%s)' % (name, ', '.join(filled))))
345+
filled[0] if len(outputs) == 1 else '(%s)' % ', '.join(filled)))
350346
for verb in ('Update', 'Peek'):
351347
out.append('')
352348
out.append(' @cython.binding(False)')
@@ -412,10 +408,8 @@ def emit_stub(func, documented):
412408
for ctype, _ in outputs]
413409
out = []
414410
if len(outputs) > 1:
415-
out.append('class %s_Value(NamedTuple):' % name)
416-
out.extend(' %s: %s' % (py[3:], t) for (_, py), t in zip(outputs, scalars))
417-
out.append('')
418-
value, filled = '%s_Value' % name, 'Tuple[%s]' % ', '.join(arrays)
411+
value = 'Tuple[%s]' % ', '.join(scalars)
412+
filled = 'Tuple[%s]' % ', '.join(arrays)
419413
else:
420414
value, filled = scalars[0], arrays[0]
421415
bars = ', '.join('%s: float' % py for py in inputs)

0 commit comments

Comments
 (0)