pfeed

  1from __future__ import annotations
  2
  3from typing import TYPE_CHECKING
  4
  5import sys
  6
  7if TYPE_CHECKING:
  8    # need these imports to support IDE hints:
  9    # NOTE: data sources (plugins), for IDE hints of pe.<Source> only, never executed at runtime.
 10    # pe.<Source> is reserved for OFFICIAL sources, only they are listed here and in __getattr__.
 11    # third-party sources are NOT exposed as pe.<Source>, use either:
 12    #   from pfeed_xxx import XXX
 13    #   pe.get_feed("xxx", ...)
 14
 15    # from pfeed_binance import Binance as Binance  # TODO
 16    # from pfeed_okx import OKX as OKX  # TODO
 17    from alphafund._pfeed_source import (  # ty: ignore[import-not-found]
 18        AlphaFund as AlphaFund,
 19    )
 20    from pfeed_bybit import Bybit as Bybit
 21    from pfund._pfeed_source import PFund as PFund  # ty: ignore[import-not-found]
 22
 23    from pfeed.base import get_feed
 24    from pfeed.engine import DataEngine
 25    from pfeed.io.ducklake_io import DuckLakeIO
 26    from pfeed.io.parquet_io import ParquetIO
 27    from pfeed.market.feed import MarketFeed
 28
 29from pfeed.config import configure, configure_logging, get_config
 30
 31# pdoc only documents names already in the module, not ones from __getattr__
 32if "pdoc" in sys.modules:
 33    from pfeed.base import get_feed
 34    from pfeed.engine import DataEngine
 35    from pfeed.io.ducklake_io import DuckLakeIO
 36    from pfeed.io.parquet_io import ParquetIO
 37    from pfeed.market.feed import MarketFeed
 38
 39
 40def __getattr__(name: str):
 41    if name == "__version__":
 42        from importlib.metadata import version
 43
 44        return version("pfeed")
 45    elif name == "ParquetIO":
 46        from pfeed.io.parquet_io import ParquetIO
 47
 48        return ParquetIO
 49    elif name == "DuckLakeIO":
 50        from pfeed.io.ducklake_io import DuckLakeIO
 51
 52        return DuckLakeIO
 53    elif name == "MarketFeed":
 54        from pfeed.market.feed import MarketFeed
 55
 56        return MarketFeed
 57    elif name == "get_feed":
 58        from pfeed.base import get_feed
 59
 60        return get_feed
 61    elif name == "DataEngine":
 62        from pfeed.engine import DataEngine
 63
 64        return DataEngine
 65    elif name == "Bybit":
 66        return _import_official_source("pfeed_bybit", "Bybit")
 67
 68    elif name == "PFund":
 69        return _get_official_source("PFund", install="pfund")
 70
 71    elif name == "AlphaFund":
 72        return _get_official_source("AlphaFund", install="alphafund")
 73    raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
 74
 75
 76def _import_official_source(module: str, source: str):
 77    """Imports an official data source class (e.g. pe.Bybit) from its plugin, e.g. module="pfeed_bybit"."""
 78    from pfund_kit.plugins import import_official_plugin
 79
 80    return import_official_plugin(module, source, label=f"pe.{source}")
 81
 82
 83def _get_official_source(source: str, install: str):
 84    """Loads an official data source class (e.g. pe.PFund) via its "pfeed.sources" entry point.
 85
 86    Used for sources shipped inside another package (e.g. pfund), so pfeed doesn't hardcode
 87    their (private) module path, the package registering the entry point owns it.
 88    """
 89    from pfeed.registry import get_entry_points, get_source
 90
 91    if source.upper() not in get_entry_points():
 92        raise ModuleNotFoundError(
 93            f"pe.{source} requires '{install}', install it with: pip install {install}"
 94        )
 95    return get_source(source)
 96
 97
 98__all__ = (  # noqa: RUF022
 99    "get_config",
100    "configure",
101    "configure_logging",
102    "plot",
103    "DataEngine",
104    "get_feed",
105    # Feeds (for the convenience of inheritance)
106    "MarketFeed",
107    # IOs
108    "DuckLakeIO",
109    "ParquetIO",
110)
111
112
113def __dir__():
114    return sorted(__all__)
def get_config() -> pfeed.config.PFeedConfig:
41def get_config() -> PFeedConfig:
42    """Lazy singleton - only creates config when first called.
43    Also loads the .env file.
44    """
45    global _config
46    if _config is None:
47        _config = PFeedConfig()
48    return _config

Lazy singleton - only creates config when first called. Also loads the .env file.

def configure( data_path: str | pathlib.Path | None = None, log_path: str | pathlib.Path | None = None, cache_path: str | pathlib.Path | None = None, show_progress_bar: bool | None = None, allow_timestamp_precision_loss: bool | None = None, stream_latency_log_interval: float | None = None, stream_optimize_interval: float | None = None, persist: bool = False) -> pfeed.config.PFeedConfig:
55def configure(
56    data_path: str | Path | None = None,
57    log_path: str | Path | None = None,
58    cache_path: str | Path | None = None,
59    show_progress_bar: bool | None = None,
60    allow_timestamp_precision_loss: bool | None = None,
61    stream_latency_log_interval: float | None = None,
62    stream_optimize_interval: float | None = None,
63    persist: bool = False,
64) -> PFeedConfig:
65    """
66    Configures the global config object.
67    Args:
68        data_path: Path to the data directory.
69        log_path: Path to the log directory.
70        cache_path: Path to the cache directory.
71        show_progress_bar: Whether pfeed progress bars are displayed.
72        allow_timestamp_precision_loss: Whether IOs that store timestamps in a coarser unit
73            (e.g. DeltaLakeIO, microseconds) may truncate finer ones (e.g. nanoseconds), with a warning.
74            If False, such writes raise. Defaults to False.
75        stream_latency_log_interval: Seconds between a stream's latency logs (at DEBUG level), e.g. 60.
76            Defaults to None, i.e. latency is not tracked.
77        stream_optimize_interval: Seconds between optimize() calls on the table IOs (e.g. DuckLakeIO) a feed streams to,
78            e.g. to move DuckLake's inlined rows out of its catalog into parquet files. 0 runs it only once, when the streams end.
79            Defaults to 1800.
80        persist: If True, the config will be saved to the config file.
81    """
82    config = get_config()
83    config_dict = config.to_dict()
84
85    # Apply updates for non-None values
86    for k in config_dict:
87        v = locals().get(k)
88        if v is not None:
89            if "_path" in k:
90                v = Path(v)
91            setattr(config, k, v)
92
93    config.ensure_dirs()
94
95    if persist:
96        config.save()
97
98    return config

Configures the global config object.

Arguments:
  • data_path: Path to the data directory.
  • log_path: Path to the log directory.
  • cache_path: Path to the cache directory.
  • show_progress_bar: Whether pfeed progress bars are displayed.
  • allow_timestamp_precision_loss: Whether IOs that store timestamps in a coarser unit (e.g. DeltaLakeIO, microseconds) may truncate finer ones (e.g. nanoseconds), with a warning. If False, such writes raise. Defaults to False.
  • stream_latency_log_interval: Seconds between a stream's latency logs (at DEBUG level), e.g. 60. Defaults to None, i.e. latency is not tracked.
  • stream_optimize_interval: Seconds between optimize() calls on the table IOs (e.g. DuckLakeIO) a feed streams to, e.g. to move DuckLake's inlined rows out of its catalog into parquet files. 0 runs it only once, when the streams end. Defaults to 1800.
  • persist: If True, the config will be saved to the config file.
def configure_logging( logging_config: dict[str, Any] | None = None, debug: bool = False) -> dict[str, typing.Any]:
101def configure_logging(
102    logging_config: dict[str, Any] | None = None, debug: bool = False
103) -> dict[str, Any]:
104    return kit_logging.configure_logging(
105        get_config(), overrides=logging_config, debug=debug
106    )
plot
class DataEngine:
 33class DataEngine:
 34    """Runs the streams or replays of several feeds together and sends out their output, e.g. for pfund.
 35
 36    The output is each streamed message after the feeds' transformations (e.g. a resampled bar), with or without Ray, sent to:
 37    - the callback, if given
 38    - the iteration, i.e. `async for data in engine`
 39    - the subscribers of the engine's XPUB, if setup_publishing() was called
 40    """
 41
 42    QUEUE_MAXSIZE: ClassVar[int] = 1000
 43
 44    def __init__(
 45        self,
 46        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 47        isolate_failures: bool = True,
 48    ):
 49        """
 50        Args:
 51            callback: Sync or async callable called with each output, i.e. a message after the feeds' transformations.
 52                It is awaited before the next output, so a slow callback delays every feed, offload heavy work, e.g. to a task or a thread.
 53            isolate_failures: If True (default), a failed feed ends alone, the others keep running.
 54                If False, the first failed feed ends all the others.
 55        """
 56        self._logger = logging.getLogger("pfeed")
 57        self._callback = callback
 58        self._isolate_failures = isolate_failures
 59        self._feeds: list[StreamingFeed] = []
 60        self._is_running = False
 61        # XPUB publishing the output to other processes, only set up by setup_publishing()
 62        self._msg_queue: ZeroMQ | None = None
 63        # PULL receiving the Ray workers' output, set up by run_async() when a feed uses Ray
 64        self._worker_msg_queue: ZeroMQ | None = None
 65        self._worker_thread: threading.Thread | None = None
 66        self._stop_worker_thread = threading.Event()
 67        # NOTE: only set while replaying, merges the replays of all feeds by time
 68        self._replay_clock: ReplayClock | None = None
 69        # NOTE: only set while iterating the engine with __aiter__()
 70        self._iter_queue: asyncio.Queue[Message] | None = None
 71        self._zmq_routes: dict[
 72            DataFlow[Any, Any], tuple[ZeroMQChannel, ZeroMQTopic]
 73        ] = {}
 74        # NOTE: only set while running, so that end() can cancel the feeds from another thread
 75        self._loop: asyncio.AbstractEventLoop | None = None
 76        self._feed_tasks: list[asyncio.Task[RunResult]] = []
 77
 78    def is_running(self) -> bool:
 79        return self._is_running
 80
 81    @property
 82    def feeds(self) -> list[BaseFeed[Any, Any, Any, Any]]:
 83        return cast("list[BaseFeed[Any, Any, Any, Any]]", self._feeds)
 84
 85    def add_feed(
 86        self,
 87        data_source: str,
 88        data_category: DataCategory | str = DataCategory.MARKET_DATA,
 89        num_workers: int | None = None,
 90    ) -> BaseFeed[Any, Any, Any, Any]:
 91        """Creates a feed in pipeline mode, to call stream() or replay() on, run by the engine.
 92
 93        Args:
 94            num_workers: Number of Ray workers for the feed's streams, None to run them without Ray.
 95                Feeds with and without Ray can be mixed.
 96        """
 97        from pfeed.base import get_feed
 98        from pfeed.streaming.mixin import StreamingMixin
 99
100        if self.is_running():
101            raise RuntimeError("cannot add a feed while the data engine is running")
102        feed: BaseFeed[Any, Any, Any, Any] = get_feed(
103            data_source, data_category, pipeline_mode=True, num_workers=num_workers
104        )
105        if not isinstance(feed, StreamingMixin) or not (
106            feed._supports("stream") or feed._supports("replay")
107        ):
108            raise ValueError(
109                f"{feed.name} supports neither stream() nor replay(), the data engine only runs streams and replays"
110            )
111        feed._set_engine(self)
112        self._feeds.append(feed)
113        return feed
114
115    def setup_publishing(
116        self,
117        zmq_url: str | None = None,
118        zmq_sender_port: int | None = None,
119        io_threads: int = 1,
120    ) -> None:
121        """Sets up the XPUB publishing the output to other processes, e.g. pfund's.
122
123        Each output is sent on its dataflow's channel, see BaseDataModel.to_zmq_route().
124        The XPUB is closed when the run ends.
125
126        Args:
127            zmq_url: e.g. "tcp://localhost" (default).
128            zmq_sender_port: The XPUB's port, a random free one if None, see `engine._msg_queue.get_ports_in_use()`.
129        """
130        import zmq
131
132        from pfeed.streaming.zeromq import ZeroMQ
133
134        if self._msg_queue is not None:
135            raise RuntimeError("Publishing is already set up, cannot set it up again")
136        self._msg_queue = ZeroMQ(
137            name="data_engine",
138            logger=self._logger,
139            io_threads=io_threads,
140            sender_type=zmq.XPUB,
141        )
142        self._msg_queue.bind(
143            self._msg_queue.sender,
144            port=zmq_sender_port,
145            url=zmq_url or ZeroMQ.DEFAULT_URL,
146        )
147
148    def _get_worker_ports(self) -> list[int]:
149        """The ports of the PULL the Ray workers push their output to."""
150        assert self._worker_msg_queue is not None, (
151            "the data engine's PULL for the Ray workers is not set up"
152        )
153        return self._worker_msg_queue.get_ports_in_use(self._worker_msg_queue.receiver)
154
155    def _validate_feeds(self) -> None:
156        if not self._feeds:
157            raise RuntimeError("No feeds to run, add one with add_feed()")
158        if idle := [feed.name for feed in self._feeds if not feed.is_streaming()]:
159            raise RuntimeError(
160                f"{idle} have nothing to run, call stream() or replay() on them first"
161            )
162        envs = {
163            feed: {request.env for request in feed._requests} for feed in self._feeds
164        }
165        if len(set().union(*envs.values())) > 1:
166            details = ", ".join(
167                f"{feed.name}={sorted(feed_envs)}" for feed, feed_envs in envs.items()
168            )
169            raise ValueError(
170                f"cannot run feeds of different envs in one data engine: {details}"
171            )
172        if self._is_replaying():
173            replay_paces = {
174                request.replay_pace
175                for feed in self._feeds
176                for request in feed._requests
177            }
178            if len(replay_paces) > 1:
179                raise ValueError(
180                    f"the replays of a data engine share one pace, got different replay_pace: {replay_paces}"
181                )
182
183    def _register_codecs(self, msg_queue: ZeroMQ) -> None:
184        """Encodes and decodes each dataflow's channel with its data model's wire format, e.g. Bars for market data."""
185        for dataflow, (channel, _topic) in self._zmq_routes.items():
186            data_model = dataflow.data_model
187            msg_queue.register_codec(
188                channel,
189                data_model.StreamMessage,
190                data_model.enc_hook,
191                data_model.dec_hook,
192            )
193
194    def _is_replaying(self) -> bool:
195        # NOTE: no mixed envs, see _validate_feeds()
196        return self._feeds[0]._requests[0].env == Environment.BACKTEST
197
198    # --- output ---
199
200    async def _forward_dataflow_message(
201        self, dataflow: DataFlow[Any, Any], data: Message
202    ) -> None:
203        """Called by a feed's dataflow (without Ray) with each message's output."""
204        channel, topic = self._zmq_routes[dataflow]
205        await self._emit(channel, topic, data)
206
207    async def _emit(
208        self, channel: ZeroMQChannel, topic: ZeroMQTopic, data: Message
209    ) -> None:
210        """Sends an output to the callback, the iteration and the XPUB's subscribers."""
211        if self._callback:
212            # NOTE: a failed callback skips only this output, with or without Ray
213            try:
214                result = self._callback(data)
215                if inspect.isawaitable(result):
216                    await result
217            except Exception:
218                self._logger.exception(
219                    f"Data Engine callback failed on {channel}, skipping the output:"
220                )
221        if (iter_queue := self._iter_queue) is not None:
222            # NOTE: drop the oldest output instead of blocking, a slow consumer would otherwise stall every feed
223            if iter_queue.full():
224                self._logger.warning(
225                    "data engine iteration queue is full, dropping the oldest output - consider increasing QUEUE_MAXSIZE "
226                    + f"(current: {self.QUEUE_MAXSIZE}) or improving the consumer's speed"
227                )
228                _ = iter_queue.get_nowait()
229            iter_queue.put_nowait(data)
230        if self._msg_queue is not None:
231            # NOTE: a failed send skips only this output, it must not stop the dataflow that produced it
232            try:
233                self._msg_queue.send(channel=channel, topic=topic, data=data)
234            except Exception:
235                self._logger.exception(
236                    f"Data Engine failed to send the output on {channel}, skipping it:"
237                )
238
239    def _setup_worker_messaging(self, inbox: asyncio.Queue[Output]) -> None:
240        """Binds the PULL the Ray workers push their output to, a thread receives from it and puts the output in the inbox."""
241        import zmq
242
243        from pfeed.streaming.zeromq import ZeroMQ, ZeroMQDataChannel
244
245        loop = asyncio.get_running_loop()
246        msg_queue = ZeroMQ(
247            name="data_engine.workers",
248            logger=self._logger,
249            receiver_type=zmq.PULL,
250            receiver_method="bind",
251        )
252        # NOTE: decodes the outputs back into their types, e.g. Bar/Tick for market data, like the outputs without Ray
253        self._register_codecs(msg_queue)
254        msg_queue.bind(msg_queue.receiver)
255        self._worker_msg_queue = msg_queue
256        self._stop_worker_thread.clear()
257
258        def _receive():
259            # NOTE: only this thread uses the PULL after this, a zmq socket isn't thread-safe
260            try:
261                while True:
262                    if (msg := msg_queue.recv()) is None:
263                        # NOTE: stops only once nothing is left to receive, the workers' last outputs can arrive after they have stopped
264                        if self._stop_worker_thread.is_set():
265                            break
266                        continue
267                    channel, topic, data, _msg_ts = msg
268                    # NOTE: each worker sends STOP when it terminates, it is not an output
269                    if channel == ZeroMQDataChannel.signal:
270                        continue
271                    _ = loop.call_soon_threadsafe(
272                        inbox.put_nowait, (channel, topic, data)
273                    )
274            finally:
275                msg_queue.terminate()
276
277        self._worker_thread = threading.Thread(
278            target=_receive, name="data_engine.workers", daemon=True
279        )
280        self._worker_thread.start()
281
282    def _teardown_worker_messaging(self) -> None:
283        self._stop_worker_thread.set()
284        if self._worker_thread is not None:
285            self._worker_thread.join(timeout=10)
286            if self._worker_thread.is_alive():
287                self._logger.warning(
288                    "data engine's thread receiving from the Ray workers did not stop in time"
289                )
290        self._worker_thread = None
291        self._worker_msg_queue = None
292
293    async def _forward_worker_output(self, inbox: asyncio.Queue[Output]) -> None:
294        while True:
295            channel, topic, data = await inbox.get()
296            try:
297                await self._emit(channel, topic, data)
298            # NOTE: keeps forwarding, run_async() waits for the inbox to be emptied when the feeds have ended
299            except Exception:
300                self._logger.exception(
301                    f"Data Engine failed to send the output on {channel}:"
302                )
303            finally:
304                inbox.task_done()
305
306    # --- running ---
307
308    def run(self) -> dict[BaseFeed[Any, Any, Any, Any], RunResult]:
309        """Runs every feed's streams or replays until they end or end() is called, see run_async()."""
310        try:
311            _ = asyncio.get_running_loop()
312        except RuntimeError:
313            # no running event loop, safe to use asyncio.run()
314            pass
315        else:
316            raise RuntimeError(
317                "Cannot call engine.run() from within a running event loop.\n"
318                + "Did you mean to call engine.run_async()?"
319            )
320        return asyncio.run(self.run_async())
321
322    async def run_async(self) -> dict[BaseFeed[Any, Any, Any, Any], RunResult]:
323        """Runs every feed's streams or replays until they end or end() is called.
324
325        Returns:
326            Each feed's result. A feed that raised gets its dataflows failed with the error.
327        """
328        from pfeed.dataflow.result import DataFlowResult, RunResult
329        from pfeed.streaming.replay_clock import ReplayClock
330
331        if self.is_running():
332            raise RuntimeError("Data Engine is already running, cannot run again")
333        self._validate_feeds()
334        self._is_running = True
335        self._loop = asyncio.get_running_loop()
336        inbox: asyncio.Queue[Output] = asyncio.Queue()
337        forwarding: asyncio.Task[None] | None = None
338        # NOTE: kept since a feed clears its dataflows when its run ends
339        dataflows = {feed: feed.dataflows for feed in self._feeds}
340        try:
341            # NOTE: each dataflow's channel, its outputs are sent on it, e.g. to the XPUB's subscribers
342            self._zmq_routes = {
343                dataflow: dataflow.data_model.to_zmq_route()
344                for feed_dataflows in dataflows.values()
345                for dataflow in feed_dataflows
346            }
347            if self._msg_queue is not None:
348                self._register_codecs(self._msg_queue)
349            if any(feed._is_using_ray() for feed in self._feeds):
350                self._setup_worker_messaging(inbox)
351                forwarding = asyncio.create_task(self._forward_worker_output(inbox))
352            if self._is_replaying():
353                self._replay_clock = ReplayClock(
354                    self._feeds[0]._requests[0].replay_pace
355                )
356                # NOTE: every feed joins before any starts, otherwise a feed could replay ahead of one that hasn't joined yet
357                for feed in self._feeds:
358                    self._replay_clock.join(feed)
359            self._feed_tasks = [
360                asyncio.create_task(feed.run_async()) for feed in self._feeds
361            ]
362            try:
363                await self._wait_for_feeds()
364            except asyncio.CancelledError:
365                self._logger.warning("Data Engine was cancelled, ending the feeds...")
366            finally:
367                # NOTE: e.g. cancelled, or a feed failed with isolate_failures=False
368                if pending := [task for task in self._feed_tasks if not task.done()]:
369                    for task in pending:
370                        _ = task.cancel()
371                    _ = await asyncio.wait(pending)
372            results: dict[BaseFeed[Any, Any, Any, Any], RunResult] = {}
373            for feed, task in zip(self._feeds, self._feed_tasks, strict=True):
374                # NOTE: a cancelled feed's dataflows keep their results, a cancelled stream is not a failure
375                if task.cancelled():
376                    results[feed] = RunResult(data=None, dataflows=dataflows[feed])
377                elif (err := task.exception()) is not None:
378                    self._logger.error(f"{feed.name} failed: {err!r}")
379                    for dataflow in dataflows[feed]:
380                        dataflow._result = DataFlowResult.failed(error=err)
381                    results[feed] = RunResult(data=None, dataflows=dataflows[feed])
382                else:
383                    results[feed] = task.result()
384            if failed := [
385                feed.name for feed, result in results.items() if not result.success
386            ]:
387                self._logger.warning(
388                    f"Data Engine has {len(failed)} failed feeds: {failed}"
389                )
390            return results
391        finally:
392            # NOTE: every feed has ended, and with them the Ray workers
393            self._teardown_worker_messaging()
394            if forwarding is not None:
395                # NOTE: lets the thread's last call_soon_threadsafe() puts run, then sends what is left in the inbox
396                await asyncio.sleep(0)
397                if not forwarding.done():
398                    await inbox.join()
399                _ = forwarding.cancel()
400                _ = await asyncio.wait([forwarding])
401            if self._msg_queue is not None:
402                self._msg_queue.terminate()
403                self._msg_queue = None
404            self._replay_clock = None
405            self._zmq_routes.clear()
406            self._feed_tasks = []
407            self._loop = None
408            self._is_running = False
409
410    async def _wait_for_feeds(self) -> None:
411        """Waits until every feed has ended, or until the first failed one if isolate_failures is False."""
412        pending: set[asyncio.Task[RunResult]] = set(self._feed_tasks)
413        while pending:
414            done, pending = await asyncio.wait(
415                pending, return_when=asyncio.FIRST_COMPLETED
416            )
417            if self._isolate_failures:
418                continue
419            for task in done:
420                if not task.cancelled() and (
421                    task.exception() is not None or not task.result().success
422                ):
423                    self._logger.warning(
424                        "a feed has failed, ending the other feeds since isolate_failures=False"
425                    )
426                    return
427
428    def end(self) -> None:
429        """Ends the feeds, run() / run_async() then return their results. Can be called from any thread."""
430        if (loop := self._loop) is None:
431            return
432        self._logger.debug("Data Engine is ending")
433
434        def _cancel_feeds():
435            for task in self._feed_tasks:
436                _ = task.cancel()
437
438        # NOTE: raised if the loop has just closed, i.e. the run has ended
439        with contextlib.suppress(RuntimeError):
440            _ = loop.call_soon_threadsafe(_cancel_feeds)
441
442    async def __aiter__(self) -> AsyncGenerator[Message]:
443        """Runs the feeds and yields each output, like the callback gets it.
444
445        Breaking out of the loop ends the feeds only when the generator is closed, which Python does lazily,
446        wrap it in `contextlib.aclosing()` to end them right at the break.
447        """
448        queue: asyncio.Queue[Message] = asyncio.Queue(maxsize=self.QUEUE_MAXSIZE)
449        self._iter_queue = queue
450        producer = asyncio.create_task(self.run_async())
451        # NOTE: a non-immediate shutdown lets the consumer drain the queued outputs before get() raises QueueShutDown
452        producer.add_done_callback(lambda _: queue.shutdown())
453        try:
454            while True:
455                try:
456                    data = await queue.get()
457                except asyncio.QueueShutDown:
458                    break
459                yield data
460            # re-raises the producer's exception, if any
461            _ = await producer
462        finally:
463            # NOTE: the consumer stopped early, e.g. break or cancelled, end the feeds
464            if not producer.done():
465                _ = producer.cancel()
466                # NOTE: wait() instead of awaiting the producer, so the producer's cancellation isn't raised here, but this task's own is
467                _ = await asyncio.wait([producer])
468            self._iter_queue = None

