# piker: trading gear for hackers # Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers) # SPDX-License-Identifier: AGPL-3.0-or-later # This program is free software: you can redistribute it and/or # modify it under the terms of the GNU Affero General Public License # as published by the Free Software Foundation, either version 3 # of the License, or (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public # License along with this program. If not, see # . ''' Causality, streaming, and publication contracts for Trend Calculus. ''' from collections.abc import AsyncIterator, Callable from dataclasses import Field, fields from types import SimpleNamespace from typing import Any, cast import numpy as np import pytest import trio from trio.testing import wait_all_tasks_blocked from trio_typing import TaskStatus from piker.data.ticktools import FeedQuote from piker.fsp._api import ( FspSample, FspStream, FspYield, _load_builtins, ) from piker.fsp._engine import ( Cascade, QuoteFrame, _next_step_output, _sample_wakeups, _write_sample, filter_quotes_by_sym, ) from piker.fsp._trend import ( TrendCalculator, TrendHistory, TrendUpdate, ) from piker.fsp._trend_fsp import trend_calculus from tractor.ipc._shm import ShmArray from tractor.trionics import Lagged def test_split_preserves_both_reversals() -> None: ''' A net unchanged direction can contain two opposing reversals. The final outside window [2, 5] follows the rising window [4, 3]. Bridging its earlier extremum reveals a top at 4 and a bottom at 2, both detected on observation 5. A one-pulse or net-sign-only implementation loses the top. Assert both pivot coordinates and the later detection coordinate, including the historical fields. ''' values: np.ndarray = np.array([1., 2., 4., 3., 2., 5.]) calc: TrendCalculator = TrendCalculator() calc.extend(values[:-1], np.arange(5.)) update: TrendUpdate = calc.push(5., values[-1]) assert update.trends == (1, 0, 0, 0) assert update.reversals['index'].tolist() == [2, 4] assert update.reversals['direction'].tolist() == [-1, 1] assert update.reversals['price'].tolist() == [4., 2.] assert update.reversals['detected_index'].tolist() == [5, 5] assert update.reversals['detected_time'].tolist() == [5., 5.] history: TrendHistory = TrendCalculator().extend( values, np.arange(6.), ) np.testing.assert_array_equal( history.pivot_prices[-1, 0], [4., 2.], ) np.testing.assert_array_equal( history.pivot_times[-1, 0], [2., 4.], ) assert np.isnan(history.pivot_prices[:-1]).all() def test_batch_chunking_and_every_prefix() -> None: ''' Future observations and transport chunking must not alter output. Materialize a random walk with ties and a missing observation, then compare every prefix and uneven continuation batches with a single history scan. Compare pivot detection fields too, because equal direction arrays alone can hide backdated reversals. ''' rng: np.random.Generator = np.random.default_rng(18) values: np.ndarray = ( rng.integers(-2, 3, 80).cumsum().astype(float) ) values[31] = np.nan times: np.ndarray = np.arange(80.) * 0.125 + 1000. indices: np.ndarray = np.arange(80, dtype=np.int64) + 12345 windows: tuple[int, ...] = (3, 2, 4) full: TrendHistory = TrendCalculator(windows).extend( values, times, indices, ) cut: int field: Field[Any] for cut in range(81): prefix: TrendHistory = TrendCalculator(windows).extend( values[:cut], times[:cut], indices[:cut], ) for field in fields(TrendHistory): np.testing.assert_array_equal( getattr(prefix, field.name), getattr(full, field.name)[:cut], ) calc: TrendCalculator = TrendCalculator(windows) start: int = 0 for cut in (0, 1, 2, 7, 31, 32, 34, 60, 80): chunk: TrendHistory = calc.extend( values[start:cut], times[start:cut], indices[start:cut], ) for field in fields(TrendHistory): np.testing.assert_array_equal( getattr(chunk, field.name), getattr(full, field.name)[start:cut], ) start = cut @pytest.mark.parametrize('window', [2, 3, 7]) @pytest.mark.parametrize('direction', [-1, 0, 1]) def test_warmup_flat_and_negative_values( window: int, direction: int, ) -> None: ''' Startup must not invent a trend from a synthetic zero-price bar. Use negative, monotone or flat values through two full windows. The first comparable pair establishes readiness and direction; no sign change or initial pivot exists before that comparison. ''' values: np.ndarray = ( np.arange(2 * window, dtype=float) * direction - 100 ) result: TrendHistory = TrendCalculator((window,)).extend( values, np.arange(float(len(values))), ) assert not result.ready[:-1].any() assert result.ready[-1, 0] assert not result.trends[:-1].any() assert result.trends[-1, 0] == direction assert np.isnan(result.pivot_prices).all() def test_nan_resets_hierarchy_without_future_contamination() -> None: ''' A missing value invalidates every recursive reversal order. Build nontrivial history, append NaN, and compare the following observations against a newly constructed calculator. Retaining even a higher-order partial window would make this replay differ. ''' rng: np.random.Generator = np.random.default_rng(77) calc: TrendCalculator = TrendCalculator((2, 2, 2)) calc.extend(rng.normal(size=200), np.arange(200.)) update: TrendUpdate = calc.push(200., np.nan) assert update.trends == (0, 0, 0) assert update.ready == (False, False, False) assert not len(update.reversals) values: np.ndarray = rng.normal(size=100) times: np.ndarray = np.arange(201., 301.) actual: TrendHistory = calc.extend(values, times) expected: TrendHistory = TrendCalculator((2, 2, 2)).extend( values, times, ) field: Field[Any] for field in fields(TrendHistory): np.testing.assert_array_equal( getattr(actual, field.name), getattr(expected, field.name), ) @pytest.mark.parametrize( 'time,value,index', [ (3., 1., 14), (2., 1., 14), (4., np.inf, 14), (np.nan, 1., 14), (4., 1., 13), (4., 1., -1), (4., 1., 2 ** 63), (4., 1., np.uint64(2 ** 63)), (4., 1., True), (4., 1., np.bool_(True)), ], ) def test_invalid_sample_is_atomic( time: float, value: float, index: int, ) -> None: ''' Invalid coordinates must not corrupt a partially filled window. Seed a calculator with four observations, attempt one rejected sample, and compare continuation to an untouched state copy. This checks that validation precedes recurrence mutation. ''' calc: TrendCalculator = TrendCalculator() calc.extend(np.arange(4.), np.arange(4.), np.arange(10, 14)) untouched: TrendCalculator = calc.copy() with pytest.raises(ValueError): calc.push(time, value, index=index) actual: TrendUpdate = calc.push(4., -1., index=14) expected: TrendUpdate = untouched.push(4., -1., index=14) assert actual.trends == expected.trends np.testing.assert_array_equal( actual.reversals, expected.reversals, ) def test_invalid_batch_is_atomic() -> None: ''' A late malformed sample cannot partly advance a batch operation. The duplicate final timestamp is detected before the first valid row is consumed. A subsequent valid replay matches fresh state. ''' calc: TrendCalculator = TrendCalculator() with pytest.raises(ValueError): calc.extend(np.arange(4.), np.array([1., 2., 3., 3.])) actual: TrendHistory = calc.extend(np.arange(4.), np.arange(4.)) expected: TrendHistory = TrendCalculator().extend( np.arange(4.), np.arange(4.), ) np.testing.assert_array_equal(actual.trends, expected.trends) def test_last_int64_index_is_exact_and_overflow_is_atomic() -> None: ''' Explicit unsigned coordinates must not wrap in compiled state. Observe the last representable coordinate using a NumPy uint64. A subsequent automatic push or batch must reject overflow before touching the partial window, and retain the exact last index. ''' calc: TrendCalculator = TrendCalculator() limit: int = np.iinfo(np.int64).max result: TrendUpdate = calc.push( 1., 2., index=np.uint64(limit), ) assert result.index == limit assert isinstance(result.index, int) before: np.ndarray = calc._bars.copy() with pytest.raises(ValueError, match='int64'): calc.push(2., 3.) with pytest.raises(ValueError, match='overflow'): calc.extend(np.array([3.]), np.array([2.])) np.testing.assert_array_equal(calc._bars, before) class MemorySource: ''' Expose mutable source rows with absolute, nonzero SHM bounds. ''' def __init__(self, values: list[float]) -> None: ''' Seed mutable rows with a nonzero absolute SHM origin. ''' self._first: SimpleNamespace = SimpleNamespace(value=37) self._last: SimpleNamespace = SimpleNamespace( value=37 + len(values), ) self._array: np.ndarray = np.zeros(128, dtype=[ ('index', ' np.ndarray: ''' Expose ``MemorySource._array`` between published bounds. ''' return self._array[self._first.value:self._last.value] def assert_sample_matches( sample: FspSample, rows: np.ndarray, ) -> None: ''' Compare every FSP field with a fresh source-prefix replay. ''' history: TrendHistory = TrendCalculator().extend( rows['close'], rows['time'], rows['index'], ) assert sample.index == rows['index'][-1] assert sample.time == rows['time'][-1] assert sample.fields['ready'] == history.ready[-1].sum() level: int for level in range(4): order: int = level + 1 name: str = 'trend' if level == 0 else f'trend_{order}' assert sample.fields[name] == history.trends[-1, level] side: int label: str for side, label in enumerate(('top', 'bottom')): np.testing.assert_equal( sample.fields[f'{label}_{order}'], history.pivot_prices[-1, level, side], ) np.testing.assert_equal( sample.fields[f'{label}_time_{order}'], history.pivot_times[-1, level, side], ) def test_fsp_preview_mutations_and_skipped_steps() -> None: ''' Updating an open bar must replace its preview, not consume it. Repeatedly mutate the current SHM close across both directions, then advance by two rows before the next quote. Each FspSample must equal a fresh batch replay, and its absolute row must match the source. Quote payloads are deliberately empty wakeups so the source state rather than trade arrivals must drive sampling. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' shm: MemorySource = MemorySource([1., 2., 4., 3., 2., 5.]) async def quotes() -> AsyncIterator[FeedQuote]: ''' Provide payload-free wakeups for source snapshot sampling. ''' while True: yield {} stream: FspStream = trend_calculus( quotes(), cast(ShmArray, shm), ) try: historical: FspYield = await anext(stream) assert isinstance(historical, dict) assert set(historical) == set(trend_calculus.outputs) value: np.ndarray|None # noqa: F842 assert all( value is not None and len(value) == 6 for value in historical.values() ) close: float for close in (100., -20., 5., 5.): shm.array['close'][-1] = close sample: FspYield = await anext(stream) assert isinstance(sample, FspSample) assert_sample_matches(sample, shm.array) last: int = shm._last.value shm._array['close'][last:last + 2] = [-4., -5.] shm._last.value += 2 end: int for end in (6, 7, 8): sample: FspYield = await anext(stream) assert isinstance(sample, FspSample) assert_sample_matches(sample, shm.array[:end]) finally: await stream.aclose() trio.run(main) def test_fsp_empty_bootstrap_and_source_revision() -> None: ''' Empty startup must accept later source rows without old state. After the first sample, prepend changes the source origin. The old generator must stop; restarting it recomputes the revised history rather than reusing recurrence state from another origin. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' shm: MemorySource = MemorySource([]) async def quotes() -> AsyncIterator[FeedQuote]: ''' Provide payload-free wakeups for source snapshot sampling. ''' while True: yield {} stream: FspStream = trend_calculus( quotes(), cast(ShmArray, shm), ) historical: FspYield = await anext(stream) assert isinstance(historical, dict) value: np.ndarray|None # noqa: F842 assert all( value is not None and not len(value) for value in historical.values() ) shm._array['close'][37] = 1. shm._last.value += 1 sample: FspYield = await anext(stream) assert isinstance(sample, FspSample) assert_sample_matches(sample, shm.array) shm._first.value -= 1 with pytest.raises(StopAsyncIteration): await anext(stream) restarted: FspStream = trend_calculus( quotes(), cast(ShmArray, shm), ) try: await anext(restarted) sample: FspYield = await anext(restarted) assert isinstance(sample, FspSample) assert_sample_matches(sample, shm.array) finally: await restarted.aclose() trio.run(main) def test_indexed_sample_waits_for_destination_step() -> None: ''' A quote can reach the FSP before the sampler appends destination. Block _write_sample with destination one row behind source and wait until its task is suspended. Publishing the next destination bound and setting its event permits one coherent write. The prior row remains unchanged, proving no accidental previous-bar write. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' array: np.ndarray = np.zeros(16, dtype=[ ('time', ' None: ''' Wait for the destination bound, then signal publication. ''' assert await _write_sample(casc, FspSample( 10, 1010., {'trend': -1., 'ready': 2.}, )) complete.set() nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon(publish) await wait_all_tasks_blocked() assert not complete.is_set() assert not array['trend'].any() dst._last.value += 1 assert casc.sample_ready is not None casc.sample_ready.set() await complete.wait() assert tuple(array[9]) == (0., 0., 0.) assert tuple(array[10]) == (1010., -1., 2.) trio.run(main) def test_indexed_sample_wait_is_cancellable() -> None: ''' History resync must cancel a compute task waiting on sampling. Suspend indexed publication against an empty destination, cancel its nursery, then advance that destination. No cancelled sample may publish into the new source revision after cancellation. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' array: np.ndarray = np.zeros(4, dtype=[ ('time', float), ('trend', float), ]) dst: SimpleNamespace = SimpleNamespace( _first=SimpleNamespace(value=0), _last=SimpleNamespace(value=0), _array=array, ) casc: Cascade = cast(Cascade, SimpleNamespace( dst=SimpleNamespace(rt_shm=dst), sample_ready=None, )) nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon( _write_sample, casc, FspSample(0, 1., {'trend': 1.}), ) await wait_all_tasks_blocked() nursery.cancel_scope.cancel() dst._last.value = 1 assert casc.sample_ready is not None casc.sample_ready.set() assert not array['trend'].any() trio.run(main) def test_builtin_registration() -> None: ''' Actor-side builtin discovery must resolve the new FSP target. ''' assert _load_builtins()[trend_calculus.ns_path] is trend_calculus def test_quote_free_sampler_steps_finalize_without_repeating_pivots( ) -> None: ''' A quiet feed still finalizes bars and clears old reversal pulses. Start with an open bar containing two provisional reversals and advance the source on sampler notifications alone. Mirror the destination append protocol and run the actual FSP generator and indexed writer. Every stored field must equal causal replay; event fields must also be clear before compute catches up. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' source: MemorySource = MemorySource([1., 2., 4., 3., 2., 5.]) first: int = source._first.value last: int = source._last.value name: str dtype: list[tuple[str, str]] = [ ('index', ' None: ''' Run until cancellation and record completion on exit. ''' try: sample: FspYield async for sample in stream: assert isinstance(sample, FspSample) await _write_sample(casc, sample) finally: await stream.aclose() nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon(compute) await wait_all_tasks_blocked() assert array['top_1'][last - 1] == 4. assert array['bottom_1'][last - 1] == 2. close: float for close in (5., 5., -4., -5.): last = source._last.value source._array['close'][last] = close source._last.value += 1 array[last:last + 1] = _next_step_output( array[last - 1:last], trend_calculus, False, ) array['index'][last] = last array['time'][last] = source.array['time'][-1] destination._last.value += 1 assert np.isnan(array['top_1'][last]) assert np.isnan(array['bottom_1'][last]) if casc.sample_ready is not None: casc.sample_ready.set() casc.sample_ready = trio.Event() assert notify is not None # Coalesce a burst without losing finalized rows. notify() notify() await wait_all_tasks_blocked() offset: int row: np.void for offset, row in enumerate(source.array): name: str sample: FspSample = FspSample( int(row['index']), float(row['time']), { name: array[name][int(row['index'])] for name in trend_calculus.outputs }, ) assert_sample_matches( sample, source.array[:offset + 1], ) nursery.cancel_scope.cancel() trio.run(main) def test_snapshot_quote_overrun_resumes_source_wakeups() -> None: ''' Losing irrelevant quote payloads must not kill a SHM processor. A broadcast receiver raises Lagged and then remains usable. Simulate that protocol, require a catchup notification, and deliver another quote afterward to prove the relay resumed. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' send: trio.MemorySendChannel[QuoteFrame|Exception] receive: trio.MemoryReceiveChannel[QuoteFrame|Exception] send, receive = trio.open_memory_channel[ QuoteFrame|Exception ](1) class Quotes: ''' Inject one overrun without closing the quote receiver. ''' def __aiter__(self) -> 'Quotes': ''' Retain this controlled quote receiver across overruns. ''' return self async def __anext__(self) -> QuoteFrame: ''' Deliver the scheduled frame or injected receiver error. ''' value: QuoteFrame|Exception = await receive.receive() if isinstance(value, Exception): raise value return value wakeups: AsyncIterator[QuoteFrame] notify: Callable[[], None]|None async with send, receive, _sample_wakeups( Quotes(), 'test.market', True, ) as (wakeups, notify): await send.send(Lagged('synthetic quote overrun')) assert 'test.market' in await anext(wakeups) await send.send({'test.market': {'ticks': []}}) assert 'test.market' in await anext(wakeups) assert notify is not None trio.run(main) def test_edge_exit_stops_compute_before_source_receiver_closes( ) -> None: ''' Normal sampler EOF must not strand or abruptly close compute. Keep the parent nursery alive beyond open_edge and inspect the compute task's finalizer immediately after context exit. This proves cleanup belongs to the edge scope rather than relying on an outer actor cancellation or a closed receiver exception. ''' async def main() -> None: ''' Run the controlled scenario in an isolated Trio lifetime. ''' cleaned: trio.Event = trio.Event() nursery: trio.Nursery async with trio.open_nursery() as nursery: casc: Cascade = cast(Cascade, SimpleNamespace( tn=nursery, cs=None, complete=None, bind_func=None, )) async def compute( *, task_status: TaskStatus[int] = ( trio.TASK_STATUS_IGNORED ), ) -> None: ''' Run until cancellation and record completion on exit. ''' scope: trio.CancelScope with trio.CancelScope() as scope: casc.cs = scope casc.complete = trio.Event() task_status.started(42) try: await trio.sleep_forever() finally: cleaned.set() casc.complete.set() index: int async with Cascade.open_edge(casc, compute) as index: assert index == 42 assert not cleaned.is_set() assert cleaned.is_set() trio.run(main)