# 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 # . ''' Native SHM-to-regime contracts, using synthetic source publications. ''' from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, Never import json import httpx import numpy as np import pytest import trio from tractor.trionics import Lagged from trio.testing import wait_all_tasks_blocked from piker.fsp._regime import ( CloseSample, MultiTimeframeTrend, RegimeSnapshot, ) from piker.fsp._regime_feed import ( ClosedSourceRows, SourceChanged, consume_regime_source, copy_closed_rows, run_regime_feed, serve_regimes, ) class FakeSource: ''' Model absolute bounds independently of mutable backing storage. ''' def __init__( self, first: int = 0, last: int = 6, ) -> None: ''' Seed indexed rows independently of their published bounds. ''' self._first: SimpleNamespace = SimpleNamespace(value=first) self._last: SimpleNamespace = SimpleNamespace(value=last) self._array: np.ndarray = np.zeros(128, dtype=[ ('index', ' None: ''' Treating the mutable source tail as finalized leaks provisional closes into regime decisions. Make that tail NaN and verify the detached prefix remains valid, immutable and independent of a later shared-memory write. Absolute first/last bounds survive. ''' source: FakeSource = FakeSource(first=2, last=8) source._array['close'][7] = np.nan copied: ClosedSourceRows = copy_closed_rows(source) assert (copied.first, copied.last, copied.start) == (2, 8, 2) assert len(copied.rows) == 5 assert copied.rows['index'][-1] == 6 assert not copied.rows.flags.writeable original: float = copied.rows['close'][0] source._array['close'][2] = 999 assert copied.rows['close'][0] == original @pytest.mark.parametrize('first,last', [(0, 8), (2, 6)]) def test_changed_bounds_reject_mixed_copy( first: int, last: int, ) -> None: ''' A source prepend or truncation between the first bound read and array access invalidates the copied range. Publish that change from a controlled _array property access; the second bound check must reject the candidate before any tracker can consume it. ''' class ChangingSource: ''' Trigger a deterministic source prepend while copying begins. ''' def __init__(self) -> None: ''' Retain a prefix whose origin or last bound will change. ''' self._first: SimpleNamespace = SimpleNamespace(value=2) self._last: SimpleNamespace = SimpleNamespace(value=8) self.storage: np.ndarray = FakeSource()._array @property def _array(self) -> np.ndarray: ''' Publish the changed origin after capturing old bounds. ''' self._first.value = first self._last.value = last return self.storage with pytest.raises(SourceChanged, match='changed during copy'): copy_closed_rows(ChangingSource()) def test_append_during_copy_preserves_closed_prefix() -> None: ''' A high-frequency producer may append during every large history copy. Its old closed prefix remains stable because the captured mutable tail was excluded. Accept the detached prefix with its original last bound, then consume the newly closed suffix once without duplicating observations or starving bootstrap retries. ''' class AppendingSource: ''' Publish two rows after the reader captures initial bounds. ''' def __init__(self) -> None: ''' Retain closed rows before publishing an appended suffix. ''' self._first: SimpleNamespace = SimpleNamespace(value=0) self._last: SimpleNamespace = SimpleNamespace(value=6) self.storage: np.ndarray = FakeSource()._array @property def _array(self) -> np.ndarray: ''' Preserve the existing prefix while exposing a new tail. ''' self._last.value = 8 return self.storage source: AppendingSource = AppendingSource() copied: ClosedSourceRows = copy_closed_rows(source) assert (copied.first, copied.last, copied.start) == (0, 6, 0) assert copied.rows['index'].tolist() == [0, 1, 2, 3, 4] suffix: ClosedSourceRows = copy_closed_rows( source, start=copied.last - 1, ) assert (suffix.first, suffix.last, suffix.start) == (0, 8, 5) assert suffix.rows['index'].tolist() == [5, 6] @pytest.mark.parametrize('first,last', [(-1, 6), (8, 6), (0, 129)]) def test_stable_invalid_bounds_fail_instead_of_spinning( first: int, last: int, ) -> None: ''' Stable invalid bounds cannot become a usable source through a tight retry loop. Treat negative, reversed and out-of-capacity ranges as source errors, distinguishing them from bounds that changed during the two-read copy validation window. ''' with pytest.raises(ValueError, match='readable range'): copy_closed_rows(FakeSource(first, last)) def test_unsigned_coordinate_overflow_is_rejected_before_cast( ) -> None: ''' Converting uint64 coordinates to the core's int64 storage can wrap an oversized value negative. Put that value last in a valid increasing finalized prefix so ordering checks cannot mask the overflow; source validation must reject it before replay begins. ''' source: FakeSource = FakeSource(last=3) source._array = source._array.astype([ ('index', ' None: ''' Invalid finalized rows must not partially warm a tracker. Corrupt a middle row and require validation of the whole detached range before it can be handed to the recurrence; mutable-tail exclusion is covered separately because unfinished rows may be invalid. ''' source: FakeSource = FakeSource() source._array[field][2] = value with pytest.raises(ValueError, match='invalid finalized'): copy_closed_rows(source) def test_bootstrap_and_skipped_steps_emit_only_latest_snapshot( ) -> None: ''' A missed sampler wakeup must not skip finalized closes, and a bootstrap must not create one HTTP job per historical bar. Load five closed rows, mutate the open sixth row, then append three rows with one wakeup. Exactly two snapshots must cover the complete prefixes; duplicate and foreign messages produce none. ''' published: list[RegimeSnapshot] = [] async def scenario() -> None: ''' Drive source publication and wakeups in a controlled order. ''' source: FakeSource = FakeSource() tracker: MultiTimeframeTrend = MultiTimeframeTrend( 'test.market', periods=(1, 2), ) send: trio.MemorySendChannel receive: trio.MemoryReceiveChannel send, receive = trio.open_memory_channel(0) first: trio.Event = trio.Event() def submit(snapshot: RegimeSnapshot) -> None: ''' Observe publication synchronously at the admission point. ''' published.append(snapshot) first.set() nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon( consume_regime_source, source, receive, tracker, submit, ) await first.wait() source._array['close'][5] = 500 await send.send({'period': 1}) await send.send({'period': 60}) await send.send({'backfilling': ['other.market', 1]}) await send.send({'backfilling': ['test.market', 60]}) await wait_all_tasks_blocked() assert len(published) == 1 source._last.value = 9 await send.send({'period': 1}) await send.aclose() trio.run(scenario) assert len(published) == 2 assert published[0].sequence == 5 assert published[1].sequence == 8 assert published[1].as_of == 8 assert published[1].revision == 0 closes: tuple[CloseSample, ...] = ( published[1].timeframes[0].recent_closes ) assert closes[5].price == 500 @pytest.mark.parametrize( 'change', ['in_place', 'prepend', 'truncate'], ) def test_history_changes_revoke_and_replay(change: str) -> None: ''' Appending revised history to warm recurrence state is incorrect, including repairs that leave both bounds unchanged. Publish a source change and verify immediate revision invalidation precedes its replacement snapshot. The rebuilt result must equal replay through a fresh tracker over exactly the corrected closed prefix. ''' published: list[RegimeSnapshot] = [] order: list[tuple[str, int]] = [] async def scenario() -> None: ''' Compare each revised output with an independent full replay. ''' source: FakeSource = FakeSource(first=2, last=20) tracker: MultiTimeframeTrend = MultiTimeframeTrend( 'test.market', periods=(1, 2), ) send: trio.MemorySendChannel receive: trio.MemoryReceiveChannel send, receive = trio.open_memory_channel(0) first: trio.Event = trio.Event() def submit(snapshot: RegimeSnapshot) -> None: ''' Preserve publication order and source generation. ''' published.append(snapshot) order.append(('snapshot', snapshot.revision)) first.set() def invalidate(revision: int) -> None: ''' Record when old pending judgments become inadmissible. ''' order.append(('invalidate', revision)) async def consume() -> None: ''' Attach both source callbacks to one consumer lifetime. ''' await consume_regime_source( source, receive, tracker, submit, invalidate=invalidate, ) nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon(consume) await first.wait() if change == 'in_place': source._array['close'][4] = 200 await send.send({ 'backfilling': ['test.market', 1], }) else: if change == 'prepend': source._first.value = 0 else: source._last.value = 10 await send.send({'period': 1}) await send.aclose() reference: MultiTimeframeTrend = MultiTimeframeTrend( 'test.market', periods=(1, 2), revision=1, ) row: np.void for row in copy_closed_rows(source).rows: expected: RegimeSnapshot = reference.push( float(row['time']), float(row['close']), index=int(row['index']), ) assert published[-1] == expected trio.run(scenario) assert order == [ ('snapshot', 0), ('invalidate', 1), ('snapshot', 1), ] def test_revision_during_bootstrap_suppresses_old_publication( ) -> None: ''' Losing a backfill during bootstrap publishes a mixed-generation classification that ordinary bound checks cannot detect. ``SignallingTracker.push()`` sets ``entered`` after one row; ``consume_regime_source()`` yields at every replay row so the ``scenario()`` task can repair a close and send its notice. ``_Notices.collect()`` must invalidate that revision before the replay publishes. One replacement snapshot, its corrected close and full sequence prove old evidence never reaches ``published``. ''' published: list[RegimeSnapshot] = [] invalidated: list[int] = [] async def scenario() -> None: ''' Synchronize repair with the first chunk's cooperative yield. ''' entered: trio.Event = trio.Event() class SignallingTracker(MultiTimeframeTrend): ''' Expose the start of bootstrap without adding I/O to push. ''' def push( self, *args: Any, **kwargs: Any, ) -> RegimeSnapshot: ''' Signal after the first historical row is consumed. ''' snapshot: RegimeSnapshot = super().push( *args, **kwargs, ) entered.set() return snapshot source: FakeSource = FakeSource(last=100) tracker: SignallingTracker = SignallingTracker( 'test.market', periods=(1,), history_limit=128, ) send: trio.MemorySendChannel receive: trio.MemoryReceiveChannel send, receive = trio.open_memory_channel(0) async def consume() -> None: ''' Yield each row so the controlled repair reaches replay. ''' await consume_regime_source( source, receive, tracker, published.append, invalidate=invalidated.append, replay_chunk=1, ) nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon(consume) await entered.wait() source._array['close'][0] = 999 await send.send({'backfilling': ['test.market', 1]}) await send.aclose() trio.run(scenario) assert invalidated == [1] assert len(published) == 1 assert published[0].revision == 1 assert published[0].sequence == 99 assert published[0].timeframes[0].recent_closes[0].price == 999 @pytest.mark.parametrize('quote_lag', [False, True]) @pytest.mark.parametrize('source_age', [None, 0, 60, -10]) def test_native_wrapper_subscribes_filters_age_and_drains_quotes( monkeypatch: pytest.MonkeyPatch, quote_lag: bool, source_age: float|None, ) -> None: ''' Subscribing after history capture loses in-place revisions in the startup gap. ``GuardedSource._array`` asserts the subscription is active before every read. ``sampler_context()`` then replaces ``Flume.stream`` with ``OtherConsumer``; any later lookup of that shared field fails instead of stealing another consumer's stream. The independent cases cover these admission contracts: - ``LaggingQuotes`` raises ``Lagged`` once. It sets ``recovered`` only when the quote drain resumes; SHM processing must survive. - Old/future source clocks must withhold model evidence even though submission occurs locally now; no age limit permits offline replay. ``published`` distinguishes those outcomes. - ``feed_context()`` rejects a requested quote throttle: feeds cached solely by market must retain their shared quote policy. ``finish()`` waits on ``read_source`` and, when needed, ``recovered``, then closes sampling after tasks block. Context exit order proves the native wrapper releases its subscription before the feed, without any broker, actor service or API key. ''' order: list[str] = [] subscribed: bool = False published: list[RegimeSnapshot] = [] read_source: trio.Event flume_ref: SimpleNamespace|None = None class GuardedSource: ''' Require the native sampler subscription before source access. ''' def __init__(self) -> None: ''' Seed a readable range whose array access is guarded. ''' self._first: SimpleNamespace = SimpleNamespace(value=0) self._last: SimpleNamespace = SimpleNamespace(value=6) self.storage: np.ndarray = FakeSource()._array @property def _array(self) -> np.ndarray: ''' Detect a source read before entering the subscription. ''' assert subscribed order.append('read_source') read_source.set() return self.storage async def scenario() -> None: ''' Close the synthetic sampler to end the actual wrapper. ''' nonlocal read_source read_source = trio.Event() recovered: trio.Event = trio.Event() send: trio.MemorySendChannel receive: trio.MemoryReceiveChannel send, receive = trio.open_memory_channel(0) quote_send: trio.MemorySendChannel quote_receive: trio.MemoryReceiveChannel quote_send, quote_receive = trio.open_memory_channel(0) class LaggingQuotes: ''' Lose one discarded quote before resuming a healthy wait. ''' def __aiter__(self) -> 'LaggingQuotes': ''' Return the resumable stream after a Lagged exception. ''' return self async def __anext__(self) -> dict: ''' Force one overrun, then prove the drain resumed. ''' if 'quote_lagged' not in order: order.append('quote_lagged') raise Lagged('synthetic discarded quote') recovered.set() await trio.sleep_forever() @asynccontextmanager async def feed_context( **kwargs: object, ) -> AsyncIterator[SimpleNamespace]: ''' Expose a source Flume with a cancellable quote stream. ''' nonlocal flume_ref assert 'tick_throttle' not in kwargs order.append('open_feed') flume_ref = SimpleNamespace( rt_sample_period_s=1, rt_shm=GuardedSource(), stream=( LaggingQuotes() if quote_lag else quote_receive ), ) yield SimpleNamespace(flumes={ 'test.market': flume_ref, }) await quote_send.aclose() order.append('close_feed') @asynccontextmanager async def sampler_context( **kwargs: object, ) -> AsyncIterator[trio.MemoryReceiveChannel]: ''' Mark exactly when the sampler subscription begins. ''' nonlocal subscribed subscribed = True order.append('subscribe') class OtherConsumer: ''' Model a shared Flume's receiver ownership change. ''' def __aiter__(self) -> Never: ''' Detect consuming a later caller's broadcast view. ''' pytest.fail('drain stole another quote receiver') assert flume_ref is not None flume_ref.stream = OtherConsumer() try: yield receive finally: subscribed = False order.append('unsubscribe') monkeypatch.setattr( 'piker.data.feed.maybe_open_feed', feed_context, ) monkeypatch.setattr( 'piker.data._sampling.open_sample_stream', sampler_context, ) if source_age is not None: monkeypatch.setattr( 'piker.fsp._regime_feed.time.time', lambda: 5 + source_age, ) async def finish() -> None: ''' Close the sampler after processing and draining quotes. ''' await read_source.wait() if quote_lag: await recovered.wait() await wait_all_tasks_blocked() await send.aclose() nursery: trio.Nursery async with trio.open_nursery() as nursery: nursery.start_soon(finish) await run_regime_feed( 'test.market', published.append, max_source_age=None if source_age is None else 5, ) trio.run(scenario) assert len(published) == (source_age in (None, 0)) assert order.index('subscribe') < order.index('read_source') assert order[-2:] == ['unsubscribe', 'close_feed'] def test_service_composes_feed_and_classifier_in_one_lifetime( monkeypatch: pytest.MonkeyPatch, ) -> None: ''' A prepared provider adapter needs concrete feed-to-classifier wiring and shared cancellation ownership. Replace the broker source with a synthetic callback producer but retain the actual HTTP client, worker and service nursery. One corrected snapshot must produce one validated judgment; normal source exit must stop the classifier while leaving the caller's HTTP session open. ''' from piker.fsp._jev import ( DEFAULT_MODEL, JevClassification, JevClient, RegimeClassifier, ) published: list[JevClassification] = [] async def scenario() -> None: ''' End the source only after its judgment is published. ''' delivered: trio.Event = trio.Event() async def source( fqme: str, submit: Callable[[RegimeSnapshot], object], *, invalidate: Callable[[int], object], **kwargs: object, ) -> None: ''' Replay one revision through the bound service callbacks. ''' assert fqme == 'test.market' assert kwargs['max_source_age'] == 5 invalidate(1) tracker: MultiTimeframeTrend = MultiTimeframeTrend( fqme, periods=(1,), revision=1, ) second: int for second in range(8): snapshot: RegimeSnapshot = tracker.push( second, second, ) submit(snapshot) await delivered.wait() def handler(request: httpx.Request) -> httpx.Response: ''' Return a documented Choice response without real HTTP. ''' request_body: dict = json.loads(request.content) answers: dict[str, dict] = {} name: str question: dict for name, question in request_body['questions'].items(): probabilities: dict[str, float] = {} label: str for label in question['criteria']: probabilities[label] = float(label == 'uptrend') answers[name] = { 'type': 'choice', 'choice': 'uptrend', 'probabilities': probabilities, 'confidence': 1, } return httpx.Response(200, json={ 'model': DEFAULT_MODEL, 'answers': answers, 'usage': { 'input_tokens': 100, 'output_tokens': 20, }, }) async def publish(result: JevClassification) -> None: ''' Release source shutdown after receiving a typed judgment. ''' published.append(result) delivered.set() monkeypatch.setattr( 'piker.fsp._regime_feed.run_regime_feed', source, ) http: httpx.AsyncClient async with httpx.AsyncClient( transport=httpx.MockTransport(handler), ) as http: client: JevClient = JevClient(http, 'offline-test-key') worker: RegimeClassifier = await serve_regimes( 'test.market', client, publish, ) assert not http.is_closed assert worker.requests == 1 assert worker.published == 1 assert http.is_closed trio.run(scenario) assert len(published) == 1 assert published[0].market == 'test.market' assert published[0].revision == 1