Runs the streams or replays of several feeds together and sends out their output, e.g. for pfund.

The output is each streamed message after the feeds' transformations (e.g. a resampled bar), with or without Ray, sent to:

  • the callback, if given
  • the iteration, i.e. async for data in engine
  • the subscribers of the engine's XPUB, if setup_publishing() was called
DataEngine( callback: Callable[[pfund_blocks.typing.Message], Awaitable[None] | None] | None = None, isolate_failures: bool = True)
44    def __init__(
45        self,
46        callback: Callable[[Message], Awaitable[None] | None] | None = None,
47        isolate_failures: bool = True,
48    ):
49        """
50        Args:
51            callback: Sync or async callable called with each output, i.e. a message after the feeds' transformations.
52                It is awaited before the next output, so a slow callback delays every feed, offload heavy work, e.g. to a task or a thread.
53            isolate_failures: If True (default), a failed feed ends alone, the others keep running.
54                If False, the first failed feed ends all the others.
55        """
56        self._logger = logging.getLogger("pfeed")
57        self._callback = callback
58        self._isolate_failures = isolate_failures
59        self._feeds: list[StreamingFeed] = []
60        self._is_running = False
61        # XPUB publishing the output to other processes, only set up by setup_publishing()
62        self._msg_queue: ZeroMQ | None = None
63        # PULL receiving the Ray workers' output, set up by run_async() when a feed uses Ray
64        self._worker_msg_queue: ZeroMQ | None = None
65        self._worker_thread: threading.Thread | None = None
66        self._stop_worker_thread = threading.Event()
67        # NOTE: only set while replaying, merges the replays of all feeds by time
68        self._replay_clock: ReplayClock | None = None
69        # NOTE: only set while iterating the engine with __aiter__()
70        self._iter_queue: asyncio.Queue[Message] | None = None
71        self._zmq_routes: dict[
72            DataFlow[Any, Any], tuple[ZeroMQChannel, ZeroMQTopic]
73        ] = {}
74        # NOTE: only set while running, so that end() can cancel the feeds from another thread
75        self._loop: asyncio.AbstractEventLoop | None = None
76        self._feed_tasks: list[asyncio.Task[RunResult]] = []
Arguments:
  • callback: Sync or async callable called with each output, i.e. a message after the feeds' transformations. It is awaited before the next output, so a slow callback delays every feed, offload heavy work, e.g. to a task or a thread.
  • isolate_failures: If True (default), a failed feed ends alone, the others keep running. If False, the first failed feed ends all the others.
QUEUE_MAXSIZE: ClassVar[int] = 1000
def is_running(self) -> bool:
78    def is_running(self) -> bool:
79        return self._is_running
feeds: list[pfeed.base.feed.BaseFeed[typing.Any, typing.Any, typing.Any, typing.Any]]
81    @property
82    def feeds(self) -> list[BaseFeed[Any, Any, Any, Any]]:
83        return cast("list[BaseFeed[Any, Any, Any, Any]]", self._feeds)
def add_feed( self, data_source: str, data_category: pfeed.enums.data_category.DataCategory | str = <DataCategory.MARKET_DATA: 'MARKET_DATA'>, num_workers: int | None = None) -> pfeed.base.feed.BaseFeed[typing.Any, typing.Any, typing.Any, typing.Any]:
 85    def add_feed(
 86        self,
 87        data_source: str,
 88        data_category: DataCategory | str = DataCategory.MARKET_DATA,
 89        num_workers: int | None = None,
 90    ) -> BaseFeed[Any, Any, Any, Any]:
 91        """Creates a feed in pipeline mode, to call stream() or replay() on, run by the engine.
 92
 93        Args:
 94            num_workers: Number of Ray workers for the feed's streams, None to run them without Ray.
 95                Feeds with and without Ray can be mixed.
 96        """
 97        from pfeed.base import get_feed
 98        from pfeed.streaming.mixin import StreamingMixin
 99
100        if self.is_running():
101            raise RuntimeError("cannot add a feed while the data engine is running")
102        feed: BaseFeed[Any, Any, Any, Any] = get_feed(
103            data_source, data_category, pipeline_mode=True, num_workers=num_workers
104        )
105        if not isinstance(feed, StreamingMixin) or not (
106            feed._supports("stream") or feed._supports("replay")
107        ):
108            raise ValueError(
109                f"{feed.name} supports neither stream() nor replay(), the data engine only runs streams and replays"
110            )
111        feed._set_engine(self)
112        self._feeds.append(feed)
113        return feed

Creates a feed in pipeline mode, to call stream() or replay() on, run by the engine.

Arguments:
  • num_workers: Number of Ray workers for the feed's streams, None to run them without Ray. Feeds with and without Ray can be mixed.
def setup_publishing( self, zmq_url: str | None = None, zmq_sender_port: int | None = None, io_threads: int = 1) -> None:
115    def setup_publishing(
116        self,
117        zmq_url: str | None = None,
118        zmq_sender_port: int | None = None,
119        io_threads: int = 1,
120    ) -> None:
121        """Sets up the XPUB publishing the output to other processes, e.g. pfund's.
122
123        Each output is sent on its dataflow's channel, see BaseDataModel.to_zmq_route().
124        The XPUB is closed when the run ends.
125
126        Args:
127            zmq_url: e.g. "tcp://localhost" (default).
128            zmq_sender_port: The XPUB's port, a random free one if None, see `engine._msg_queue.get_ports_in_use()`.
129        """
130        import zmq
131
132        from pfeed.streaming.zeromq import ZeroMQ
133
134        if self._msg_queue is not None:
135            raise RuntimeError("Publishing is already set up, cannot set it up again")
136        self._msg_queue = ZeroMQ(
137            name="data_engine",
138            logger=self._logger,
139            io_threads=io_threads,
140            sender_type=zmq.XPUB,
141        )
142        self._msg_queue.bind(
143            self._msg_queue.sender,
144            port=zmq_sender_port,
145            url=zmq_url or ZeroMQ.DEFAULT_URL,
146        )

Sets up the XPUB publishing the output to other processes, e.g. pfund's.

Each output is sent on its dataflow's channel, see BaseDataModel.to_zmq_route(). The XPUB is closed when the run ends.

Arguments:
  • zmq_url: e.g. "tcp://localhost" (default).
  • zmq_sender_port: The XPUB's port, a random free one if None, see engine._msg_queue.get_ports_in_use().
def run( self) -> dict[pfeed.base.feed.BaseFeed[typing.Any, typing.Any, typing.Any, typing.Any], pfeed.dataflow.result.RunResult]:
308    def run(self) -> dict[BaseFeed[Any, Any, Any, Any], RunResult]:
309        """Runs every feed's streams or replays until they end or end() is called, see run_async()."""
310        try:
311            _ = asyncio.get_running_loop()
312        except RuntimeError:
313            # no running event loop, safe to use asyncio.run()
314            pass
315        else:
316            raise RuntimeError(
317                "Cannot call engine.run() from within a running event loop.\n"
318                + "Did you mean to call engine.run_async()?"
319            )
320        return asyncio.run(self.run_async())

Runs every feed's streams or replays until they end or end() is called, see run_async().

async def run_async( self) -> dict[pfeed.base.feed.BaseFeed[typing.Any, typing.Any, typing.Any, typing.Any], pfeed.dataflow.result.RunResult]:
322    async def run_async(self) -> dict[BaseFeed[Any, Any, Any, Any], RunResult]:
323        """Runs every feed's streams or replays until they end or end() is called.
324
325        Returns:
326            Each feed's result. A feed that raised gets its dataflows failed with the error.
327        """
328        from pfeed.dataflow.result import DataFlowResult, RunResult
329        from pfeed.streaming.replay_clock import ReplayClock
330
331        if self.is_running():
332            raise RuntimeError("Data Engine is already running, cannot run again")
333        self._validate_feeds()
334        self._is_running = True
335        self._loop = asyncio.get_running_loop()
336        inbox: asyncio.Queue[Output] = asyncio.Queue()
337        forwarding: asyncio.Task[None] | None = None
338        # NOTE: kept since a feed clears its dataflows when its run ends
339        dataflows = {feed: feed.dataflows for feed in self._feeds}
340        try:
341            # NOTE: each dataflow's channel, its outputs are sent on it, e.g. to the XPUB's subscribers
342            self._zmq_routes = {
343                dataflow: dataflow.data_model.to_zmq_route()
344                for feed_dataflows in dataflows.values()
345                for dataflow in feed_dataflows
346            }
347            if self._msg_queue is not None:
348                self._register_codecs(self._msg_queue)
349            if any(feed._is_using_ray() for feed in self._feeds):
350                self._setup_worker_messaging(inbox)
351                forwarding = asyncio.create_task(self._forward_worker_output(inbox))
352            if self._is_replaying():
353                self._replay_clock = ReplayClock(
354                    self._feeds[0]._requests[0].replay_pace
355                )
356                # NOTE: every feed joins before any starts, otherwise a feed could replay ahead of one that hasn't joined yet
357                for feed in self._feeds:
358                    self._replay_clock.join(feed)
359            self._feed_tasks = [
360                asyncio.create_task(feed.run_async()) for feed in self._feeds
361            ]
362            try:
363                await self._wait_for_feeds()
364            except asyncio.CancelledError:
365                self._logger.warning("Data Engine was cancelled, ending the feeds...")
366            finally:
367                # NOTE: e.g. cancelled, or a feed failed with isolate_failures=False
368                if pending := [task for task in self._feed_tasks if not task.done()]:
369                    for task in pending:
370                        _ = task.cancel()
371                    _ = await asyncio.wait(pending)
372            results: dict[BaseFeed[Any, Any, Any, Any], RunResult] = {}
373            for feed, task in zip(self._feeds, self._feed_tasks, strict=True):
374                # NOTE: a cancelled feed's dataflows keep their results, a cancelled stream is not a failure
375                if task.cancelled():
376                    results[feed] = RunResult(data=None, dataflows=dataflows[feed])
377                elif (err := task.exception()) is not None:
378                    self._logger.error(f"{feed.name} failed: {err!r}")
379                    for dataflow in dataflows[feed]:
380                        dataflow._result = DataFlowResult.failed(error=err)
381                    results[feed] = RunResult(data=None, dataflows=dataflows[feed])
382                else:
383                    results[feed] = task.result()
384            if failed := [
385                feed.name for feed, result in results.items() if not result.success
386            ]:
387                self._logger.warning(
388                    f"Data Engine has {len(failed)} failed feeds: {failed}"
389                )
390            return results
391        finally:
392            # NOTE: every feed has ended, and with them the Ray workers
393            self._teardown_worker_messaging()
394            if forwarding is not None:
395                # NOTE: lets the thread's last call_soon_threadsafe() puts run, then sends what is left in the inbox
396                await asyncio.sleep(0)
397                if not forwarding.done():
398                    await inbox.join()
399                _ = forwarding.cancel()
400                _ = await asyncio.wait([forwarding])
401            if self._msg_queue is not None:
402                self._msg_queue.terminate()
403                self._msg_queue = None
404            self._replay_clock = None
405            self._zmq_routes.clear()
406            self._feed_tasks = []
407            self._loop = None
408            self._is_running = False

Runs every feed's streams or replays until they end or end() is called.

Returns:

Each feed's result. A feed that raised gets its dataflows failed with the error.

def end(self) -> None:
428    def end(self) -> None:
429        """Ends the feeds, run() / run_async() then return their results. Can be called from any thread."""
430        if (loop := self._loop) is None:
431            return
432        self._logger.debug("Data Engine is ending")
433
434        def _cancel_feeds():
435            for task in self._feed_tasks:
436                _ = task.cancel()
437
438        # NOTE: raised if the loop has just closed, i.e. the run has ended
439        with contextlib.suppress(RuntimeError):
440            _ = loop.call_soon_threadsafe(_cancel_feeds)

Ends the feeds, run() / run_async() then return their results. Can be called from any thread.

def get_feed( data_source: str, data_category: pfeed.enums.data_category.DataCategory | str, pipeline_mode: bool = False, num_workers: int | None = None) -> pfeed.base.feed.BaseFeed:
11def get_feed(
12    data_source: str,
13    data_category: DataCategory | str,
14    pipeline_mode: bool = False,
15    num_workers: int | None = None,
16) -> BaseFeed:
17    from pfeed import registry
18
19    Feed = registry.get_feed(data_source, data_category)
20    # feeds are only created by their source, so build the source and return its feed
21    source = registry.get_source(data_source)(
22        pipeline_mode=pipeline_mode, num_workers=num_workers
23    )
24    return getattr(source, Feed.data_domain.feed_name)
class MarketFeed(pfeed.streaming.mixin.StreamingMixin, pfeed.base.time_based_feed.TimeBasedFeed[SourceT, RequestT, DataModelT, PipelineT], abc.ABC, typing.Generic[SourceT, RequestT, DataModelT, PipelineT]):
  42class MarketFeed[
  43    SourceT: BaseSource[bool],
  44    RequestT: MarketFeedBaseRequest = MarketFeedBaseRequest,
  45    DataModelT: MarketDataModel = MarketDataModel,
  46    PipelineT: bool = Literal[False],
  47](StreamingMixin, TimeBasedFeed[SourceT, RequestT, DataModelT, PipelineT], ABC):
  48    class Requests:  # ty: ignore[incompatible-attribute-override]
  49        """Request class per verb, a subclass sets a verb to None if it doesn't support it."""
  50
  51        download: ClassVar[type[MarketFeedDownloadRequest] | None] = (
  52            MarketFeedDownloadRequest
  53        )
  54        retrieve: ClassVar[type[MarketFeedRetrieveRequest] | None] = (
  55            MarketFeedRetrieveRequest
  56        )
  57        stream: ClassVar[type[MarketFeedStreamRequest] | None] = MarketFeedStreamRequest
  58        replay: ClassVar[type[MarketFeedReplayRequest] | None] = MarketFeedReplayRequest
  59
  60    DataModel: ClassVar[type[MarketDataModel]] = MarketDataModel  # ty: ignore[incompatible-mutable-override]
  61    data_domain: ClassVar[DataCategory] = DataCategory.MARKET_DATA
  62    _required_methods: ClassVar[dict[str, tuple[str, ...]]] = {
  63        MarketFeedDownloadRequest.extract_type: (
  64            "_download_impl",
  65            "_normalize_downloaded_data",
  66        ),
  67        MarketFeedStreamRequest.extract_type: ("_parse_streaming_message",),
  68    }
  69
  70    @staticmethod
  71    def _standardize_columns(
  72        df: pl.LazyFrame, product: BaseProduct, resolution: Resolution
  73    ) -> pl.LazyFrame:
  74        """Standardizes the columns of a DataFrame.
  75        Adds columns 'product', 'resolution'
  76        """
  77        from pfeed.schemas.market_data_schema import PRODUCT, RESOLUTION
  78
  79        return df.with_columns(
  80            pl.lit(product.name).alias(PRODUCT),
  81            # NOTE: str(resolution) is used, NOT repr(resolution)
  82            pl.lit(str(resolution)).alias(RESOLUTION),
  83        )
  84
  85    @staticmethod
  86    def _filter_columns(df: pl.LazyFrame, product: BaseProduct) -> pl.LazyFrame:
  87        """Keeps only the standard columns of cleaned data, plus the optional ones present."""
  88        from pfeed.schemas.bar_data_schema import CLOSE, HIGH, LOW, OPEN
  89        from pfeed.schemas.market_data_schema import KEY_COLS, VOLUME
  90        from pfeed.schemas.tick_data_schema import PRICE, SIDE
  91
  92        cols = df.collect_schema().names()
  93        is_tick_data = PRICE in cols
  94        if is_tick_data:
  95            value_cols = [PRICE, VOLUME, SIDE]
  96            # side is a standard column even if the source has no trade direction, null means unknown
  97            if SIDE not in cols:
  98                df = df.with_columns(pl.lit(None, dtype=pl.Int8).alias(SIDE))
  99        else:
 100            value_cols = [OPEN, HIGH, LOW, CLOSE, VOLUME]
 101        standard_cols = [*KEY_COLS, *value_cols]
 102
 103        product_specific_cols = []
 104        if product.is_stock() or product.is_etf():
 105            from pfeed.schemas.stock_data_schema import DIVIDENDS, SPLITS
 106
 107            product_specific_cols.extend([DIVIDENDS, SPLITS])
 108
 109        for col in product_specific_cols:
 110            if col in cols:
 111                standard_cols.append(col)
 112        return df.select(standard_cols)
 113
 114    @staticmethod
 115    def _organize_columns(df: pl.LazyFrame) -> pl.LazyFrame:
 116        """Moves the key columns ('date', 'product', 'resolution') to the leftmost side."""
 117        from pfeed.schemas.market_data_schema import KEY_COLS
 118
 119        left_cols = list(KEY_COLS)
 120        current_cols = df.collect_schema().names()
 121        target_cols = left_cols + [c for c in current_cols if c not in left_cols]
 122        if current_cols == target_cols:
 123            return df
 124        return df.select(target_cols)
 125
 126    @staticmethod
 127    def _resample_data(
 128        df: pl.LazyFrame,
 129        product: BaseProduct,
 130        resolution: str | Resolution,
 131        offset: str | None = None,
 132    ) -> pl.LazyFrame:
 133        """Resamples the input lazyframe based on the target resolution.
 134
 135        Args:
 136            df: The input lazyframe to be resampled.
 137            product: Product instance of the data, e.g. created from `'BTC_USDT_PERP'`.
 138                Decides the product-specific columns to keep and aggregate,
 139                e.g. `dividends` and `splits` for stocks and ETFs.
 140            resolution: The target resolution to resample the data to.
 141            offset: Optional polars duration string (e.g., '30m') to shift the resampling window.
 142                For example, with '1h' resolution and '30m' offset, timestamps will be XX:30:00.
 143
 144        Returns:
 145            The resampled lazyframe.
 146        """
 147        if isinstance(resolution, str):
 148            resolution = Resolution(resolution)
 149
 150        from pfeed.schemas.bar_data_schema import CLOSE, HIGH, LOW, OPEN
 151        from pfeed.schemas.market_data_schema import PRODUCT, RESOLUTION, VOLUME
 152        from pfeed.schemas.tick_data_schema import PRICE
 153        from pfeed.schemas.time_based_data_schema import DATE
 154
 155        df_resolution_str = df.select(pl.col(RESOLUTION).first()).collect().item()
 156        df_resolution = Resolution(df_resolution_str)
 157        is_resample_required = resolution < df_resolution
 158        if not is_resample_required:
 159            return df
 160
 161        df = MarketFeed._filter_columns(df, product)
 162
 163        # pfund's timeframe units (s, m, h, d, w, mo, y) are the same as polars' duration units
 164        eresolution = f"{resolution.period}{resolution.timeframe.canonical}"
 165
 166        cols = df.collect_schema().names()
 167        is_tick_data = PRICE in cols
 168
 169        aggs: list[pl.Expr] = []
 170        if is_tick_data:
 171            aggs.extend(
 172                [
 173                    pl.col(PRICE).first().alias(OPEN),
 174                    pl.col(PRICE).max().alias(HIGH),
 175                    pl.col(PRICE).min().alias(LOW),
 176                    pl.col(PRICE).last().alias(CLOSE),
 177                    pl.col(VOLUME).sum().alias(VOLUME),
 178                ]
 179            )
 180        else:
 181            aggs.extend(
 182                [
 183                    pl.col(OPEN).first().alias(OPEN),
 184                    pl.col(HIGH).max().alias(HIGH),
 185                    pl.col(LOW).min().alias(LOW),
 186                    pl.col(CLOSE).last().alias(CLOSE),
 187                    pl.col(VOLUME).sum().alias(VOLUME),
 188                ]
 189            )
 190        aggs.append(pl.col(PRODUCT).first().alias(PRODUCT))
 191
 192        if product.is_stock() or product.is_etf():
 193            from pfeed.schemas.stock_data_schema import DIVIDENDS, SPLITS
 194
 195            if DIVIDENDS in cols:
 196                aggs.append(pl.col(DIVIDENDS).sum().alias(DIVIDENDS))
 197            if SPLITS in cols:
 198                aggs.append(pl.col(SPLITS).product().alias(SPLITS))
 199
 200        aggs.append(pl.len().alias("n_data_points"))
 201
 202        resampled = (
 203            df.sort(DATE)
 204            .group_by_dynamic(
 205                DATE,
 206                every=eresolution,
 207                offset=offset,
 208                label="left",
 209                closed="left",
 210            )
 211            .agg(aggs)
 212            .with_columns(pl.lit(str(resolution)).alias(RESOLUTION))
 213        )
 214        return resampled
 215
 216    def get_supported_resolutions(
 217        self, include_resampled: bool = False
 218    ) -> list[Resolution]:
 219        """Get all supported resolutions for batch processing for the data source.
 220
 221        Args:
 222            include_resampled: If False (default), return only the resolutions the
 223                data source literally provides. If True, also include all coarser
 224                resolutions derivable by resampling from the finest native one —
 225                e.g. a source providing '1t' also "supports" '1s', '1m', '1h', '1d', etc.
 226                Quote resolutions pass through but are never a resampling base, so a
 227                quote-only source returns only its native resolutions.
 228        """
 229        native = [
 230            Resolution(dtype_or_resol)
 231            for dtype_or_resol in self.data_source.data_categories[self.data_domain]
 232        ]
 233        if not include_resampled or not native:
 234            return native
 235        resample_bases = [r for r in native if not r.is_quote()]
 236        if not resample_bases:
 237            return native
 238        finest = max(resample_bases)
 239        return sorted(
 240            set(native) | set(finest.get_coarser_resolutions(exclude_quote=True)),
 241            reverse=True,
 242        )
 243
 244    @classmethod
 245    def create_data_model(
 246        cls,
 247        product: BaseProduct | str,
 248        resolution: Resolution | MarketDataType | str,
 249        start_date: datetime.date | str,
 250        end_date: datetime.date | str | None = None,
 251        env: Environment | str = Environment.BACKTEST,
 252        data_origin: str = "",
 253        **product_specs: Any,
 254    ) -> MarketDataModel:
 255        """Create a MarketDataModel instance.
 256
 257        Args:
 258            product: Product basis, e.g. `'BTC_USDT_PERP'`, or a product instance.
 259            resolution: Resolution, e.g. `'1t'`, `'1m'`, a `MarketDataType` e.g. "minute", or a `Resolution` instance.
 260            start_date: First date (inclusive), e.g. `'2025-01-01'`, or a `datetime.date`.
 261            end_date: Last date (inclusive). If None, defaults to `start_date` (a single-day model).
 262            env: Trading environment.
 263            data_origin: Sub-label for data from the same source but different origins.
 264                Defaults to the source name.
 265            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
 266                futures. Leave them out to get an error listing the required ones.
 267        """
 268        if isinstance(product, str):
 269            product = cls.DataSource.create_product(product, **product_specs)
 270        return cls.DataModel(
 271            env=env,
 272            data_source=cls.DataSource.metadata.name,
 273            data_origin=data_origin,
 274            product=product,
 275            resolution=resolution,
 276            start_date=start_date,
 277            end_date=end_date or start_date,
 278        )
 279
 280    # NOTE: the return type follows the pipeline mode, see BaseFeed's PipelineT
 281    @overload
 282    def download[F: MarketFeed[Any, Any, Any, Literal[True]]](
 283        self: F,
 284        product: str,
 285        resolution: Resolution | MarketDataType | str,
 286        *,
 287        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 288        start_date: datetime.date | str | None = None,
 289        end_date: datetime.date | str | None = None,
 290        data_origin: str = "",
 291        data_layer: DataLayer | str = DataLayer.CLEANED,
 292        io: BaseIO | None = None,
 293        **product_specs: Any,
 294    ) -> F: ...
 295    @overload
 296    def download(
 297        self: MarketFeed[Any, Any, Any, Literal[False]],
 298        product: str,
 299        resolution: Resolution | MarketDataType | str,
 300        *,
 301        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 302        start_date: datetime.date | str | None = None,
 303        end_date: datetime.date | str | None = None,
 304        data_origin: str = "",
 305        data_layer: DataLayer | str = DataLayer.CLEANED,
 306        io: BaseIO | None = None,
 307        **product_specs: Any,
 308    ) -> RunResult: ...
 309    @overload
 310    def download[F: MarketFeed[Any, Any, Any, bool]](
 311        self: F,
 312        product: str,
 313        resolution: Resolution | MarketDataType | str,
 314        *,
 315        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 316        start_date: datetime.date | str | None = None,
 317        end_date: datetime.date | str | None = None,
 318        data_origin: str = "",
 319        data_layer: DataLayer | str = DataLayer.CLEANED,
 320        io: BaseIO | None = None,
 321        **product_specs: Any,
 322    ) -> F | RunResult: ...
 323    @requires("download")
 324    def download(
 325        self,
 326        product: str,
 327        resolution: Resolution | MarketDataType | str,
 328        *,
 329        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 330        start_date: datetime.date | str | None = None,
 331        end_date: datetime.date | str | None = None,
 332        data_origin: str = "",
 333        data_layer: DataLayer | str = DataLayer.CLEANED,
 334        io: BaseIO | None = None,
 335        **product_specs: Any,
 336    ) -> Self | RunResult:
 337        """Download historical data from the data source.
 338
 339        Args:
 340            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
 341            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
 342                If the source doesn't provide it, the closest finer resolution is
 343                downloaded and downsampled.
 344            rollback_period: How far to roll back from today (UTC) when `start_date`
 345                is None, e.g. `'7d'` = the last 7 days up to yesterday.
 346                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
 347            start_date: First date (inclusive), e.g. `'2025-01-01'`.
 348            end_date: Last date (inclusive). If None, defaults to yesterday (UTC).
 349                Requires `start_date`.
 350            data_origin: Sub-label for data from the same source but different origins.
 351                Defaults to the source name.
 352            data_layer: Data layer of the downloaded data, `'raw'` or `'cleaned'` (default).
 353                `'cleaned'` runs the default transformations (normalize, standardize columns,
 354                downsample); `'raw'` returns the data as-is. The data is stored in this layer if `io` is given.
 355            io: Where and how to store the data. If None, data is not stored.
 356                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
 357            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
 358                futures. Leave them out to get an error listing the required ones.
 359
 360        Returns:
 361            `RunResult` with the downloaded data, or `self` in pipeline mode.
 362        """
 363        env = Environment.BACKTEST
 364        setup_logging(env=env)
 365        product: BaseProduct = self.data_source.create_product(product, **product_specs)
 366        resolution = Resolution(resolution)
 367        start_date, end_date = self._standardize_dates(
 368            resolution, start_date, end_date, rollback_period
 369        )
 370        if not (
 371            candidates := [
 372                r for r in self.get_supported_resolutions() if r >= resolution
 373            ]
 374        ):
 375            raise ValueError(f"{resolution} is not supported by {self.name}")
 376        # find the first resolution that is >= (finer/higher) the target resolution
 377        data_resolution = min(candidates)
 378        request = cast(
 379            "RequestT",
 380            cast("type[MarketFeedDownloadRequest]", self.Requests.download)(
 381                data_source=self.data_source,
 382                data_origin=data_origin,
 383                io=io,
 384                data_layer=data_layer,
 385                env=env,
 386                product=product,
 387                target_resolution=resolution,
 388                data_resolution=data_resolution,
 389                start_date=start_date,
 390                end_date=end_date,
 391            ),
 392        )
 393        self._append_request(request)
 394        data_model = cast("DataModelT", request.to_data_model())
 395        self._create_batch_dataflows(
 396            extract_func=self._download_impl,
 397            data_models=data_model.split_by_date()
 398            if self.download_dataflow_per_date
 399            else [data_model],
 400        )
 401        return self.run() if not self.is_pipeline() else self
 402
 403    @staticmethod
 404    def _normalize_downloaded_data(df: pl.LazyFrame) -> pl.LazyFrame:
 405        raise NotImplementedError
 406
 407    @staticmethod
 408    def _download_impl(
 409        request: RequestT, data_model: DataModelT
 410    ) -> pl.LazyFrame | None:
 411        raise NotImplementedError
 412
 413    def _get_default_transformations(
 414        self, request: RequestT, /
 415    ) -> list[Callable[..., Any]]:
 416        match request.extract_type:
 417            case "download":
 418                return self._get_default_transformations_for_download(request)
 419            case "retrieve":
 420                return self._get_default_transformations_for_retrieve(request)
 421            case "stream":
 422                return self._get_default_transformations_for_stream(request)
 423            case "replay":
 424                return self._get_default_transformations_for_replay(request)
 425            case _:
 426                raise ValueError(f"Unknown extract type: {request.extract_type}")
 427
 428    def _get_default_transformations_for_download(
 429        self, request: RequestT, /
 430    ) -> list[Callable[..., Any]]:
 431        from pfeed.utils import lambda_with_name
 432
 433        # bind the class and plain values so the lambdas don't capture self (the whole feed) when pickled by Ray
 434        cls = type(self)
 435        is_raw_data = not request.should_clean_data()
 436        product, data_resolution, target_resolution = (
 437            request.product,
 438            request.data_resolution,
 439            request.target_resolution,
 440        )
 441        default_transformations = [
 442            lambda_with_name(
 443                "standardize_date_column",
 444                lambda df: cls._standardize_date_column(df, is_raw_data=is_raw_data),
 445            ),
 446        ]
 447        if is_raw_data:
 448            return default_transformations
 449        default_transformations.extend(
 450            [
 451                cls._normalize_downloaded_data,
 452                lambda_with_name(
 453                    "standardize_columns",
 454                    lambda df: cls._standardize_columns(df, product, data_resolution),
 455                ),
 456                lambda_with_name(
 457                    "resample_data_if_necessary",
 458                    lambda df: cls._resample_data(df, product, target_resolution),
 459                ),
 460                cls._organize_columns,
 461            ]
 462        )
 463        return default_transformations
 464
 465    # NOTE: the return type follows the pipeline mode, see BaseFeed's PipelineT
 466    @overload
 467    def retrieve[F: MarketFeed[Any, Any, Any, Literal[True]]](
 468        self: F,
 469        product: str,
 470        resolution: Resolution | MarketDataType | str,
 471        *,
 472        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 473        start_date: datetime.date | str | None = None,
 474        end_date: datetime.date | str | None = None,
 475        data_origin: str = "",
 476        data_layer: DataLayer | str = DataLayer.CLEANED,
 477        env: Environment | str = Environment.BACKTEST,
 478        dataflow_per_date: bool = False,
 479        io: BaseIO | None = None,
 480        **product_specs: Any,
 481    ) -> F: ...
 482    @overload
 483    def retrieve(
 484        self: MarketFeed[Any, Any, Any, Literal[False]],
 485        product: str,
 486        resolution: Resolution | MarketDataType | str,
 487        *,
 488        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 489        start_date: datetime.date | str | None = None,
 490        end_date: datetime.date | str | None = None,
 491        data_origin: str = "",
 492        data_layer: DataLayer | str = DataLayer.CLEANED,
 493        env: Environment | str = Environment.BACKTEST,
 494        dataflow_per_date: bool = False,
 495        io: BaseIO | None = None,
 496        **product_specs: Any,
 497    ) -> RunResult: ...
 498    @overload
 499    def retrieve[F: MarketFeed[Any, Any, Any, bool]](
 500        self: F,
 501        product: str,
 502        resolution: Resolution | MarketDataType | str,
 503        *,
 504        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 505        start_date: datetime.date | str | None = None,
 506        end_date: datetime.date | str | None = None,
 507        data_origin: str = "",
 508        data_layer: DataLayer | str = DataLayer.CLEANED,
 509        env: Environment | str = Environment.BACKTEST,
 510        dataflow_per_date: bool = False,
 511        io: BaseIO | None = None,
 512        **product_specs: Any,
 513    ) -> F | RunResult: ...
 514    @requires("retrieve")
 515    def retrieve(
 516        self,
 517        product: str,
 518        resolution: Resolution | MarketDataType | str,
 519        *,
 520        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 521        start_date: datetime.date | str | None = None,
 522        end_date: datetime.date | str | None = None,
 523        data_origin: str = "",
 524        data_layer: DataLayer | str = DataLayer.CLEANED,
 525        env: Environment | str = Environment.BACKTEST,
 526        dataflow_per_date: bool = False,
 527        io: BaseIO | None = None,
 528        **product_specs: Any,
 529    ) -> Self | RunResult:
 530        """Retrieve data from storage.
 531
 532        Args:
 533            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
 534            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
 535                If not stored, finer stored data is retrieved and downsampled.
 536            rollback_period: How far to roll back from today (UTC) when `start_date`
 537                is None, e.g. `'7d'` = the last 7 days up to yesterday.
 538                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
 539            start_date: First date (inclusive), e.g. `'2025-01-01'`.
 540            end_date: Last date (inclusive). If None, defaults to yesterday (UTC).
 541                Requires `start_date`.
 542            data_origin: Sub-label for data from the same source but different origins.
 543                Defaults to the source name.
 544            data_layer: Data layer to retrieve the data from:
 545                `'raw'`, `'cleaned'` (default) or `'curated'`. Data is returned as stored, raw data is not cleaned.
 546            env: Trading environment the data was stored in: `'BACKTEST'` (default) for
 547                downloaded data, `'PAPER'` or `'LIVE'` for streamed data.
 548            dataflow_per_date: Whether to create one dataflow per date.
 549                If False (default), all dates are retrieved in one dataflow
 550                (one multi-file scan), fastest for typical queries.
 551                Set True when:
 552
 553                - all dates don't fit in memory at once, e.g. when downsampling
 554                - using Ray, to parallelize per-date tasks across workers
 555            io: Where and how to retrieve the data from. If None, uses `ParquetIO()`.
 556                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
 557            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
 558                futures. Leave them out to get an error listing the required ones.
 559
 560        Returns:
 561            `RunResult` with the retrieved data, or `self` in pipeline mode.
 562        """
 563        from functools import partial
 564
 565        from pfeed.io.parquet_io import ParquetIO
 566
 567        env = Environment(env.upper())
 568        setup_logging(env=env)
 569        product: BaseProduct = self.data_source.create_product(product, **product_specs)
 570        resolution = Resolution(resolution)
 571        start_date, end_date = self._standardize_dates(
 572            resolution, start_date, end_date, rollback_period
 573        )
 574        data_layer = DataLayer.parse(data_layer)
 575        # search for higher resolutions (closest first), e.g. if resolution is '1m', search '1m' -> '1s' -> '1t'
 576        # only cleaned data has a known schema to resample, raw and curated data only use the requested resolution
 577        search_resolutions = (
 578            [resolution]
 579            if data_layer != DataLayer.CLEANED
 580            else [
 581                resolution,
 582                *sorted(
 583                    [
 584                        _resolution
 585                        for _resolution in self.get_supported_resolutions(
 586                            include_resampled=True
 587                        )
 588                        # quote data has no price or OHLC columns to resample into bars
 589                        if _resolution > resolution and not _resolution.is_quote()
 590                    ]
 591                ),
 592            ]
 593        )
 594
 595        # find the data resolution: the first search resolution stored for every date,
 596        # falls back to the target resolution if none is fully stored
 597        partial_request = partial(
 598            cast("type[MarketFeedRetrieveRequest]", self.Requests.retrieve),
 599            data_source=self.data_source,
 600            data_origin=data_origin,
 601            io_for_retrieval=io or ParquetIO(),
 602            data_layer=data_layer,
 603            env=env,
 604            product=product,
 605            target_resolution=resolution,
 606            start_date=start_date,
 607            end_date=end_date,
 608        )
 609        # data probing, checks storage before any dataflow is created, so the request carries the exact data_resolution to read
 610        missing_dates: list[datetime.date] = []
 611        for search_resolution in search_resolutions:
 612            request = partial_request(data_resolution=search_resolution)
 613            if not (
 614                _missing_dates
 615                := request.create_data_resolution_handler().find_missing_dates_in_storage()
 616            ):
 617                break
 618            # the target resolution is the one fallen back to, report its missing dates
 619            if search_resolution == resolution:
 620                missing_dates = _missing_dates
 621        else:
 622            # list at most 10 missing dates, a long range can miss thousands
 623            shown_dates = ", ".join(str(date) for date in missing_dates[:10]) + (
 624                ", ..." if len(missing_dates) > 10 else ""
 625            )
 626            self.logger.warning(
 627                f"{product} data from {start_date} to {end_date} is not fully stored at any of {search_resolutions} in {request.io_for_retrieval!r}, "
 628                + f"falling back to {resolution}, missing {len(missing_dates)} dates: {shown_dates}"
 629            )
 630            request = partial_request(data_resolution=resolution)
 631        self._append_request(request)
 632        data_model = cast("DataModelT", request.to_data_model())
 633        self._create_batch_dataflows(
 634            extract_func=self._retrieve_impl,
 635            data_models=data_model.split_by_date()
 636            if dataflow_per_date
 637            else [data_model],
 638        )
 639        return self.run() if not self.is_pipeline() else self
 640
 641    @staticmethod
 642    def _retrieve_impl(
 643        request: RequestT, data_model: DataModelT
 644    ) -> pl.LazyFrame | None:
 645        # data_model is of target resolution and has the dataflow's date range (one date when dataflow_per_date=True),
 646        # the handler reads that range at data_resolution
 647        assert isinstance(request, MarketFeedRetrieveRequest)
 648        lf, _ = request.create_data_resolution_handler(data_model).read()
 649        return lf
 650
 651    def _get_default_transformations_for_retrieve(
 652        self, request: RequestT, /
 653    ) -> list[Callable[..., Any]]:
 654        from pfeed.utils import lambda_with_name
 655
 656        default_transformations = []
 657
 658        if cast("MarketFeedRetrieveRequest", request).is_streamed_data():
 659            import polars.selectors as cs
 660
 661            from pfeed.schemas.market_data_schema import RESOLUTION
 662            from pfeed.schemas.streaming.bar_message_schema import START_TS
 663            from pfeed.schemas.streaming.market_message_schema import RECEIVED_AT
 664            from pfeed.schemas.streaming.tick_message_schema import TS
 665            from pfeed.schemas.time_based_data_schema import DATE
 666
 667            # a bar's date is its start, like in batch data, see MarketDataHandler._get_date_col()
 668            date_col = START_TS if request.data_resolution.is_bar() else TS
 669            data_resolution = request.data_resolution
 670            default_transformations.append(
 671                lambda_with_name(
 672                    "streaming_to_batch_schema",
 673                    # stored as UTC, returned tz-naive UTC like batch data, e.g. start_ts, received_at;
 674                    # streamed data has no resolution column;
 675                    # sorted like batch data, the table IO doesn't keep the stored order, e.g. DuckLake's inlined rows and files,
 676                    # received_at orders the updates of the same bar, e.g. incremental bars
 677                    lambda df: (
 678                        df.with_columns(
 679                            cs.datetime(time_zone="*").dt.replace_time_zone(None),
 680                        )
 681                        .with_columns(
 682                            pl.col(date_col).alias(DATE),
 683                            pl.lit(str(data_resolution)).alias(RESOLUTION),
 684                        )
 685                        .sort(date_col, RECEIVED_AT)
 686                    ),
 687                ),
 688            )
 689        # only cleaned data has a known schema to resample and organize, raw and curated data are returned as stored
 690        if request.data_layer == DataLayer.CLEANED:
 691            # bind the class and plain values so the lambdas don't capture self (the whole feed) when pickled by Ray
 692            cls = type(self)
 693            product, target_resolution = request.product, request.target_resolution
 694            default_transformations.extend(
 695                [
 696                    lambda_with_name(
 697                        "resample_data_if_necessary",
 698                        lambda df: cls._resample_data(df, product, target_resolution),
 699                    ),
 700                    cls._organize_columns,
 701                ]
 702            )
 703        return default_transformations
 704
 705    # NOTE: the return type follows the pipeline mode, see BaseFeed's PipelineT
 706    @overload
 707    def stream[F: MarketFeed[Any, Any, Any, Literal[True]]](
 708        self: F,
 709        product: str,
 710        resolution: Resolution | MarketDataType | str,
 711        *,
 712        data_origin: str = "",
 713        io: BaseIO | None = None,
 714        env: Literal[
 715            Environment.PAPER, Environment.LIVE, "PAPER", "LIVE"
 716        ] = Environment.LIVE,
 717        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 718        store_incremental_bars: bool = False,
 719        flush_interval: float = 100,
 720        **product_specs: Any,
 721    ) -> F: ...
 722    @overload
 723    def stream(
 724        self: MarketFeed[Any, Any, Any, Literal[False]],
 725        product: str,
 726        resolution: Resolution | MarketDataType | str,
 727        *,
 728        data_origin: str = "",
 729        io: BaseIO | None = None,
 730        env: Literal[
 731            Environment.PAPER, Environment.LIVE, "PAPER", "LIVE"
 732        ] = Environment.LIVE,
 733        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 734        store_incremental_bars: bool = False,
 735        flush_interval: float = 100,
 736        **product_specs: Any,
 737    ) -> RunResult: ...
 738    @overload
 739    def stream[F: MarketFeed[Any, Any, Any, bool]](
 740        self: F,
 741        product: str,
 742        resolution: Resolution | MarketDataType | str,
 743        *,
 744        data_origin: str = "",
 745        io: BaseIO | None = None,
 746        env: Literal[
 747            Environment.PAPER, Environment.LIVE, "PAPER", "LIVE"
 748        ] = Environment.LIVE,
 749        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 750        store_incremental_bars: bool = False,
 751        flush_interval: float = 100,
 752        **product_specs: Any,
 753    ) -> F | RunResult: ...
 754    @requires("stream")
 755    def stream(
 756        self,
 757        product: str,
 758        resolution: Resolution | MarketDataType | str,
 759        *,
 760        data_origin: str = "",
 761        io: BaseIO | None = None,
 762        env: Literal[
 763            Environment.PAPER, Environment.LIVE, "PAPER", "LIVE"
 764        ] = Environment.LIVE,
 765        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 766        store_incremental_bars: bool = False,
 767        flush_interval: float = 100,
 768        **product_specs: Any,
 769    ) -> Self | RunResult:
 770        """Stream data live from the data source, to replay stored data use replay().
 771
 772        Args:
 773            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
 774            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
 775                If the source doesn't stream it, a finer streamed resolution is used and resampled.
 776            data_origin: Sub-label for data from the same source but different origins.
 777                Defaults to the source name.
 778            io: Where to store the streamed data. If None, data is not stored.
 779                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
 780            env: Trading environment, `'PAPER'` or `'LIVE'` (default).
 781            callback: Sync or async callable called with each streamed `Message`, before it is transformed or stored.
 782                `Message['data']` holds the parsed data as a list, e.g. bars or ticks,
 783                except when using Ray (see `set_num_workers()`), where the workers parse it and `Message['data']` is None.
 784                It is awaited before the next message is read, so a slow callback delays every stream of the feed,
 785                offload heavy work, e.g. to a task or a thread.
 786            store_incremental_bars: Whether to also store the updates of a bar before it closes,
 787                e.g. for a venue that only streams such updates. If False (default), only closed bars are stored.
 788                Only meaningful for bar resolutions when `io` is given.
 789            flush_interval: Seconds between writes of the buffered streamed data to `io`.
 790                Frequent writes create many small files, which slow down reads until the table is compacted.
 791                Only meaningful when `io` is given.
 792            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
 793                futures. Leave them out to get an error listing the required ones.
 794
 795        Returns:
 796            `RunResult` once the streams end, or `self` in pipeline mode.
 797        """
 798        from pfund_kit.utils.temporal import get_utc_now
 799
 800        setup_logging(env=env)
 801        product: BaseProduct = self.data_source.create_product(product, **product_specs)
 802        resolution = Resolution(resolution)
 803        start_date = end_date = get_utc_now().date()
 804
 805        data_resolution = resolution
 806        if resolution.is_bar():
 807            supported = self.data_source.get_stream_api(env).get_supported_resolutions(
 808                product
 809            )
 810            if resolution not in supported:
 811                from pfund_blocks.datas.bar_data import BarData
 812
 813                target = BarData(product, resolution)
 814                candidates = [r for r in supported if target.can_resample_from(r)]
 815                if not candidates:
 816                    raise ValueError(
 817                        f"{resolution!r} is not supported in streaming by {self.name}"
 818                    )
 819                # the coarsest one that fits, e.g. 6m with [1m, 3m, 5m, 1t] -> 3m
 820                data_resolution = min(candidates)
 821                self.logger.warning(
 822                    f"{product} {resolution} is not supported in streaming, using {data_resolution} instead to resample data",
 823                )
 824
 825        request = cast(
 826            "RequestT",
 827            cast("type[MarketFeedStreamRequest]", self.Requests.stream)(
 828                data_source=self.data_source,
 829                data_origin=data_origin,
 830                io=io,
 831                data_layer=DataLayer.CLEANED,
 832                env=env,
 833                product=product,
 834                target_resolution=resolution,
 835                data_resolution=data_resolution,
 836                start_date=start_date,
 837                end_date=end_date,
 838                store_incremental_bars=store_incremental_bars,
 839                flush_interval=flush_interval,
 840            ),
 841        )
 842        self._append_request(request)
 843        data_model = cast("DataModelT", request.to_data_model())
 844        self._create_stream_dataflow(
 845            extract_func=self._stream_impl,
 846            data_model=data_model,
 847            callback=callback,
 848        )
 849        return self.run() if not self.is_pipeline() else self
 850
 851    @staticmethod
 852    def _create_queue_key_from_request(request: RequestT) -> tuple[ProductName, str]:
 853        return (request.product.name, repr(request.data_resolution))
 854
 855    @staticmethod
 856    def _create_queue_key_from_message(message: Message) -> Hashable:
 857        return (message["product"], message["resolution"])
 858
 859    def _get_handler_kwargs(self, request: RequestT) -> dict[str, Any]:
 860        if request.extract_type == "stream":
 861            return {
 862                "store_incremental_bars": cast(
 863                    "MarketFeedStreamRequest", request
 864                ).store_incremental_bars
 865            }
 866        return {}
 867
 868    @staticmethod
 869    def _parse_streaming_message(msg: Message) -> Message:
 870        raise NotImplementedError
 871
 872    async def _stream_impl(self, request: RequestT):
 873        """Streams from the data source's stream API (env=PAPER/LIVE), replaying (env=BACKTEST) is _replay_impl()."""
 874        stream_api = self.data_source.get_stream_api(
 875            cast("Literal[Environment.PAPER, Environment.LIVE]", request.env)
 876        )
 877        try:
 878            await stream_api.connect()
 879            # NOTE: connect() returns right away, this is what notices that the stream has stopped
 880            await stream_api.wait_until_disconnected()
 881        finally:
 882            try:
 883                # NOTE: cancelling wait_until_disconnected() doesn't disconnect the websockets
 884                await stream_api.disconnect()
 885            finally:
 886                for queue in self._queues.values():
 887                    queue.shutdown()
 888
 889    # NOTE: ALL transformation functions MUST be static methods so that they can be serialized by Ray
 890    def _get_default_transformations_for_stream(
 891        self, request: RequestT
 892    ) -> list[Callable[..., Any]]:
 893        from pfund_blocks.datas.bar_data import BarData
 894        from pfund_blocks.datas.tick_data import TickData
 895
 896        from pfeed.utils import lambda_with_name
 897
 898        default_transformations: list[Callable[..., Any]] = [
 899            self._parse_streaming_message
 900        ]
 901
 902        is_resampling = bool(request.target_resolution < request.data_resolution)
 903        if is_resampling:
 904            self.logger.warning(
 905                f"{request.product.name} {request.target_resolution!r} bars will be created by resampling {request.data_resolution!r} data, "
 906                + "incremental bars (not closed yet) will also be sent, check bar.is_closed if you only want closed bars"
 907            )
 908            product = request.product
 909            target_resolution = request.target_resolution
 910            data_resolution = request.data_resolution
 911            # use data_bar to resample data, e.g. bybit doesn't support '1s' data (target resolution), use '1t' (data resolution) instead
 912            data_bar = BarData(product=product, resolution=target_resolution)
 913            # the resampler takes every update in data resolution, and only passes what can be merged to data_bar (its resamplee),
 914            # i.e. every tick, but only closed bars, an unclosed bar's volume would otherwise be counted at each of its updates
 915            if data_resolution.is_tick():
 916                resampler = TickData(product=product, resolution=data_resolution)
 917            elif data_resolution.is_bar():
 918                resampler = BarData(product=product, resolution=data_resolution)
 919            else:
 920                raise TypeError(f"Invalid {data_resolution=} for resampling")
 921            # raises if the resampler's bars don't fit in data_bar's, e.g. 3m bars can't be resampled to 5m bars
 922            data_bar.bind_resampler(resampler)
 923            # bind the class and plain values so the lambda doesn't capture self (the whole feed) when pickled by Ray
 924            cls = type(self)
 925            default_transformations.append(
 926                lambda_with_name(
 927                    "resample_streaming_message",
 928                    lambda msg: cls._resample_streaming_message(
 929                        message=msg,
 930                        resampler=resampler,
 931                        data_bar=data_bar,
 932                        target_resolution=target_resolution,
 933                    ),
 934                )
 935            )
 936        return default_transformations
 937
 938    @staticmethod
 939    def _resample_streaming_message(
 940        message: Message,
 941        resampler: MarketData,
 942        data_bar: BarData,
 943        target_resolution: Resolution,
 944    ) -> Message:
 945        """Updates the resampler with the message's data points, returns the message with data_bar's bars as its data.
 946
 947        The data is every bar closed by the update in time order, or data_bar's open bar if it changed but nothing closed.
 948        It is None if the update didn't change data_bar, e.g. an unclosed finer bar, which the resampler keeps until it closes.
 949        """
 950        current_bar = data_bar.bar
 951        # NOTE: a parsed message can hold several data points (e.g. Bybit sends a list), each is updated in order
 952        data_points = cast("list[Bar | Tick]", message["data"])
 953        # finest first, so the resampler's own closed bars come before data_bar's
 954        closed_bars = [
 955            bar
 956            for data_point in data_points
 957            for bar in resampler.update(data_point)
 958            if bar.resolution == target_resolution
 959        ]
 960        # case 1: the update closed bars, e.g. the 1m bar ending at 9:05 closes the 9:00 5m bar,
 961        # they are sent with their final states, the bar opened by the same update (if any) is sent with the next update
 962        # NOTE: a message with several data points can close several bars, e.g. a batch of trades spanning seconds
 963        if closed_bars:
 964            resampled_bars = closed_bars
 965        # case 2: the update changed the open bar, e.g. the 9:01 1m bar closes and is merged into the open 9:00 5m bar
 966        # Bar is immutable, so a changed data_bar has a new bar object
 967        elif data_bar.bar is not current_bar:
 968            resampled_bars = [data_bar.bar]
 969        # case 3: nothing changed, e.g. an unclosed 1m bar, or no 1m bar has closed since the stream started
 970        else:
 971            resampled_bars = None
 972        return {
 973            **message,
 974            "resolution": repr(target_resolution),
 975            "data": resampled_bars,
 976        }
 977
 978    # NOTE: the return type follows the pipeline mode, see BaseFeed's PipelineT
 979    @overload
 980    def replay[F: MarketFeed[Any, Any, Any, Literal[True]]](
 981        self: F,
 982        product: str,
 983        resolution: Resolution | MarketDataType | str,
 984        *,
 985        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
 986        start_date: datetime.date | str = "",
 987        end_date: datetime.date | str = "",
 988        data_origin: str = "",
 989        io: BaseIO | None = None,
 990        callback: Callable[[Message], Awaitable[None] | None] | None = None,
 991        replay_pace: float | None = 0,
 992        **product_specs: Any,
 993    ) -> F: ...
 994    @overload
 995    def replay(
 996        self: MarketFeed[Any, Any, Any, Literal[False]],
 997        product: str,
 998        resolution: Resolution | MarketDataType | str,
 999        *,
1000        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
1001        start_date: datetime.date | str = "",
1002        end_date: datetime.date | str = "",
1003        data_origin: str = "",
1004        io: BaseIO | None = None,
1005        callback: Callable[[Message], Awaitable[None] | None] | None = None,
1006        replay_pace: float | None = 0,
1007        **product_specs: Any,
1008    ) -> RunResult: ...
1009    @overload
1010    def replay[F: MarketFeed[Any, Any, Any, bool]](
1011        self: F,
1012        product: str,
1013        resolution: Resolution | MarketDataType | str,
1014        *,
1015        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
1016        start_date: datetime.date | str = "",
1017        end_date: datetime.date | str = "",
1018        data_origin: str = "",
1019        io: BaseIO | None = None,
1020        callback: Callable[[Message], Awaitable[None] | None] | None = None,
1021        replay_pace: float | None = 0,
1022        **product_specs: Any,
1023    ) -> F | RunResult: ...
1024    @requires("replay")
1025    def replay(
1026        self,
1027        product: str,
1028        resolution: Resolution | MarketDataType | str,
1029        *,
1030        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
1031        start_date: datetime.date | str = "",
1032        end_date: datetime.date | str = "",
1033        data_origin: str = "",
1034        io: BaseIO | None = None,
1035        callback: Callable[[Message], Awaitable[None] | None] | None = None,
1036        replay_pace: float | None = 0,
1037        **product_specs: Any,
1038    ) -> Self | RunResult:
1039        """Replay stored data at the cleaned data layer as a stream, e.g. for backtesting.
1040
1041        Every replay of the feed is merged into one stream, ordered by when each message would have been received live,
1042        i.e. a bar at its end, a tick at its timestamp.
1043
1044        Args:
1045            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
1046            resolution: Resolution of the stored data to replay, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
1047            rollback_period: How far to roll back from today (UTC) when `start_date`
1048                is empty, e.g. `'7d'` = the last 7 days up to yesterday.
1049                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
1050            start_date: First date (inclusive), e.g. `'2025-01-01'`.
1051            end_date: Last date (inclusive). If empty, defaults to yesterday (UTC).
1052                Requires `start_date`.
1053            data_origin: Sub-label for data from the same source but different origins.
1054                Defaults to the source name.
1055            io: Where to replay the data from. If None, uses `ParquetIO()`.
1056                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
1057            callback: Sync or async callable called with each replayed `Message`, before it is transformed.
1058                `Message['data']` holds a list with one closed bar or one tick, like a streamed message holds a list of them.
1059                It is awaited before the next message is replayed, so a slow callback slows down the replay.
1060            replay_pace: Pacing between messages.
1061                - 0 (default): ASAP — no sleep between messages. Backtests process the whole
1062                  range as fast as possible regardless of resolution or row count.
1063                - >0: fixed cadence in seconds (e.g. 1.0 → one message per wall-second).
1064                  Useful for watching a replay at a steady, human-readable rate.
1065                - None: realistic — sleeps the time between consecutive messages, at most one bar period for bars,
1066                  so data gaps (e.g. weekends) aren't slept through.
1067                  Opt-in only: for fine resolutions or tick data this can take hours, so it is not the default.
1068            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
1069                futures. Leave them out to get an error listing the required ones.
1070
1071        Returns:
1072            `RunResult` once the replay ends, or `self` in pipeline mode.
1073        """
1074        from pfeed.io.parquet_io import ParquetIO
1075
1076        env = Environment.BACKTEST
1077        setup_logging(env=env)
1078        product: BaseProduct = self.data_source.create_product(product, **product_specs)
1079        resolution = Resolution(resolution)
1080        start_date, end_date = self._standardize_dates(
1081            resolution, start_date, end_date, rollback_period
1082        )
1083        request = cast(
1084            "RequestT",
1085            cast("type[MarketFeedReplayRequest]", self.Requests.replay)(
1086                data_source=self.data_source,
1087                data_origin=data_origin,
1088                io_for_replay=io or ParquetIO(),
1089                data_layer=DataLayer.CLEANED,
1090                env=env,
1091                product=product,
1092                target_resolution=resolution,
1093                # NOTE: replays the stored data as is, no resampling
1094                data_resolution=resolution,
1095                start_date=start_date,
1096                end_date=end_date,
1097                replay_pace=replay_pace,
1098            ),
1099        )
1100        self._append_request(request)
1101        data_model = cast("DataModelT", request.to_data_model())
1102        self._create_stream_dataflow(
1103            extract_func=self._replay_impl,
1104            data_model=data_model,
1105            callback=callback,
1106        )
1107        return self.run() if not self.is_pipeline() else self
1108
1109    async def _replay_impl(self, request: RequestT):
1110        """Replays the stored data of every stream request of the feed (env=BACKTEST), merged by the time each message would have been received live.
1111
1112        The extract function of replay(), like _stream_impl() is for stream().
1113        """
1114        import asyncio
1115        from collections import defaultdict
1116        from operator import itemgetter
1117
1118        from pfund_blocks.datas.bar_data import Bar
1119        from pfund_blocks.datas.tick_data import Tick
1120        from pfund_blocks.enums.side import Side
1121        from pfund_blocks.utils.temporal import NS_PER_SECOND
1122
1123        from pfeed.schemas.bar_data_schema import CLOSE, HIGH, LOW, OPEN
1124        from pfeed.schemas.market_data_schema import VOLUME
1125        from pfeed.schemas.tick_data_schema import PRICE, SIDE
1126        from pfeed.schemas.time_based_data_schema import DATE
1127        from pfeed.streaming.replay_clock import ReplayClock
1128
1129        # NOTE: all streams of a feed share one faucet, which runs this once with the first request;
1130        # like a stream API holding every channel, replay reads every request and merges their data by time
1131        requests = [cast("MarketFeedReplayRequest", req) for req in self._requests]
1132        # NOTE: with the data engine, its clock merges the replays of all its feeds by time, it has joined this feed already
1133        clock = self._engine._replay_clock if self._engine else None
1134        if clock is None:
1135            clock = ReplayClock(cast("MarketFeedReplayRequest", request).replay_pace)
1136            clock.join(self)
1137
1138        def _read_day(
1139            handlers: list[tuple[MarketFeedReplayRequest, MarketDataHandler]],
1140        ) -> list[tuple[int, Message]]:
1141            """Returns the day's messages of every request, each with the time it would have been received live, ordered by it."""
1142            messages: list[tuple[int, Message]] = []
1143            for req, handler in handlers:
1144                lf, _ = handler.read()
1145                if lf is None:
1146                    continue
1147                # NOTE: as int ns, iter_rows() would return a python datetime, which only has microseconds
1148                df = lf.with_columns(pl.col(DATE).cast(pl.Int64)).collect()
1149                resolution = req.data_resolution
1150                for row in df.iter_rows(named=True):
1151                    data: Bar | Tick
1152                    if resolution.is_bar():
1153                        # NOTE: a bar's date is its start, but it is only known once closed, so it is received at its end.
1154                        # columns a Bar doesn't have are not replayed, e.g. a stock's dividends and splits
1155                        start_ts: int = row[DATE]
1156                        end_ts = start_ts + resolution.to_seconds() * NS_PER_SECOND
1157                        data = Bar(
1158                            resolution=resolution,
1159                            start_ts=start_ts,
1160                            end_ts=end_ts,
1161                            updated_ts=end_ts,
1162                            open=row[OPEN],
1163                            high=row[HIGH],
1164                            low=row[LOW],
1165                            close=row[CLOSE],
1166                            volume=row[VOLUME],
1167                            is_closed=True,
1168                        )
1169                        received_at = end_ts
1170                    else:
1171                        side = row.get(SIDE)
1172                        data = Tick(
1173                            ts=row[DATE],
1174                            price=row[PRICE],
1175                            volume=row[VOLUME],
1176                            side=Side(side) if side is not None else None,
1177                        )
1178                        received_at = data.ts
1179                    message: Message = {
1180                        "source": req.data_source.name,
1181                        "account": None,
1182                        "channel": None,
1183                        "product": req.product.name,
1184                        # NOTE: must match the queue key, see _create_queue_key_from_request()
1185                        "resolution": repr(resolution),
1186                        "received_at": received_at,
1187                        "message": None,  # raw message as None
1188                        # NOTE: a list, like a streamed message, which can hold several data points
1189                        "data": [data],
1190                        "error": None,
1191                        "extra": {},
1192                    }
1193                    messages.append((received_at, message))
1194            # NOTE: a stable sort, messages received at the same time stay in request order, then in stored order
1195            messages.sort(key=itemgetter(0))
1196            return messages
1197
1198        try:
1199            handlers_per_date: dict[
1200                datetime.date, list[tuple[MarketFeedReplayRequest, MarketDataHandler]]
1201            ] = defaultdict(list)
1202            for req in requests:
1203                if not (req.data_resolution.is_bar() or req.data_resolution.is_tick()):
1204                    raise NotImplementedError(
1205                        f"replaying {req.data_resolution!r} data is not supported yet"
1206                    )
1207                replay_data_model = req.to_data_model()
1208                if missing_dates := req.create_data_resolution_handler(
1209                    replay_data_model
1210                ).find_missing_dates_in_storage():
1211                    self.logger.warning(
1212                        f"{req.product} {req.data_resolution!r} data from {replay_data_model.start_date} to {replay_data_model.end_date} "
1213                        + f"is missing {len(missing_dates)} dates in {req.io_for_replay!r}, they are skipped in the replay"
1214                    )
1215                for daily_data_model in replay_data_model.split_by_date():
1216                    handlers_per_date[daily_data_model.date].append(
1217                        (req, req.create_data_resolution_handler(daily_data_model))
1218                    )
1219            # NOTE: one day at a time to bound memory
1220            for date in sorted(handlers_per_date):
1221                # NOTE: reading blocks, so it runs in a thread, the dataflows keep consuming their queues meanwhile
1222                messages = await asyncio.to_thread(_read_day, handlers_per_date[date])
1223                if not messages:
1224                    self.logger.debug(f"No data to replay on {date}")
1225                    continue
1226                for received_at, message in messages:
1227                    data = cast("list[Bar | Tick]", message["data"])[0]
1228                    # NOTE: at most one bar period for the realistic pace, so data gaps (e.g. weekends) aren't slept through
1229                    max_delay = (
1230                        data.resolution.to_seconds() if isinstance(data, Bar) else None
1231                    )
1232                    await clock.wait_until(self, received_at, max_delay=max_delay)
1233                    await self._callback(message)
1234        finally:
1235            clock.leave(self)
1236            # NOTE: the replay has ended (or failed / was cancelled), ends the dataflows reading the queues
1237            for queue in self._queues.values():
1238                queue.shutdown()
1239
1240    def _get_default_transformations_for_replay(
1241        self, request: RequestT, /
1242    ) -> list[Callable[..., Any]]:
1243        # NOTE: the replayed data is already cleaned
1244        return []

Helper class that provides a standard way to create an ABC using inheritance.

DataModel: ClassVar[type[pfeed.market.data_model.MarketDataModel]] = <class 'pfeed.market.data_model.MarketDataModel'>
data_domain: ClassVar[pfeed.enums.data_category.DataCategory] = <DataCategory.MARKET_DATA: 'MARKET_DATA'>
def get_supported_resolutions( self, include_resampled: bool = False) -> list[pfund_blocks.resolution.Resolution]:
216    def get_supported_resolutions(
217        self, include_resampled: bool = False
218    ) -> list[Resolution]:
219        """Get all supported resolutions for batch processing for the data source.
220
221        Args:
222            include_resampled: If False (default), return only the resolutions the
223                data source literally provides. If True, also include all coarser
224                resolutions derivable by resampling from the finest native one —
225                e.g. a source providing '1t' also "supports" '1s', '1m', '1h', '1d', etc.
226                Quote resolutions pass through but are never a resampling base, so a
227                quote-only source returns only its native resolutions.
228        """
229        native = [
230            Resolution(dtype_or_resol)
231            for dtype_or_resol in self.data_source.data_categories[self.data_domain]
232        ]
233        if not include_resampled or not native:
234            return native
235        resample_bases = [r for r in native if not r.is_quote()]
236        if not resample_bases:
237            return native
238        finest = max(resample_bases)
239        return sorted(
240            set(native) | set(finest.get_coarser_resolutions(exclude_quote=True)),
241            reverse=True,
242        )

Get all supported resolutions for batch processing for the data source.

Arguments:
  • include_resampled: If False (default), return only the resolutions the data source literally provides. If True, also include all coarser resolutions derivable by resampling from the finest native one — e.g. a source providing '1t' also "supports" '1s', '1m', '1h', '1d', etc. Quote resolutions pass through but are never a resampling base, so a quote-only source returns only its native resolutions.
@classmethod
def create_data_model( cls, product: pfund_blocks.entities.products.product_base.BaseProduct | str, resolution: pfund_blocks.resolution.Resolution | pfeed.enums.data_type.MarketDataType | str, start_date: datetime.date | str, end_date: datetime.date | str | None = None, env: pfund_blocks.enums.env.Environment | str = <Environment.BACKTEST: 'BACKTEST'>, data_origin: str = '', **product_specs: Any) -> pfeed.market.data_model.MarketDataModel:
244    @classmethod
245    def create_data_model(
246        cls,
247        product: BaseProduct | str,
248        resolution: Resolution | MarketDataType | str,
249        start_date: datetime.date | str,
250        end_date: datetime.date | str | None = None,
251        env: Environment | str = Environment.BACKTEST,
252        data_origin: str = "",
253        **product_specs: Any,
254    ) -> MarketDataModel:
255        """Create a MarketDataModel instance.
256
257        Args:
258            product: Product basis, e.g. `'BTC_USDT_PERP'`, or a product instance.
259            resolution: Resolution, e.g. `'1t'`, `'1m'`, a `MarketDataType` e.g. "minute", or a `Resolution` instance.
260            start_date: First date (inclusive), e.g. `'2025-01-01'`, or a `datetime.date`.
261            end_date: Last date (inclusive). If None, defaults to `start_date` (a single-day model).
262            env: Trading environment.
263            data_origin: Sub-label for data from the same source but different origins.
264                Defaults to the source name.
265            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
266                futures. Leave them out to get an error listing the required ones.
267        """
268        if isinstance(product, str):
269            product = cls.DataSource.create_product(product, **product_specs)
270        return cls.DataModel(
271            env=env,
272            data_source=cls.DataSource.metadata.name,
273            data_origin=data_origin,
274            product=product,
275            resolution=resolution,
276            start_date=start_date,
277            end_date=end_date or start_date,
278        )

Create a MarketDataModel instance.

Arguments:
  • product: Product basis, e.g. 'BTC_USDT_PERP', or a product instance.
  • resolution: Resolution, e.g. '1t', '1m', a MarketDataType e.g. "minute", or a Resolution instance.
  • start_date: First date (inclusive), e.g. '2025-01-01', or a datetime.date.
  • end_date: Last date (inclusive). If None, defaults to start_date (a single-day model).
  • env: Trading environment.
  • data_origin: Sub-label for data from the same source but different origins. Defaults to the source name.
  • product_specs: Extra product attributes, e.g. expiration='2025-12-26' for futures. Leave them out to get an error listing the required ones.
@requires('download')
def download( self, product: str, resolution: pfund_blocks.resolution.Resolution | pfeed.enums.data_type.MarketDataType | str, *, rollback_period: pfund_blocks.resolution.Resolution | str | Literal['ytd', 'max'] = '1d', start_date: datetime.date | str | None = None, end_date: datetime.date | str | None = None, data_origin: str = '', data_layer: pfeed.enums.data_layer.DataLayer | str = CLEANED, io: pfeed.io.base_io.BaseIO | None = None, **product_specs: Any) -> Self | pfeed.dataflow.result.RunResult:
323    @requires("download")
324    def download(
325        self,
326        product: str,
327        resolution: Resolution | MarketDataType | str,
328        *,
329        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
330        start_date: datetime.date | str | None = None,
331        end_date: datetime.date | str | None = None,
332        data_origin: str = "",
333        data_layer: DataLayer | str = DataLayer.CLEANED,
334        io: BaseIO | None = None,
335        **product_specs: Any,
336    ) -> Self | RunResult:
337        """Download historical data from the data source.
338
339        Args:
340            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
341            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
342                If the source doesn't provide it, the closest finer resolution is
343                downloaded and downsampled.
344            rollback_period: How far to roll back from today (UTC) when `start_date`
345                is None, e.g. `'7d'` = the last 7 days up to yesterday.
346                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
347            start_date: First date (inclusive), e.g. `'2025-01-01'`.
348            end_date: Last date (inclusive). If None, defaults to yesterday (UTC).
349                Requires `start_date`.
350            data_origin: Sub-label for data from the same source but different origins.
351                Defaults to the source name.
352            data_layer: Data layer of the downloaded data, `'raw'` or `'cleaned'` (default).
353                `'cleaned'` runs the default transformations (normalize, standardize columns,
354                downsample); `'raw'` returns the data as-is. The data is stored in this layer if `io` is given.
355            io: Where and how to store the data. If None, data is not stored.
356                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
357            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
358                futures. Leave them out to get an error listing the required ones.
359
360        Returns:
361            `RunResult` with the downloaded data, or `self` in pipeline mode.
362        """
363        env = Environment.BACKTEST
364        setup_logging(env=env)
365        product: BaseProduct = self.data_source.create_product(product, **product_specs)
366        resolution = Resolution(resolution)
367        start_date, end_date = self._standardize_dates(
368            resolution, start_date, end_date, rollback_period
369        )
370        if not (
371            candidates := [
372                r for r in self.get_supported_resolutions() if r >= resolution
373            ]
374        ):
375            raise ValueError(f"{resolution} is not supported by {self.name}")
376        # find the first resolution that is >= (finer/higher) the target resolution
377        data_resolution = min(candidates)
378        request = cast(
379            "RequestT",
380            cast("type[MarketFeedDownloadRequest]", self.Requests.download)(
381                data_source=self.data_source,
382                data_origin=data_origin,
383                io=io,
384                data_layer=data_layer,
385                env=env,
386                product=product,
387                target_resolution=resolution,
388                data_resolution=data_resolution,
389                start_date=start_date,
390                end_date=end_date,
391            ),
392        )
393        self._append_request(request)
394        data_model = cast("DataModelT", request.to_data_model())
395        self._create_batch_dataflows(
396            extract_func=self._download_impl,
397            data_models=data_model.split_by_date()
398            if self.download_dataflow_per_date
399            else [data_model],
400        )
401        return self.run() if not self.is_pipeline() else self

Download historical data from the data source.

Arguments:
  • product: Product basis, e.g. 'BTC_USDT_PERP', 'AAPL_USD_STK'.
  • resolution: Target resolution, e.g. '1t' (tick), '1m', '1d'. If the source doesn't provide it, the closest finer resolution is downloaded and downsampled.
  • rollback_period: How far to roll back from today (UTC) when start_date is None, e.g. '7d' = the last 7 days up to yesterday. Also accepts 'ytd' (year to date) and 'max' (all available data).
  • start_date: First date (inclusive), e.g. '2025-01-01'.
  • end_date: Last date (inclusive). If None, defaults to yesterday (UTC). Requires start_date.
  • data_origin: Sub-label for data from the same source but different origins. Defaults to the source name.
  • data_layer: Data layer of the downloaded data, 'raw' or 'cleaned' (default). 'cleaned' runs the default transformations (normalize, standardize columns, downsample); 'raw' returns the data as-is. The data is stored in this layer if io is given.
  • io: Where and how to store the data. If None, data is not stored. e.g. ParquetIO(base_path='./data'), DuckLakeIO()
  • product_specs: Extra product attributes, e.g. expiration='2025-12-26' for futures. Leave them out to get an error listing the required ones.
Returns:

RunResult with the downloaded data, or self in pipeline mode.

@requires('retrieve')
def retrieve( self, product: str, resolution: pfund_blocks.resolution.Resolution | pfeed.enums.data_type.MarketDataType | str, *, rollback_period: pfund_blocks.resolution.Resolution | str | Literal['ytd', 'max'] = '1d', start_date: datetime.date | str | None = None, end_date: datetime.date | str | None = None, data_origin: str = '', data_layer: pfeed.enums.data_layer.DataLayer | str = CLEANED, env: pfund_blocks.enums.env.Environment | str = <Environment.BACKTEST: 'BACKTEST'>, dataflow_per_date: bool = False, io: pfeed.io.base_io.BaseIO | None = None, **product_specs: Any) -> Self | pfeed.dataflow.result.RunResult:
514    @requires("retrieve")
515    def retrieve(
516        self,
517        product: str,
518        resolution: Resolution | MarketDataType | str,
519        *,
520        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
521        start_date: datetime.date | str | None = None,
522        end_date: datetime.date | str | None = None,
523        data_origin: str = "",
524        data_layer: DataLayer | str = DataLayer.CLEANED,
525        env: Environment | str = Environment.BACKTEST,
526        dataflow_per_date: bool = False,
527        io: BaseIO | None = None,
528        **product_specs: Any,
529    ) -> Self | RunResult:
530        """Retrieve data from storage.
531
532        Args:
533            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
534            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
535                If not stored, finer stored data is retrieved and downsampled.
536            rollback_period: How far to roll back from today (UTC) when `start_date`
537                is None, e.g. `'7d'` = the last 7 days up to yesterday.
538                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
539            start_date: First date (inclusive), e.g. `'2025-01-01'`.
540            end_date: Last date (inclusive). If None, defaults to yesterday (UTC).
541                Requires `start_date`.
542            data_origin: Sub-label for data from the same source but different origins.
543                Defaults to the source name.
544            data_layer: Data layer to retrieve the data from:
545                `'raw'`, `'cleaned'` (default) or `'curated'`. Data is returned as stored, raw data is not cleaned.
546            env: Trading environment the data was stored in: `'BACKTEST'` (default) for
547                downloaded data, `'PAPER'` or `'LIVE'` for streamed data.
548            dataflow_per_date: Whether to create one dataflow per date.
549                If False (default), all dates are retrieved in one dataflow
550                (one multi-file scan), fastest for typical queries.
551                Set True when:
552
553                - all dates don't fit in memory at once, e.g. when downsampling
554                - using Ray, to parallelize per-date tasks across workers
555            io: Where and how to retrieve the data from. If None, uses `ParquetIO()`.
556                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
557            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
558                futures. Leave them out to get an error listing the required ones.
559
560        Returns:
561            `RunResult` with the retrieved data, or `self` in pipeline mode.
562        """
563        from functools import partial
564
565        from pfeed.io.parquet_io import ParquetIO
566
567        env = Environment(env.upper())
568        setup_logging(env=env)
569        product: BaseProduct = self.data_source.create_product(product, **product_specs)
570        resolution = Resolution(resolution)
571        start_date, end_date = self._standardize_dates(
572            resolution, start_date, end_date, rollback_period
573        )
574        data_layer = DataLayer.parse(data_layer)
575        # search for higher resolutions (closest first), e.g. if resolution is '1m', search '1m' -> '1s' -> '1t'
576        # only cleaned data has a known schema to resample, raw and curated data only use the requested resolution
577        search_resolutions = (
578            [resolution]
579            if data_layer != DataLayer.CLEANED
580            else [
581                resolution,
582                *sorted(
583                    [
584                        _resolution
585                        for _resolution in self.get_supported_resolutions(
586                            include_resampled=True
587                        )
588                        # quote data has no price or OHLC columns to resample into bars
589                        if _resolution > resolution and not _resolution.is_quote()
590                    ]
591                ),
592            ]
593        )
594
595        # find the data resolution: the first search resolution stored for every date,
596        # falls back to the target resolution if none is fully stored
597        partial_request = partial(
598            cast("type[MarketFeedRetrieveRequest]", self.Requests.retrieve),
599            data_source=self.data_source,
600            data_origin=data_origin,
601            io_for_retrieval=io or ParquetIO(),
602            data_layer=data_layer,
603            env=env,
604            product=product,
605            target_resolution=resolution,
606            start_date=start_date,
607            end_date=end_date,
608        )
609        # data probing, checks storage before any dataflow is created, so the request carries the exact data_resolution to read
610        missing_dates: list[datetime.date] = []
611        for search_resolution in search_resolutions:
612            request = partial_request(data_resolution=search_resolution)
613            if not (
614                _missing_dates
615                := request.create_data_resolution_handler().find_missing_dates_in_storage()
616            ):
617                break
618            # the target resolution is the one fallen back to, report its missing dates
619            if search_resolution == resolution:
620                missing_dates = _missing_dates
621        else:
622            # list at most 10 missing dates, a long range can miss thousands
623            shown_dates = ", ".join(str(date) for date in missing_dates[:10]) + (
624                ", ..." if len(missing_dates) > 10 else ""
625            )
626            self.logger.warning(
627                f"{product} data from {start_date} to {end_date} is not fully stored at any of {search_resolutions} in {request.io_for_retrieval!r}, "
628                + f"falling back to {resolution}, missing {len(missing_dates)} dates: {shown_dates}"
629            )
630            request = partial_request(data_resolution=resolution)
631        self._append_request(request)
632        data_model = cast("DataModelT", request.to_data_model())
633        self._create_batch_dataflows(
634            extract_func=self._retrieve_impl,
635            data_models=data_model.split_by_date()
636            if dataflow_per_date
637            else [data_model],
638        )
639        return self.run() if not self.is_pipeline() else self

Retrieve data from storage.

Arguments:
  • product: Product basis, e.g. 'BTC_USDT_PERP', 'AAPL_USD_STK'.
  • resolution: Target resolution, e.g. '1t' (tick), '1m', '1d'. If not stored, finer stored data is retrieved and downsampled.
  • rollback_period: How far to roll back from today (UTC) when start_date is None, e.g. '7d' = the last 7 days up to yesterday. Also accepts 'ytd' (year to date) and 'max' (all available data).
  • start_date: First date (inclusive), e.g. '2025-01-01'.
  • end_date: Last date (inclusive). If None, defaults to yesterday (UTC). Requires start_date.
  • data_origin: Sub-label for data from the same source but different origins. Defaults to the source name.
  • data_layer: Data layer to retrieve the data from: 'raw', 'cleaned' (default) or 'curated'. Data is returned as stored, raw data is not cleaned.
  • env: Trading environment the data was stored in: 'BACKTEST' (default) for downloaded data, 'PAPER' or 'LIVE' for streamed data.
  • dataflow_per_date: Whether to create one dataflow per date. If False (default), all dates are retrieved in one dataflow (one multi-file scan), fastest for typical queries. Set True when:

    • all dates don't fit in memory at once, e.g. when downsampling
    • using Ray, to parallelize per-date tasks across workers
  • io: Where and how to retrieve the data from. If None, uses ParquetIO(). e.g. ParquetIO(base_path='./data'), DuckLakeIO()
  • product_specs: Extra product attributes, e.g. expiration='2025-12-26' for futures. Leave them out to get an error listing the required ones.
Returns:

RunResult with the retrieved data, or self in pipeline mode.

@requires('stream')
def stream( self, product: str, resolution: pfund_blocks.resolution.Resolution | pfeed.enums.data_type.MarketDataType | str, *, data_origin: str = '', io: pfeed.io.base_io.BaseIO | None = None, env: Literal[<Environment.PAPER: 'PAPER'>, <Environment.LIVE: 'LIVE'>, 'PAPER', 'LIVE'] = <Environment.LIVE: 'LIVE'>, callback: Callable[[pfund_blocks.typing.Message], Awaitable[None] | None] | None = None, store_incremental_bars: bool = False, flush_interval: float = 100, **product_specs: Any) -> Self | pfeed.dataflow.result.RunResult:
754    @requires("stream")
755    def stream(
756        self,
757        product: str,
758        resolution: Resolution | MarketDataType | str,
759        *,
760        data_origin: str = "",
761        io: BaseIO | None = None,
762        env: Literal[
763            Environment.PAPER, Environment.LIVE, "PAPER", "LIVE"
764        ] = Environment.LIVE,
765        callback: Callable[[Message], Awaitable[None] | None] | None = None,
766        store_incremental_bars: bool = False,
767        flush_interval: float = 100,
768        **product_specs: Any,
769    ) -> Self | RunResult:
770        """Stream data live from the data source, to replay stored data use replay().
771
772        Args:
773            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
774            resolution: Target resolution, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
775                If the source doesn't stream it, a finer streamed resolution is used and resampled.
776            data_origin: Sub-label for data from the same source but different origins.
777                Defaults to the source name.
778            io: Where to store the streamed data. If None, data is not stored.
779                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
780            env: Trading environment, `'PAPER'` or `'LIVE'` (default).
781            callback: Sync or async callable called with each streamed `Message`, before it is transformed or stored.
782                `Message['data']` holds the parsed data as a list, e.g. bars or ticks,
783                except when using Ray (see `set_num_workers()`), where the workers parse it and `Message['data']` is None.
784                It is awaited before the next message is read, so a slow callback delays every stream of the feed,
785                offload heavy work, e.g. to a task or a thread.
786            store_incremental_bars: Whether to also store the updates of a bar before it closes,
787                e.g. for a venue that only streams such updates. If False (default), only closed bars are stored.
788                Only meaningful for bar resolutions when `io` is given.
789            flush_interval: Seconds between writes of the buffered streamed data to `io`.
790                Frequent writes create many small files, which slow down reads until the table is compacted.
791                Only meaningful when `io` is given.
792            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
793                futures. Leave them out to get an error listing the required ones.
794
795        Returns:
796            `RunResult` once the streams end, or `self` in pipeline mode.
797        """
798        from pfund_kit.utils.temporal import get_utc_now
799
800        setup_logging(env=env)
801        product: BaseProduct = self.data_source.create_product(product, **product_specs)
802        resolution = Resolution(resolution)
803        start_date = end_date = get_utc_now().date()
804
805        data_resolution = resolution
806        if resolution.is_bar():
807            supported = self.data_source.get_stream_api(env).get_supported_resolutions(
808                product
809            )
810            if resolution not in supported:
811                from pfund_blocks.datas.bar_data import BarData
812
813                target = BarData(product, resolution)
814                candidates = [r for r in supported if target.can_resample_from(r)]
815                if not candidates:
816                    raise ValueError(
817                        f"{resolution!r} is not supported in streaming by {self.name}"
818                    )
819                # the coarsest one that fits, e.g. 6m with [1m, 3m, 5m, 1t] -> 3m
820                data_resolution = min(candidates)
821                self.logger.warning(
822                    f"{product} {resolution} is not supported in streaming, using {data_resolution} instead to resample data",
823                )
824
825        request = cast(
826            "RequestT",
827            cast("type[MarketFeedStreamRequest]", self.Requests.stream)(
828                data_source=self.data_source,
829                data_origin=data_origin,
830                io=io,
831                data_layer=DataLayer.CLEANED,
832                env=env,
833                product=product,
834                target_resolution=resolution,
835                data_resolution=data_resolution,
836                start_date=start_date,
837                end_date=end_date,
838                store_incremental_bars=store_incremental_bars,
839                flush_interval=flush_interval,
840            ),
841        )
842        self._append_request(request)
843        data_model = cast("DataModelT", request.to_data_model())
844        self._create_stream_dataflow(
845            extract_func=self._stream_impl,
846            data_model=data_model,
847            callback=callback,
848        )
849        return self.run() if not self.is_pipeline() else self

Stream data live from the data source, to replay stored data use replay().

Arguments:
  • product: Product basis, e.g. 'BTC_USDT_PERP', 'AAPL_USD_STK'.
  • resolution: Target resolution, e.g. '1t' (tick), '1m', '1d'. If the source doesn't stream it, a finer streamed resolution is used and resampled.
  • data_origin: Sub-label for data from the same source but different origins. Defaults to the source name.
  • io: Where to store the streamed data. If None, data is not stored. e.g. ParquetIO(base_path='./data'), DuckLakeIO()
  • env: Trading environment, 'PAPER' or 'LIVE' (default).
  • callback: Sync or async callable called with each streamed Message, before it is transformed or stored. Message['data'] holds the parsed data as a list, e.g. bars or ticks, except when using Ray (see set_num_workers()), where the workers parse it and Message['data'] is None. It is awaited before the next message is read, so a slow callback delays every stream of the feed, offload heavy work, e.g. to a task or a thread.
  • store_incremental_bars: Whether to also store the updates of a bar before it closes, e.g. for a venue that only streams such updates. If False (default), only closed bars are stored. Only meaningful for bar resolutions when io is given.
  • flush_interval: Seconds between writes of the buffered streamed data to io. Frequent writes create many small files, which slow down reads until the table is compacted. Only meaningful when io is given.
  • product_specs: Extra product attributes, e.g. expiration='2025-12-26' for futures. Leave them out to get an error listing the required ones.
Returns:

RunResult once the streams end, or self in pipeline mode.

@requires('replay')
def replay( self, product: str, resolution: pfund_blocks.resolution.Resolution | pfeed.enums.data_type.MarketDataType | str, *, rollback_period: pfund_blocks.resolution.Resolution | str | Literal['ytd', 'max'] = '1d', start_date: datetime.date | str = '', end_date: datetime.date | str = '', data_origin: str = '', io: pfeed.io.base_io.BaseIO | None = None, callback: Callable[[pfund_blocks.typing.Message], Awaitable[None] | None] | None = None, replay_pace: float | None = 0, **product_specs: Any) -> Self | pfeed.dataflow.result.RunResult:
1024    @requires("replay")
1025    def replay(
1026        self,
1027        product: str,
1028        resolution: Resolution | MarketDataType | str,
1029        *,
1030        rollback_period: Resolution | str | Literal["ytd", "max"] = "1d",
1031        start_date: datetime.date | str = "",
1032        end_date: datetime.date | str = "",
1033        data_origin: str = "",
1034        io: BaseIO | None = None,
1035        callback: Callable[[Message], Awaitable[None] | None] | None = None,
1036        replay_pace: float | None = 0,
1037        **product_specs: Any,
1038    ) -> Self | RunResult:
1039        """Replay stored data at the cleaned data layer as a stream, e.g. for backtesting.
1040
1041        Every replay of the feed is merged into one stream, ordered by when each message would have been received live,
1042        i.e. a bar at its end, a tick at its timestamp.
1043
1044        Args:
1045            product: Product basis, e.g. `'BTC_USDT_PERP'`, `'AAPL_USD_STK'`.
1046            resolution: Resolution of the stored data to replay, e.g. `'1t'` (tick), `'1m'`, `'1d'`.
1047            rollback_period: How far to roll back from today (UTC) when `start_date`
1048                is empty, e.g. `'7d'` = the last 7 days up to yesterday.
1049                Also accepts `'ytd'` (year to date) and `'max'` (all available data).
1050            start_date: First date (inclusive), e.g. `'2025-01-01'`.
1051            end_date: Last date (inclusive). If empty, defaults to yesterday (UTC).
1052                Requires `start_date`.
1053            data_origin: Sub-label for data from the same source but different origins.
1054                Defaults to the source name.
1055            io: Where to replay the data from. If None, uses `ParquetIO()`.
1056                e.g. `ParquetIO(base_path='./data')`, `DuckLakeIO()`
1057            callback: Sync or async callable called with each replayed `Message`, before it is transformed.
1058                `Message['data']` holds a list with one closed bar or one tick, like a streamed message holds a list of them.
1059                It is awaited before the next message is replayed, so a slow callback slows down the replay.
1060            replay_pace: Pacing between messages.
1061                - 0 (default): ASAP — no sleep between messages. Backtests process the whole
1062                  range as fast as possible regardless of resolution or row count.
1063                - >0: fixed cadence in seconds (e.g. 1.0 → one message per wall-second).
1064                  Useful for watching a replay at a steady, human-readable rate.
1065                - None: realistic — sleeps the time between consecutive messages, at most one bar period for bars,
1066                  so data gaps (e.g. weekends) aren't slept through.
1067                  Opt-in only: for fine resolutions or tick data this can take hours, so it is not the default.
1068            product_specs: Extra product attributes, e.g. `expiration='2025-12-26'` for
1069                futures. Leave them out to get an error listing the required ones.
1070
1071        Returns:
1072            `RunResult` once the replay ends, or `self` in pipeline mode.
1073        """
1074        from pfeed.io.parquet_io import ParquetIO
1075
1076        env = Environment.BACKTEST
1077        setup_logging(env=env)
1078        product: BaseProduct = self.data_source.create_product(product, **product_specs)
1079        resolution = Resolution(resolution)
1080        start_date, end_date = self._standardize_dates(
1081            resolution, start_date, end_date, rollback_period
1082        )
1083        request = cast(
1084            "RequestT",
1085            cast("type[MarketFeedReplayRequest]", self.Requests.replay)(
1086                data_source=self.data_source,
1087                data_origin=data_origin,
1088                io_for_replay=io or ParquetIO(),
1089                data_layer=DataLayer.CLEANED,
1090                env=env,
1091                product=product,
1092                target_resolution=resolution,
1093                # NOTE: replays the stored data as is, no resampling
1094                data_resolution=resolution,
1095                start_date=start_date,
1096                end_date=end_date,
1097                replay_pace=replay_pace,
1098            ),
1099        )
1100        self._append_request(request)
1101        data_model = cast("DataModelT", request.to_data_model())
1102        self._create_stream_dataflow(
1103            extract_func=self._replay_impl,
1104            data_model=data_model,
1105            callback=callback,
1106        )
1107        return self.run() if not self.is_pipeline() else self

Replay stored data at the cleaned data layer as a stream, e.g. for backtesting.

Every replay of the feed is merged into one stream, ordered by when each message would have been received live, i.e. a bar at its end, a tick at its timestamp.

Arguments:
  • product: Product basis, e.g. 'BTC_USDT_PERP', 'AAPL_USD_STK'.
  • resolution: Resolution of the stored data to replay, e.g. '1t' (tick), '1m', '1d'.
  • rollback_period: How far to roll back from today (UTC) when start_date is empty, e.g. '7d' = the last 7 days up to yesterday. Also accepts 'ytd' (year to date) and 'max' (all available data).
  • start_date: First date (inclusive), e.g. '2025-01-01'.
  • end_date: Last date (inclusive). If empty, defaults to yesterday (UTC). Requires start_date.
  • data_origin: Sub-label for data from the same source but different origins. Defaults to the source name.
  • io: Where to replay the data from. If None, uses ParquetIO(). e.g. ParquetIO(base_path='./data'), DuckLakeIO()
  • callback: Sync or async callable called with each replayed Message, before it is transformed. Message['data'] holds a list with one closed bar or one tick, like a streamed message holds a list of them. It is awaited before the next message is replayed, so a slow callback slows down the replay.
  • replay_pace: Pacing between messages.
    • 0 (default): ASAP — no sleep between messages. Backtests process the whole range as fast as possible regardless of resolution or row count.
    • >0: fixed cadence in seconds (e.g. 1.0 → one message per wall-second). Useful for watching a replay at a steady, human-readable rate.
    • None: realistic — sleeps the time between consecutive messages, at most one bar period for bars, so data gaps (e.g. weekends) aren't slept through. Opt-in only: for fine resolutions or tick data this can take hours, so it is not the default.
  • product_specs: Extra product attributes, e.g. expiration='2025-12-26' for futures. Leave them out to get an error listing the required ones.
Returns:

RunResult once the replay ends, or self in pipeline mode.

class MarketFeed.Requests:
48    class Requests:  # ty: ignore[incompatible-attribute-override]
49        """Request class per verb, a subclass sets a verb to None if it doesn't support it."""
50
51        download: ClassVar[type[MarketFeedDownloadRequest] | None] = (
52            MarketFeedDownloadRequest
53        )
54        retrieve: ClassVar[type[MarketFeedRetrieveRequest] | None] = (
55            MarketFeedRetrieveRequest
56        )
57        stream: ClassVar[type[MarketFeedStreamRequest] | None] = MarketFeedStreamRequest
58        replay: ClassVar[type[MarketFeedReplayRequest] | None] = MarketFeedReplayRequest

Request class per verb, a subclass sets a verb to None if it doesn't support it.

download: ClassVar[type[pfeed.market.requests.download_request.MarketFeedDownloadRequest] | None] = <class 'pfeed.market.requests.download_request.MarketFeedDownloadRequest'>
retrieve: ClassVar[type[pfeed.market.requests.retrieve_request.MarketFeedRetrieveRequest] | None] = <class 'pfeed.market.requests.retrieve_request.MarketFeedRetrieveRequest'>
stream: ClassVar[type[pfeed.market.requests.stream_request.MarketFeedStreamRequest] | None] = <class 'pfeed.market.requests.stream_request.MarketFeedStreamRequest'>
replay: ClassVar[type[pfeed.market.requests.replay_request.MarketFeedReplayRequest] | None] = <class 'pfeed.market.requests.replay_request.MarketFeedReplayRequest'>
class DuckLakeIO(pfeed.io.table_io.TableIO):
 28class DuckLakeIO(TableIO):
 29    """Stores datasets as DuckLake tables, with a SQLite catalog; metadata in a per-dataset metadata table.
 30
 31    See TableIO for what all table-format IOs share.
 32    Layout: <base_path>/pfeed.db (catalog) + <base_path>/data/ (parquet files written by DuckLake)
 33    - DatasetKey -> table "<schema>"."<table>", see TableIO.
 34    - metadata: table "<table>__metadata" in the same schema, one row per partition:
 35        the partition_by columns (a DatePartition's as a date) + METADATA_COLUMN (JSON string).
 36        Written in the same transaction as the data, so the commit marker comes for free.
 37    - physical layout: the table is partitioned by the column levels of partition_by only (e.g. product),
 38        so a file never mixes their values, whatever the write pattern or compaction.
 39        A DatePartition level is logical only (the unit of replace, metadata and existence), matched by a
 40        date range; partitioning by it would keep every day in files of its own, so optimize() could never
 41        merge e.g. daily bars. Reads still skip other days' files by their min/max stats of the column.
 42    - data files are flat under <base_path>/data/<schema>/<table>/, without hive dirs per partition value,
 43        since DuckLake finds a file's partition values in the catalog, not in its path.
 44    - replace/append are one transaction each:
 45        replace: DELETE + INSERT data, DELETE + INSERT metadata; append: INSERT data, INSERT metadata of new partitions only.
 46        Concurrent writes to the same dataset conflict even on disjoint partitions (DuckLake detects
 47        conflicts per table), so a conflicting transaction is retried as a whole.
 48    """
 49
 50    DEFAULT_DIR_NAME: ClassVar[str] = "ducklake"
 51    CATALOG_FILE_NAME: ClassVar[str] = "pfeed.db"
 52    DATA_DIR_NAME: ClassVar[str] = "data"
 53    METADATA_TABLE_SUFFIX: ClassVar[str] = "__metadata"
 54    _RETRY_ON: ClassVar[tuple[type[Exception], ...]] = (duckdb.TransactionException,)
 55    _CATALOG_ALIAS: ClassVar[str] = "lake"
 56    # stored in the catalog, so they apply to every table:
 57    # - parquet_compression: DuckLake defaults to snappy; zstd made Bybit tick data ~40% smaller
 58    #     at about the same read speed, and matches ParquetIO's default
 59    # - hive_file_pattern: no partition dirs (e.g. product=BTC/), see the class docstring
 60    CATALOG_OPTIONS: ClassVar[dict[str, str | bool]] = {
 61        "parquet_compression": "zstd",
 62        "hive_file_pattern": False,
 63    }
 64
 65    def __init__(
 66        self, base_path: str | None = None, data_inlining_row_limit: int | None = None
 67    ):
 68        """
 69        Args:
 70            base_path: root directory of the catalog and data files. Local paths only for now.
 71                Defaults to <config.data_path>/<DEFAULT_DIR_NAME>.
 72            data_inlining_row_limit: inserts of up to this many rows are stored inside the catalog
 73                instead of as parquet files, so small writes (e.g. live appends) don't create many tiny files.
 74                0 disables inlining; None uses DuckLake's default.
 75        """
 76        if data_inlining_row_limit is not None and (
 77            not isinstance(data_inlining_row_limit, int) or data_inlining_row_limit < 0
 78        ):
 79            raise ValueError(
 80                f"data_inlining_row_limit must be an int >= 0, got {data_inlining_row_limit!r}"
 81            )
 82        super().__init__(base_path)
 83        self._data_inlining_row_limit = data_inlining_row_limit
 84        self._conn: duckdb.DuckDBPyConnection | None = None
 85        self._conn_pid: int | None = None
 86        # creates the catalog now, since processes creating it at the same time fail with "database is locked"
 87        self._connect().close()
 88        # WAL keeps catalog reads from waiting on a concurrent write (in 1s busy waits),
 89        # which made concurrent writes ~5x slower and sometimes fail with "database is locked"
 90        with contextlib.closing(
 91            sqlite3.connect(os.path.join(self._base_path, self.CATALOG_FILE_NAME))
 92        ) as conn:
 93            conn.execute("PRAGMA journal_mode=WAL")
 94        self._set_catalog_options()
 95
 96    def _set_catalog_options(self) -> None:
 97        """Sets CATALOG_OPTIONS in the catalog, only the ones that differ, so creating an IO doesn't write to it every time.
 98
 99        Files written before an option changed stay as they are, e.g. snappy files until optimize() rewrites them.
100        """
101        cursor = self._connect()
102        try:
103            current = dict(
104                cursor.execute(
105                    f"SELECT option_name, value FROM {self._CATALOG_ALIAS}.options()"
106                ).fetchall()
107            )
108            for name, value in self.CATALOG_OPTIONS.items():
109                # options() returns every value as a string, e.g. 'false', but set_option() needs its real type
110                if current.get(name) != (
111                    str(value).lower() if isinstance(value, bool) else value
112                ):
113                    cursor.execute(
114                        f"CALL {self._CATALOG_ALIAS}.set_option(?, ?)", [name, value]
115                    )
116        finally:
117            cursor.close()
118
119    def __getstate__(self) -> dict:
120        # a connection can't be pickled (e.g. sent to a Ray worker), each process opens its own
121        return self.__dict__ | {"_conn": None, "_conn_pid": None}
122
123    def _connect(self) -> duckdb.DuckDBPyConnection:
124        """Returns a new cursor on this process's connection, attaching the catalog on first use.
125
126        A cursor per call keeps threads from sharing one, and a connection inherited by fork isn't reused.
127        """
128        if self._conn is None or self._conn_pid != os.getpid():
129            os.makedirs(self._base_path, exist_ok=True)
130            catalog_path = os.path.join(
131                self._base_path, self.CATALOG_FILE_NAME
132            ).replace("'", "''")
133            data_path = os.path.join(self._base_path, self.DATA_DIR_NAME, "").replace(
134                "'", "''"
135            )
136            options = f"DATA_PATH '{data_path}'"
137            # not stored in the catalog, so it has to be passed on every attach
138            if self._data_inlining_row_limit is not None:
139                options += f", DATA_INLINING_ROW_LIMIT {self._data_inlining_row_limit}"
140            conn = duckdb.connect()
141            # otherwise timestamps with a time zone are returned in the machine's local time zone;
142            # GLOBAL, so the cursors (new connections) have it too
143            conn.execute("SET GLOBAL TimeZone = 'UTC'")
144            conn.execute(
145                f"ATTACH 'ducklake:sqlite:{catalog_path}' AS {self._CATALOG_ALIAS} ({options})"
146            )
147            self._conn, self._conn_pid = conn, os.getpid()
148        return self._conn.cursor()
149
150    def _dataset_tables(self, key: DatasetKey) -> tuple[str, str, str]:
151        """Returns (schema, table, metadata table) of the dataset, see TableIO._table_names()."""
152        schema, table = self._table_names(key)
153        if table.endswith(self.METADATA_TABLE_SUFFIX):
154            raise ValueError(
155                f"dataset name {table!r} cannot end with {self.METADATA_TABLE_SUFFIX!r}, it is reserved"
156            )
157        return schema, table, table + self.METADATA_TABLE_SUFFIX
158
159    def _table_exists(
160        self, cursor: duckdb.DuckDBPyConnection, schema: str, table: str
161    ) -> bool:
162        """Checks if the table exists, raising ValueError if its schema/table exists under a different case."""
163        found = cursor.execute(
164            """
165            SELECT s.schema_name, t.table_name
166            FROM duckdb_schemas() s
167            LEFT JOIN duckdb_tables() t
168                ON t.database_name = s.database_name AND t.schema_name = s.schema_name
169                AND lower(t.table_name) = lower(?)
170            WHERE s.database_name = ? AND lower(s.schema_name) = lower(?)
171            """,
172            [table, self._CATALOG_ALIAS, schema],
173        ).fetchall()
174        for found_schema, found_table in found:
175            if found_schema != schema or (
176                found_table is not None and found_table != table
177            ):
178                raise ValueError(
179                    f"{schema}.{table} differs only by case from existing {found_schema}.{found_table}, "
180                    "DuckDB names are case-insensitive"
181                )
182        return any(found_table is not None for _, found_table in found)
183
184    def _check_partition_columns(
185        self, cursor: duckdb.DuckDBPyConnection, metadata_ref: str, key: DatasetKey
186    ) -> None:
187        """Raises ValueError if `key` partitions the dataset differently than it is stored,
188        e.g. a key with no partition levels would make a replace delete every row of the dataset.
189
190        The metadata table's columns are the stored partition columns (+ METADATA_COLUMN).
191        """
192        stored = [
193            row[0]
194            for row in cursor.execute(f"DESCRIBE {metadata_ref}").fetchall()
195            if row[0] != self.METADATA_COLUMN
196        ]
197        if stored != key.partition_columns:
198            raise ValueError(
199                f"{key} is partitioned by {stored}, but the key has {key.partition_columns}"
200            )
201
202    @staticmethod
203    def _in_partitions(
204        key: DatasetKey, partitions: list[Partition]
205    ) -> tuple[str, list[PartitionValue]]:
206        """Returns a WHERE clause matching rows in `partitions`, with its query params.
207
208        Works on both the data and the metadata table: a DatePartition level matches its column
209        by the date's range [date, date + 1 day), which is the date itself in the metadata table,
210        and lets DuckLake skip the data files of other days by their min/max stats (a CAST(column AS DATE)
211        filter wouldn't).
212        """
213        if not key.partition_by:
214            return "", []  # one partition, every row is in it
215        conditions: list[str] = []
216        params: list[PartitionValue] = []
217        for partition in partitions:
218            level_conditions: list[str] = []
219            for level, value in zip(key.partition_by, partition, strict=True):
220                if isinstance(level, DatePartition):
221                    if isinstance(value, datetime.datetime) or not isinstance(
222                        value, datetime.date
223                    ):
224                        raise TypeError(
225                            f"{level} partition value must be a date, got {value!r}"
226                        )
227                    level_conditions.append(
228                        f"{_quote(level.column)} >= ? AND {_quote(level.column)} < ?"
229                    )
230                    params += [value, value + datetime.timedelta(days=1)]
231                else:
232                    level_conditions.append(f"{_quote(level)} = ?")
233                    params.append(value)
234            conditions.append("(" + " AND ".join(level_conditions) + ")")
235        return "WHERE " + " OR ".join(conditions), params
236
237    def _write(
238        self,
239        key: DatasetKey,
240        data: pa.Table,
241        partitions: dict[Partition, Metadata],
242        mode: Literal["replace", "append"],
243    ) -> None:
244        # one row per partition: its values (typed like data's partition columns, a DatePartition's as dates,
245        # so the filters match both tables) + metadata
246        partition_metadata_table = pa.table(
247            {
248                **dict(
249                    zip(
250                        key.partition_columns,
251                        self._partition_arrays(key, data, list(partitions)),
252                        strict=True,
253                    )
254                ),
255                self.METADATA_COLUMN: [
256                    self._dump_metadata(md) for md in partitions.values()
257                ],
258            }
259        )
260
261        self._with_retries(
262            lambda: self._write_transaction(
263                key, data, list(partitions), partition_metadata_table, mode
264            ),
265            f"write to {key}",
266        )
267
268    def _write_transaction(
269        self,
270        key: DatasetKey,
271        data: pa.Table,
272        partitions: list[Partition],
273        partition_metadata_table: pa.Table,
274        mode: str,
275    ) -> None:
276        schema, table, metadata_table = self._dataset_tables(key)
277        data_ref = f"{self._CATALOG_ALIAS}.{_quote(schema)}.{_quote(table)}"
278        metadata_ref = (
279            f"{self._CATALOG_ALIAS}.{_quote(schema)}.{_quote(metadata_table)}"
280        )
281        cursor = self._connect()
282        cursor.register("_data", data)
283        cursor.register("_partition_metadata", partition_metadata_table)
284        cursor.execute("BEGIN")
285        try:
286            cursor.execute(
287                f"CREATE SCHEMA IF NOT EXISTS {self._CATALOG_ALIAS}.{_quote(schema)}"
288            )
289            if not self._table_exists(cursor, schema, table):
290                cursor.execute(
291                    f"CREATE TABLE {data_ref} AS SELECT * FROM _data LIMIT 0"
292                )
293                # only the column levels, a DatePartition level is logical only (see the class docstring)
294                if cols := [
295                    _quote(level)
296                    for level in key.partition_by
297                    if not isinstance(level, DatePartition)
298                ]:
299                    cursor.execute(
300                        f"ALTER TABLE {data_ref} SET PARTITIONED BY ({', '.join(cols)})"
301                    )
302                cursor.execute(
303                    f"CREATE TABLE {metadata_ref} AS SELECT * FROM _partition_metadata LIMIT 0"
304                )
305            else:
306                self._check_partition_columns(cursor, metadata_ref, key)
307                # schema drift: add new columns, missing ones are null-filled by INSERT BY NAME
308                existing_types = {
309                    row[0]: row[1]
310                    for row in cursor.execute(f"DESCRIBE {data_ref}").fetchall()
311                }
312                for col, dtype, *_ in cursor.execute(
313                    "DESCRIBE SELECT * FROM _data"
314                ).fetchall():
315                    if col not in existing_types:
316                        cursor.execute(
317                            f"ALTER TABLE {data_ref} ADD COLUMN {_quote(col)} {dtype}"
318                        )
319                    elif existing_types[col] != dtype:
320                        raise TypeError(
321                            f"column {col!r} is {dtype}, but {existing_types[col]} in the dataset"
322                        )
323            predicate, params = self._in_partitions(key, partitions)
324            if mode == "replace":
325                cursor.execute(f"DELETE FROM {data_ref} {predicate}", params)
326                cursor.execute(f"INSERT INTO {data_ref} BY NAME SELECT * FROM _data")
327                cursor.execute(f"DELETE FROM {metadata_ref} {predicate}", params)
328                cursor.execute(
329                    f"INSERT INTO {metadata_ref} BY NAME SELECT * FROM _partition_metadata"
330                )
331            else:
332                cursor.execute(f"INSERT INTO {data_ref} BY NAME SELECT * FROM _data")
333                # only the partitions without metadata, existing ones keep theirs
334                # (both tables have a DatePartition's values as dates, so they compare by equality)
335                same_partition = (
336                    " AND ".join(
337                        [
338                            f"m.{_quote(col)} IS NOT DISTINCT FROM p.{_quote(col)}"
339                            for col in key.partition_columns
340                        ]
341                    )
342                    or "TRUE"
343                )
344                cursor.execute(
345                    f"INSERT INTO {metadata_ref} BY NAME SELECT * FROM _partition_metadata p "
346                    + f"WHERE NOT EXISTS (SELECT 1 FROM {metadata_ref} m WHERE {same_partition})"
347                )
348            cursor.execute("COMMIT")
349        except BaseException:
350            # a failed COMMIT has already rolled back, then ROLLBACK raises and would hide the original error
351            with contextlib.suppress(duckdb.TransactionException):
352                cursor.execute("ROLLBACK")
353            raise
354        finally:
355            cursor.close()
356
357    def _read(
358        self,
359        key: DatasetKey,
360        partitions: list[Partition] | None,
361    ) -> tuple[pl.LazyFrame | None, dict[Partition, Metadata]]:
362        schema, table, metadata_table = self._dataset_tables(key)
363        data_ref = f"{self._CATALOG_ALIAS}.{_quote(schema)}.{_quote(table)}"
364        metadata_ref = (
365            f"{self._CATALOG_ALIAS}.{_quote(schema)}.{_quote(metadata_table)}"
366        )
367        cursor = self._connect()
368        # metadata and snapshot id from the same transaction, so the data can be pinned to that snapshot
369        cursor.execute("BEGIN")
370        try:
371            if not self._table_exists(cursor, schema, table):
372                return None, {}
373            self._check_partition_columns(cursor, metadata_ref, key)
374            ((snapshot_id,),) = cursor.execute(
375                f"SELECT id FROM {self._CATALOG_ALIAS}.current_snapshot()"
376            ).fetchall()
377            predicate, params = (
378                self._in_partitions(key, partitions)
379                if partitions is not None
380                else ("", [])
381            )
382            cols = ", ".join(
383                [*(_quote(col) for col in key.partition_columns), self.METADATA_COLUMN]
384            )
385            rows = cursor.execute(
386                f"SELECT {cols} FROM {metadata_ref} {predicate}", params
387            ).fetchall()
388        finally:
389            cursor.execute("COMMIT")
390        metadata: dict[Partition, Metadata] = {
391            tuple(row[:-1]): json.loads(row[-1]) for row in rows
392        }
393        if not metadata:
394            return None, {}
395
396        # only rows of the partitions with metadata (commit marker)
397        predicate, params = self._in_partitions(key, list(metadata))
398        relation = cursor.sql(
399            f"SELECT * FROM {data_ref} AT (VERSION => {snapshot_id}) {predicate}",
400            params=params,
401        )
402        if not relation.limit(1).fetchall():
403            return None, metadata
404        return relation.pl(lazy=True), metadata
405
406    def optimize(self) -> None:
407        """See TableIO.optimize().
408
409        - moves inlined rows (see data_inlining_row_limit) out of the catalog into parquet files
410        - rewrites files where most rows were deleted (e.g. by 'replace' writes)
411        - merges small files into larger ones
412        """
413        cursor = self._connect()
414        try:
415            for function in (
416                "ducklake_flush_inlined_data",
417                "ducklake_rewrite_data_files",
418                "ducklake_merge_adjacent_files",
419            ):
420                self._with_retries(
421                    lambda function=function: cursor.execute(
422                        f"CALL {function}(?)", [self._CATALOG_ALIAS]
423                    ),
424                    function,
425                )
426        finally:
427            cursor.close()
428
429    def vacuum(
430        self,
431        *,
432        retention: datetime.timedelta = TableIO.DEFAULT_VACUUM_RETENTION,
433        dry_run: bool = True,
434    ) -> VacuumResult:
435        """See TableIO.vacuum().
436
437        - expires snapshots older than `retention` (the latest snapshot is never expired)
438        - deletes files that expired snapshots no longer use, `retention` after they were expired,
439            so a file is only deleted by a later vacuum than the one expiring its snapshot
440        - deletes files no snapshot ever used (e.g. left by a crashed write), once older than `retention`
441        """
442        self._check_retention(retention)
443        older_than = datetime.datetime.now(datetime.UTC) - retention
444        params = [self._CATALOG_ALIAS, older_than, dry_run]
445        cursor = self._connect()
446        try:
447            snapshot_ids = self._with_retries(
448                lambda: cursor.execute(
449                    "SELECT snapshot_id FROM ducklake_expire_snapshots(?, older_than => ?, dry_run => ?)",
450                    params,
451                ).fetchall(),
452                "ducklake_expire_snapshots",
453            )
454            paths = []
455            for function in (
456                "ducklake_cleanup_old_files",
457                "ducklake_delete_orphaned_files",
458            ):
459                paths += self._with_retries(
460                    lambda function=function: cursor.execute(
461                        f"SELECT path FROM {function}(?, older_than => ?, dry_run => ?)",
462                        params,
463                    ).fetchall(),
464                    function,
465                )
466        finally:
467            cursor.close()
468        return VacuumResult(
469            expired_snapshots=[str(snapshot_id) for (snapshot_id,) in snapshot_ids],
470            deleted_paths=[path for (path,) in paths],
471        )

Stores datasets as DuckLake tables, with a SQLite catalog; metadata in a per-dataset metadata table.

See TableIO for what all table-format IOs share. Layout: /pfeed.db (catalog) + /data/ (parquet files written by DuckLake)

  • DatasetKey -> table ""."", see TableIO.
  • metadata: table "
  • __metadata" in the same schema, one row per partition: the partition_by columns (a DatePartition's as a date) + METADATA_COLUMN (JSON string). Written in the same transaction as the data, so the commit marker comes for free.
  • physical layout: the table is partitioned by the column levels of partition_by only (e.g. product), so a file never mixes their values, whatever the write pattern or compaction. A DatePartition level is logical only (the unit of replace, metadata and existence), matched by a date range; partitioning by it would keep every day in files of its own, so optimize() could never merge e.g. daily bars. Reads still skip other days' files by their min/max stats of the column.
  • data files are flat under /data//
  • /, without hive dirs per partition value, since DuckLake finds a file's partition values in the catalog, not in its path.
  • replace/append are one transaction each: replace: DELETE + INSERT data, DELETE + INSERT metadata; append: INSERT data, INSERT metadata of new partitions only. Concurrent writes to the same dataset conflict even on disjoint partitions (DuckLake detects conflicts per table), so a conflicting transaction is retried as a whole.
  • DuckLakeIO( base_path: str | None = None, data_inlining_row_limit: int | None = None)
    65    def __init__(
    66        self, base_path: str | None = None, data_inlining_row_limit: int | None = None
    67    ):
    68        """
    69        Args:
    70            base_path: root directory of the catalog and data files. Local paths only for now.
    71                Defaults to <config.data_path>/<DEFAULT_DIR_NAME>.
    72            data_inlining_row_limit: inserts of up to this many rows are stored inside the catalog
    73                instead of as parquet files, so small writes (e.g. live appends) don't create many tiny files.
    74                0 disables inlining; None uses DuckLake's default.
    75        """
    76        if data_inlining_row_limit is not None and (
    77            not isinstance(data_inlining_row_limit, int) or data_inlining_row_limit < 0
    78        ):
    79            raise ValueError(
    80                f"data_inlining_row_limit must be an int >= 0, got {data_inlining_row_limit!r}"
    81            )
    82        super().__init__(base_path)
    83        self._data_inlining_row_limit = data_inlining_row_limit
    84        self._conn: duckdb.DuckDBPyConnection | None = None
    85        self._conn_pid: int | None = None
    86        # creates the catalog now, since processes creating it at the same time fail with "database is locked"
    87        self._connect().close()
    88        # WAL keeps catalog reads from waiting on a concurrent write (in 1s busy waits),
    89        # which made concurrent writes ~5x slower and sometimes fail with "database is locked"
    90        with contextlib.closing(
    91            sqlite3.connect(os.path.join(self._base_path, self.CATALOG_FILE_NAME))
    92        ) as conn:
    93            conn.execute("PRAGMA journal_mode=WAL")
    94        self._set_catalog_options()
    
    Arguments:
    • base_path: root directory of the catalog and data files. Local paths only for now. Defaults to /.
    • data_inlining_row_limit: inserts of up to this many rows are stored inside the catalog instead of as parquet files, so small writes (e.g. live appends) don't create many tiny files. 0 disables inlining; None uses DuckLake's default.
    DEFAULT_DIR_NAME: ClassVar[str] = 'ducklake'
    CATALOG_FILE_NAME: ClassVar[str] = 'pfeed.db'
    DATA_DIR_NAME: ClassVar[str] = 'data'
    METADATA_TABLE_SUFFIX: ClassVar[str] = '__metadata'
    CATALOG_OPTIONS: ClassVar[dict[str, str | bool]] = {'parquet_compression': 'zstd', 'hive_file_pattern': False}
    def optimize(self) -> None:
    406    def optimize(self) -> None:
    407        """See TableIO.optimize().
    408
    409        - moves inlined rows (see data_inlining_row_limit) out of the catalog into parquet files
    410        - rewrites files where most rows were deleted (e.g. by 'replace' writes)
    411        - merges small files into larger ones
    412        """
    413        cursor = self._connect()
    414        try:
    415            for function in (
    416                "ducklake_flush_inlined_data",
    417                "ducklake_rewrite_data_files",
    418                "ducklake_merge_adjacent_files",
    419            ):
    420                self._with_retries(
    421                    lambda function=function: cursor.execute(
    422                        f"CALL {function}(?)", [self._CATALOG_ALIAS]
    423                    ),
    424                    function,
    425                )
    426        finally:
    427            cursor.close()
    

    See TableIO.optimize().

    • moves inlined rows (see data_inlining_row_limit) out of the catalog into parquet files
    • rewrites files where most rows were deleted (e.g. by 'replace' writes)
    • merges small files into larger ones
    def vacuum( self, *, retention: datetime.timedelta = datetime.timedelta(days=7), dry_run: bool = True) -> pfeed.io.table_io.VacuumResult:
    429    def vacuum(
    430        self,
    431        *,
    432        retention: datetime.timedelta = TableIO.DEFAULT_VACUUM_RETENTION,
    433        dry_run: bool = True,
    434    ) -> VacuumResult:
    435        """See TableIO.vacuum().
    436
    437        - expires snapshots older than `retention` (the latest snapshot is never expired)
    438        - deletes files that expired snapshots no longer use, `retention` after they were expired,
    439            so a file is only deleted by a later vacuum than the one expiring its snapshot
    440        - deletes files no snapshot ever used (e.g. left by a crashed write), once older than `retention`
    441        """
    442        self._check_retention(retention)
    443        older_than = datetime.datetime.now(datetime.UTC) - retention
    444        params = [self._CATALOG_ALIAS, older_than, dry_run]
    445        cursor = self._connect()
    446        try:
    447            snapshot_ids = self._with_retries(
    448                lambda: cursor.execute(
    449                    "SELECT snapshot_id FROM ducklake_expire_snapshots(?, older_than => ?, dry_run => ?)",
    450                    params,
    451                ).fetchall(),
    452                "ducklake_expire_snapshots",
    453            )
    454            paths = []
    455            for function in (
    456                "ducklake_cleanup_old_files",
    457                "ducklake_delete_orphaned_files",
    458            ):
    459                paths += self._with_retries(
    460                    lambda function=function: cursor.execute(
    461                        f"SELECT path FROM {function}(?, older_than => ?, dry_run => ?)",
    462                        params,
    463                    ).fetchall(),
    464                    function,
    465                )
    466        finally:
    467            cursor.close()
    468        return VacuumResult(
    469            expired_snapshots=[str(snapshot_id) for (snapshot_id,) in snapshot_ids],
    470            deleted_paths=[path for (path,) in paths],
    471        )
    

    See TableIO.vacuum().

    • expires snapshots older than retention (the latest snapshot is never expired)
    • deletes files that expired snapshots no longer use, retention after they were expired, so a file is only deleted by a later vacuum than the one expiring its snapshot
    • deletes files no snapshot ever used (e.g. left by a crashed write), once older than retention
    class ParquetIO(pfeed.io.base_io.BaseIO):
     34class ParquetIO(BaseIO):
     35    """Stores each partition as one parquet file in a hive layout, metadata in the file's footer.
     36
     37    Layout: <base_path>/<namespace k=v>/.../<name k=v>/.../<partition_by k=v>/.../part-0.parquet
     38    e.g. ./data/env=BACKTEST/.../resolution=1t/product=BTC_USDT_PERP/year=2025/month=01/day=01/part-0.parquet
     39
     40    - a column level is one dir `col=value`; a DatePartition level is three dirs `year=YYYY/month=MM/day=DD`
     41    - partition columns are kept inside the file too, so their types survive the round-trip;
     42        a DatePartition's date is only in the dirs, it is computed from its column, not stored
     43    - an empty partition is a 0-row file carrying its metadata
     44    - replace is atomic per partition: write to a temp file, then rename over part-0.parquet;
     45        a multi-partition write is not atomic as a whole, its partitions are committed one by one
     46    - every file has all the dataset's columns (missing ones as nulls), so a replace never drops a column
     47    - a read is not a snapshot: its metadata is read right away, but its LazyFrame reads the files when collected,
     48        so a write in between can make the data newer than the metadata; use DuckLakeIO for concurrent read/write
     49    - no append: a parquet file is immutable, appending means multi-file state, use DeltaLakeIO/DuckLakeIO
     50    """
     51
     52    CAPABILITIES: ClassVar[IOCapabilities] = IOCapabilities(
     53        append=False, concurrent_partition_writes=True
     54    )
     55    DEFAULT_DIR_NAME: ClassVar[str] = "parquet"
     56    FILE_NAME: ClassVar[str] = "part-0.parquet"
     57    METADATA_KEY: ClassVar[bytes] = b"pfeed_metadata"
     58    # args set by ParquetIO itself, not overridable via write_options/read_options
     59    _RESERVED_WRITE_OPTIONS: ClassVar[set[str]] = {
     60        "table",
     61        "where",
     62        "filesystem",
     63        "compression",
     64    }
     65    _RESERVED_READ_OPTIONS: ClassVar[set[str]] = {
     66        "source",
     67        "hive_partitioning",
     68        "schema",
     69        "missing_columns",
     70    }
     71
     72    def __init__(
     73        self,
     74        base_path: str | None = None,
     75        compression: str = "zstd",
     76        write_options: dict[str, Any] | None = None,
     77        read_options: dict[str, Any] | None = None,
     78    ):
     79        """
     80        Args:
     81            base_path: root directory of all datasets. Local paths only for now.
     82                Defaults to <config.data_path>/<DEFAULT_DIR_NAME>.
     83            compression: parquet compression codec.
     84            write_options: extra kwargs passed to `pyarrow.parquet.write_table`.
     85            read_options: extra kwargs passed to `polars.scan_parquet`.
     86        """
     87        if base_path is None:
     88            from pfeed.config import get_config
     89
     90            base_path = str(get_config().data_path / self.DEFAULT_DIR_NAME)
     91        scheme = urlparse(base_path).scheme
     92        # a one-letter scheme is a Windows drive letter (C:\...), not a URI scheme
     93        if scheme not in ("", "file") and len(scheme) > 1:
     94            raise NotImplementedError(
     95                f"only local paths are supported for now, got {base_path!r}"
     96            )
     97        self._filesystem, self._base_path = pafs.FileSystem.from_uri(
     98            os.path.abspath(base_path.removeprefix("file://"))
     99        )
    100        self._compression = compression
    101        self._write_options = write_options or {}
    102        self._read_options = read_options or {}
    103        if reserved := self._RESERVED_WRITE_OPTIONS & self._write_options.keys():
    104            raise ValueError(
    105                f"write_options cannot set {reserved}, they are controlled by ParquetIO"
    106            )
    107        if reserved := self._RESERVED_READ_OPTIONS & self._read_options.keys():
    108            raise ValueError(
    109                f"read_options cannot set {reserved}, they are controlled by ParquetIO"
    110            )
    111
    112    @staticmethod
    113    def _to_hive_dir(column: str, value: PartitionValue) -> str:
    114        if isinstance(value, datetime.datetime) or not isinstance(
    115            value, (str, int, datetime.date)
    116        ):
    117            raise TypeError(f"unsupported partition value {value!r} for {column!r}")
    118        value = value.isoformat() if isinstance(value, datetime.date) else str(value)
    119        if not value or "/" in value:
    120            raise ValueError(f"invalid partition value {value!r} for {column!r}")
    121        return f"{column}={value}"
    122
    123    @staticmethod
    124    def _from_hive_value(value: str, dtype: pa.DataType) -> PartitionValue:
    125        if pa.types.is_date(dtype):
    126            return datetime.date.fromisoformat(value)
    127        if pa.types.is_integer(dtype):
    128            return int(value)
    129        if (
    130            pa.types.is_string(dtype)
    131            or pa.types.is_large_string(dtype)
    132            or pa.types.is_string_view(dtype)
    133        ):
    134            return value
    135        raise TypeError(f"unsupported partition column type {dtype}")
    136
    137    @staticmethod
    138    def _level_dir_names(level: PartitionLevel) -> list[str]:
    139        return ["year", "month", "day"] if isinstance(level, DatePartition) else [level]
    140
    141    @classmethod
    142    def _partition_dir_names(cls, key: DatasetKey) -> list[str]:
    143        """The hive dir names of the partition levels, raising ValueError if two levels share a dir name."""
    144        dir_names = [
    145            name for level in key.partition_by for name in cls._level_dir_names(level)
    146        ]
    147        if len(set(dir_names)) != len(dir_names):
    148            raise ValueError(
    149                f"partition_by {key.partition_by} has duplicate dir names {dir_names}"
    150            )
    151        return dir_names
    152
    153    @classmethod
    154    def _to_partition_dirs(
    155        cls, level: PartitionLevel, value: PartitionValue
    156    ) -> list[str]:
    157        if isinstance(level, DatePartition):
    158            if isinstance(value, datetime.datetime) or not isinstance(
    159                value, datetime.date
    160            ):
    161                raise TypeError(
    162                    f"{level} partition value must be a date, got {value!r}"
    163                )
    164            return [
    165                f"year={value.year:04d}",
    166                f"month={value.month:02d}",
    167                f"day={value.day:02d}",
    168            ]
    169        return [cls._to_hive_dir(level, value)]
    170
    171    def _dataset_dir(self, key: DatasetKey) -> str:
    172        dataset_dirs = [
    173            self._to_hive_dir(col, value)
    174            for col, value in (key.namespace | key.name).items()
    175        ]
    176        return posixpath.join(self._base_path, *dataset_dirs)
    177
    178    def _file_path(self, key: DatasetKey, partition: Partition) -> str:
    179        dataset_dir = self._dataset_dir(key)
    180        partition_dirs = [
    181            partition_dir
    182            for level, value in zip(key.partition_by, partition, strict=True)
    183            for partition_dir in self._to_partition_dirs(level, value)
    184        ]
    185        return posixpath.join(dataset_dir, *partition_dirs, self.FILE_NAME)
    186
    187    def _find_partitions(
    188        self, key: DatasetKey
    189    ) -> dict[Partition, tuple[str, FileMetaData]]:
    190        """Finds all committed partition files of the dataset, parsing partition values back from the hive dirs.
    191
    192        Returns the footer (FileMetaData) along with the file path, so each file is opened only once.
    193        A file without metadata isn't committed (see commit marker), so its partition is treated as missing.
    194        """
    195        dataset_dir = self._dataset_dir(key)
    196        dir_names = self._partition_dir_names(key)
    197        selector = pafs.FileSelector(dataset_dir, allow_not_found=True, recursive=True)
    198        file_paths = [
    199            info.path
    200            for info in self._filesystem.get_file_info(selector)
    201            if info.type == pafs.FileType.File and info.base_name == self.FILE_NAME
    202        ]
    203        partitions: dict[Partition, tuple[str, FileMetaData]] = {}
    204        for file_path in file_paths:
    205            hive_dirs = file_path.removeprefix(dataset_dir + "/").split("/")[:-1]
    206            if [d.split("=", 1)[0] for d in hive_dirs] != dir_names:
    207                continue  # not a partition of this dataset, e.g. a nested dataset sharing the prefix
    208            file_metadata = pq.read_metadata(file_path, filesystem=self._filesystem)
    209            if (
    210                not file_metadata.metadata
    211                or self.METADATA_KEY not in file_metadata.metadata
    212            ):
    213                continue
    214            schema = file_metadata.schema.to_arrow_schema()
    215            values = iter(d.split("=", 1)[1] for d in hive_dirs)
    216            partition = tuple(
    217                datetime.date(int(next(values)), int(next(values)), int(next(values)))
    218                if isinstance(level, DatePartition)
    219                else self._from_hive_value(next(values), schema.field(level).type)
    220                for level in key.partition_by
    221            )
    222            partitions[partition] = (file_path, file_metadata)
    223        return partitions
    224
    225    @staticmethod
    226    def _dataset_schema(schemas: list[pa.Schema]) -> pa.Schema:
    227        """Unions the columns of all partition files, since each file only has the columns it was written with.
    228
    229        Raises TypeError if a column has different types across schemas.
    230        """
    231        try:
    232            return pa.unify_schemas(schemas)
    233        except (pa.ArrowInvalid, pa.ArrowTypeError) as e:
    234            raise TypeError(
    235                f"a column has different types across partitions: {e}"
    236            ) from e
    237
    238    def write(
    239        self,
    240        key: DatasetKey,
    241        data: pa.Table,
    242        *,
    243        partitions: dict[Partition, Metadata],
    244        mode: Literal["replace", "append"] = "replace",
    245    ) -> None:
    246        if mode == "append":
    247            raise NotImplementedError(
    248                f"{type(self).__name__} does not support append, use DeltaLakeIO or DuckLakeIO"
    249            )
    250        data_partitions = self._data_partitions(key, data, partitions)
    251        partition_value_arrays = self._partition_value_arrays(key, data)
    252        # build all paths and serialize all metadata before writing any file,
    253        # so an invalid partition value or metadata writes nothing
    254        file_paths = {
    255            partition: self._file_path(key, partition) for partition in partitions
    256        }
    257        dumped_metadata = {
    258            partition: self._dump_metadata(md) for partition, md in partitions.items()
    259        }
    260        # schema drift: new/missing columns are fine, a column changing type isn't
    261        existing_files = self._find_partitions(key)
    262        dataset_schema = self._dataset_schema(
    263            [fm.schema.to_arrow_schema() for _, fm in existing_files.values()]
    264            + [data.schema]
    265        )
    266        # every written file carries all the dataset's columns (missing ones as nulls),
    267        # so replacing the last file that had a column doesn't drop the column from the dataset
    268        dataset_schema = pa.schema(
    269            [field.with_nullable(True) for field in dataset_schema]
    270        )
    271        data = pa.table(
    272            {
    273                field.name: data[field.name]
    274                if field.name in data.column_names
    275                else pa.nulls(len(data), field.type)
    276                for field in dataset_schema
    277            }
    278        ).cast(dataset_schema.with_metadata(data.schema.metadata))
    279
    280        for partition, partition_metadata in dumped_metadata.items():
    281            file_path = file_paths[partition]
    282            if partition in data_partitions and key.partition_by:
    283                # pyarrow.compute functions are generated at runtime, so ty can't see them
    284                mask = reduce(
    285                    pc.and_,  # ty: ignore[missing-attribute]
    286                    [
    287                        pc.equal(array, pa.scalar(value, type=array.type))  # ty: ignore[missing-attribute]
    288                        for array, value in zip(
    289                            partition_value_arrays, partition, strict=True
    290                        )
    291                    ],
    292                )
    293                table = data.filter(mask)
    294            elif partition in data_partitions:
    295                table = data
    296            else:
    297                table = data.schema.empty_table()
    298            table = table.replace_schema_metadata(
    299                {
    300                    **(table.schema.metadata or {}),
    301                    self.METADATA_KEY: partition_metadata,
    302                }
    303            )
    304            # write to a temp file first so readers never see a half-written partition
    305            partition_dir = file_path.rsplit("/", 1)[0]
    306            temp_file_path = f"{partition_dir}/.{self.FILE_NAME}.{uuid.uuid4().hex}.tmp"
    307            self._filesystem.create_dir(partition_dir, recursive=True)
    308            pq.write_table(
    309                table,
    310                temp_file_path,
    311                filesystem=self._filesystem,
    312                compression=self._compression,
    313                **self._write_options,
    314            )
    315            self._filesystem.move(temp_file_path, file_path)
    316
    317    def read(
    318        self,
    319        key: DatasetKey,
    320        *,
    321        partitions: list[Partition] | None = None,
    322    ) -> tuple[pl.LazyFrame | None, dict[Partition, Metadata]]:
    323        # all files are needed even for a subset, since the dataset's columns are the union of all files (schema drift)
    324        all_files = self._find_partitions(key)
    325        if partitions is None:
    326            files = all_files
    327        else:
    328            files = {
    329                partition: all_files[partition]
    330                for partition in partitions
    331                if partition in all_files
    332            }
    333
    334        metadata: dict[Partition, Metadata] = {}
    335        non_empty_file_paths: list[str] = []
    336        for partition, (file_path, file_metadata) in files.items():
    337            metadata[partition] = json.loads(file_metadata.metadata[self.METADATA_KEY])
    338            if file_metadata.num_rows:
    339                non_empty_file_paths.append(file_path)
    340
    341        data = None
    342        if non_empty_file_paths:
    343            schema = self._dataset_schema(
    344                [fm.schema.to_arrow_schema() for _, fm in all_files.values()]
    345            )
    346            data = pl.scan_parquet(
    347                non_empty_file_paths,
    348                hive_partitioning=False,
    349                schema=pl.DataFrame(schema.empty_table()).schema,
    350                missing_columns="insert",  # null for columns a file doesn't have
    351                **self._read_options,
    352            )
    353        return data, metadata
    

    Stores each partition as one parquet file in a hive layout, metadata in the file's footer.

    Layout: //...//...//.../part-0.parquet e.g. ./data/env=BACKTEST/.../resolution=1t/product=BTC_USDT_PERP/year=2025/month=01/day=01/part-0.parquet

    • a column level is one dir col=value; a DatePartition level is three dirs year=YYYY/month=MM/day=DD
    • partition columns are kept inside the file too, so their types survive the round-trip; a DatePartition's date is only in the dirs, it is computed from its column, not stored
    • an empty partition is a 0-row file carrying its metadata
    • replace is atomic per partition: write to a temp file, then rename over part-0.parquet; a multi-partition write is not atomic as a whole, its partitions are committed one by one
    • every file has all the dataset's columns (missing ones as nulls), so a replace never drops a column
    • a read is not a snapshot: its metadata is read right away, but its LazyFrame reads the files when collected, so a write in between can make the data newer than the metadata; use DuckLakeIO for concurrent read/write
    • no append: a parquet file is immutable, appending means multi-file state, use DeltaLakeIO/DuckLakeIO
    ParquetIO( base_path: str | None = None, compression: str = 'zstd', write_options: dict[str, Any] | None = None, read_options: dict[str, Any] | None = None)
     72    def __init__(
     73        self,
     74        base_path: str | None = None,
     75        compression: str = "zstd",
     76        write_options: dict[str, Any] | None = None,
     77        read_options: dict[str, Any] | None = None,
     78    ):
     79        """
     80        Args:
     81            base_path: root directory of all datasets. Local paths only for now.
     82                Defaults to <config.data_path>/<DEFAULT_DIR_NAME>.
     83            compression: parquet compression codec.
     84            write_options: extra kwargs passed to `pyarrow.parquet.write_table`.
     85            read_options: extra kwargs passed to `polars.scan_parquet`.
     86        """
     87        if base_path is None:
     88            from pfeed.config import get_config
     89
     90            base_path = str(get_config().data_path / self.DEFAULT_DIR_NAME)
     91        scheme = urlparse(base_path).scheme
     92        # a one-letter scheme is a Windows drive letter (C:\...), not a URI scheme
     93        if scheme not in ("", "file") and len(scheme) > 1:
     94            raise NotImplementedError(
     95                f"only local paths are supported for now, got {base_path!r}"
     96            )
     97        self._filesystem, self._base_path = pafs.FileSystem.from_uri(
     98            os.path.abspath(base_path.removeprefix("file://"))
     99        )
    100        self._compression = compression
    101        self._write_options = write_options or {}
    102        self._read_options = read_options or {}
    103        if reserved := self._RESERVED_WRITE_OPTIONS & self._write_options.keys():
    104            raise ValueError(
    105                f"write_options cannot set {reserved}, they are controlled by ParquetIO"
    106            )
    107        if reserved := self._RESERVED_READ_OPTIONS & self._read_options.keys():
    108            raise ValueError(
    109                f"read_options cannot set {reserved}, they are controlled by ParquetIO"
    110            )
    
    Arguments:
    • base_path: root directory of all datasets. Local paths only for now. Defaults to /.
    • compression: parquet compression codec.
    • write_options: extra kwargs passed to pyarrow.parquet.write_table.
    • read_options: extra kwargs passed to polars.scan_parquet.
    CAPABILITIES: ClassVar[pfeed.io.base_io.IOCapabilities] = IOCapabilities(append=False, concurrent_partition_writes=True)
    DEFAULT_DIR_NAME: ClassVar[str] = 'parquet'
    FILE_NAME: ClassVar[str] = 'part-0.parquet'
    METADATA_KEY: ClassVar[bytes] = b'pfeed_metadata'
    def write( self, key: pfeed.io.base_io.DatasetKey, data: pyarrow.lib.Table, *, partitions: dict[Partition, Metadata], mode: Literal['replace', 'append'] = 'replace') -> None:
    238    def write(
    239        self,
    240        key: DatasetKey,
    241        data: pa.Table,
    242        *,
    243        partitions: dict[Partition, Metadata],
    244        mode: Literal["replace", "append"] = "replace",
    245    ) -> None:
    246        if mode == "append":
    247            raise NotImplementedError(
    248                f"{type(self).__name__} does not support append, use DeltaLakeIO or DuckLakeIO"
    249            )
    250        data_partitions = self._data_partitions(key, data, partitions)
    251        partition_value_arrays = self._partition_value_arrays(key, data)
    252        # build all paths and serialize all metadata before writing any file,
    253        # so an invalid partition value or metadata writes nothing
    254        file_paths = {
    255            partition: self._file_path(key, partition) for partition in partitions
    256        }
    257        dumped_metadata = {
    258            partition: self._dump_metadata(md) for partition, md in partitions.items()
    259        }
    260        # schema drift: new/missing columns are fine, a column changing type isn't
    261        existing_files = self._find_partitions(key)
    262        dataset_schema = self._dataset_schema(
    263            [fm.schema.to_arrow_schema() for _, fm in existing_files.values()]
    264            + [data.schema]
    265        )
    266        # every written file carries all the dataset's columns (missing ones as nulls),
    267        # so replacing the last file that had a column doesn't drop the column from the dataset
    268        dataset_schema = pa.schema(
    269            [field.with_nullable(True) for field in dataset_schema]
    270        )
    271        data = pa.table(
    272            {
    273                field.name: data[field.name]
    274                if field.name in data.column_names
    275                else pa.nulls(len(data), field.type)
    276                for field in dataset_schema
    277            }
    278        ).cast(dataset_schema.with_metadata(data.schema.metadata))
    279
    280        for partition, partition_metadata in dumped_metadata.items():
    281            file_path = file_paths[partition]
    282            if partition in data_partitions and key.partition_by:
    283                # pyarrow.compute functions are generated at runtime, so ty can't see them
    284                mask = reduce(
    285                    pc.and_,  # ty: ignore[missing-attribute]
    286                    [
    287                        pc.equal(array, pa.scalar(value, type=array.type))  # ty: ignore[missing-attribute]
    288                        for array, value in zip(
    289                            partition_value_arrays, partition, strict=True
    290                        )
    291                    ],
    292                )
    293                table = data.filter(mask)
    294            elif partition in data_partitions:
    295                table = data
    296            else:
    297                table = data.schema.empty_table()
    298            table = table.replace_schema_metadata(
    299                {
    300                    **(table.schema.metadata or {}),
    301                    self.METADATA_KEY: partition_metadata,
    302                }
    303            )
    304            # write to a temp file first so readers never see a half-written partition
    305            partition_dir = file_path.rsplit("/", 1)[0]
    306            temp_file_path = f"{partition_dir}/.{self.FILE_NAME}.{uuid.uuid4().hex}.tmp"
    307            self._filesystem.create_dir(partition_dir, recursive=True)
    308            pq.write_table(
    309                table,
    310                temp_file_path,
    311                filesystem=self._filesystem,
    312                compression=self._compression,
    313                **self._write_options,
    314            )
    315            self._filesystem.move(temp_file_path, file_path)
    

    Write data and its per-partition metadata to the dataset.

    Arguments:
    • key: dataset to write to.
    • data: must contain all key.partition_columns. Its columns may differ from the dataset's existing columns (schema drift):
      • a new column is added to the dataset; existing rows read it as null
      • a missing column is null in this write's rows; the dataset never loses a column
      • a column whose type differs from the dataset's raises TypeError and writes nothing
    • partitions: the partitions this write covers, each with its metadata. Every partition in data must be in it. A partition in it but with no rows in data is a valid empty partition (e.g. a date that was fetched but had no trades).
    • mode: - 'replace': overwrite partitions (dynamic partition overwrite); an empty partition's existing rows are deleted, its existing metadata is replaced, not merged. Other partitions are untouched.
      • 'append': add rows to partitions. A partition that doesn't exist yet is created with its metadata, an existing one keeps its metadata, so e.g. a live stream appending every few seconds doesn't rewrite the same metadata on every write. Only if CAPABILITIES.append, otherwise raises NotImplementedError.
    def read( self, key: pfeed.io.base_io.DatasetKey, *, partitions: list[Partition] | None = None) -> tuple[polars.lazyframe.frame.LazyFrame | None, dict[Partition, Metadata]]:
    317    def read(
    318        self,
    319        key: DatasetKey,
    320        *,
    321        partitions: list[Partition] | None = None,
    322    ) -> tuple[pl.LazyFrame | None, dict[Partition, Metadata]]:
    323        # all files are needed even for a subset, since the dataset's columns are the union of all files (schema drift)
    324        all_files = self._find_partitions(key)
    325        if partitions is None:
    326            files = all_files
    327        else:
    328            files = {
    329                partition: all_files[partition]
    330                for partition in partitions
    331                if partition in all_files
    332            }
    333
    334        metadata: dict[Partition, Metadata] = {}
    335        non_empty_file_paths: list[str] = []
    336        for partition, (file_path, file_metadata) in files.items():
    337            metadata[partition] = json.loads(file_metadata.metadata[self.METADATA_KEY])
    338            if file_metadata.num_rows:
    339                non_empty_file_paths.append(file_path)
    340
    341        data = None
    342        if non_empty_file_paths:
    343            schema = self._dataset_schema(
    344                [fm.schema.to_arrow_schema() for _, fm in all_files.values()]
    345            )
    346            data = pl.scan_parquet(
    347                non_empty_file_paths,
    348                hive_partitioning=False,
    349                schema=pl.DataFrame(schema.empty_table()).schema,
    350                missing_columns="insert",  # null for columns a file doesn't have
    351                **self._read_options,
    352            )
    353        return data, metadata
    

    Read the dataset and its per-partition metadata.

    Metadata has to be read anyway to find the existing partitions (see commit marker), and the LazyFrame loads nothing until collected, so metadata-only callers can just ignore the frame, e.g. _, metadata = io.read(key, partitions=partitions).

    Arguments:
    • key: dataset to read.
    • partitions: partitions to read; None reads the whole dataset.
    Returns:

    (data, metadata):

    • data: LazyFrame including the key.partition_columns, only from partitions that have metadata; None if the dataset does not exist or none of the partitions exist or all of them are empty. Has every column ever written to the dataset, null where a partition doesn't have it.
    • metadata: metadata of the existing partitions; {} if none. Requested partitions not in it are missing.