pfeed-bybit 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: pfeed-bybit
3
+ Version: 0.0.1
4
+ Summary: Bybit data source plugin for pfeed: historical and live market data from Bybit.
5
+ Author: softwareentrepreneer
6
+ Author-email: softwareentrepreneer <softwareentrepreneer+pfeed-bybit@gmail.com>
7
+ Requires-Dist: pfeed>=0.0.21
8
+ Requires-Dist: pfund-bybit>=0.0.1
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pfeed-bybit
13
+ Bybit data source plugin for pfeed: historical and live market data from Bybit.
@@ -0,0 +1,2 @@
1
+ # pfeed-bybit
2
+ Bybit data source plugin for pfeed: historical and live market data from Bybit.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "pfeed-bybit"
3
+ version = "0.0.1"
4
+ description = "Bybit data source plugin for pfeed: historical and live market data from Bybit."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "pfeed>=0.0.21",
9
+ "pfund-bybit>=0.0.1",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "softwareentrepreneer"
14
+ email = "softwareentrepreneer+pfeed-bybit@gmail.com"
15
+
16
+ [project.entry-points."pfeed.sources"]
17
+ bybit = "pfeed_bybit:Bybit"
18
+
19
+ [tool.uv]
20
+ exclude-newer = "0 days"
21
+
22
+ [tool.uv.exclude-newer-package]
23
+ uv_build = false
24
+ pfund-blocks = false
25
+ pfund-kit = false
26
+ pfeed = false
27
+ pfund-bybit = false
28
+
29
+ [tool.pytest]
30
+ markers = [
31
+ "network: Run tests that call Bybit's real servers",
32
+ "rate_limit: Run tests that can be rate-limited",
33
+ ]
34
+ strict_markers = true
35
+ addopts = ["--import-mode=importlib"]
36
+ testpaths = ["tests"]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.13.7,<0.14.0"]
40
+ build-backend = "uv_build"
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "pfeed-bybit"
3
+ version = "0.0.1"
4
+ description = "Bybit data source plugin for pfeed: historical and live market data from Bybit."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "softwareentrepreneer", email = "softwareentrepreneer+pfeed-bybit@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "pfeed>=0.0.21",
12
+ "pfund-bybit>=0.0.1"
13
+ ]
14
+
15
+ [project.entry-points."pfeed.sources"]
16
+ bybit = "pfeed_bybit:Bybit"
17
+
18
+ [tool.uv]
19
+ # FIXME: set it back to "14 days" when the package is stable
20
+ exclude-newer = "0 days"
21
+ exclude-newer-package = { uv_build = false, pfund-blocks = false, pfund-kit = false, pfeed = false, pfund-bybit = false }
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.13.7,<0.14.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.pytest]
28
+ markers = [
29
+ "network: Run tests that call Bybit's real servers",
30
+ "rate_limit: Run tests that can be rate-limited",
31
+ ]
32
+ strict_markers = true
33
+ addopts = ["--import-mode=importlib"]
34
+ testpaths = ["tests"]
@@ -0,0 +1,5 @@
1
+ from pfeed_bybit.source import Bybit
2
+
3
+ __all__ = [
4
+ "Bybit",
5
+ ]
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar
4
+
5
+ if TYPE_CHECKING:
6
+ from httpx2 import Response
7
+ from pfund_blocks.entities.products.product_base import BaseProduct
8
+ from pfund_blocks.resolution import Resolution
9
+
10
+ import datetime
11
+
12
+ import polars as pl
13
+
14
+ # NOTE: do NOT trust the catalog in https://public.bybit.com/trading/, BTC_USDT_FUTs are missing,
15
+ # e.g. BTCUSDT-22AUG25 is missing in the catalog but its url is valid: https://public.bybit.com/trading/BTCUSDT-22AUG25/
16
+ # the same applies to https://quote-saver.bycsi.com/orderbook/, 'spot' is missing in the catalog but its url is valid: https://quote-saver.bycsi.com/orderbook/spot
17
+ """
18
+ Bybit's naming conventions for different contracts (non-spot, no options data):
19
+ BTCUSDT = BTC_USDT_PERP
20
+ BTCUSDT-22AUG25 = BTC_USDT_FUT
21
+ BTCPERP = BTC_USDC_PERP
22
+ # BTC-30AUG24 = BTC_USDC_FUT (DEPRECATED, no USDC futures anymore)
23
+ BTCUSD = BTC_USD_IPERP
24
+ BTCUSDH25 = BTC_USD_IFUT
25
+ """
26
+
27
+
28
+ # url: e.g. https://quote-saver.bycsi.com/orderbook/linear/BTCUSDT/2025-01-01_BTCUSDT_ob500.data.zip
29
+ # NOTE: productId=orderbook/trade, bizType=contract/spot
30
+ # See downloadable symbols:
31
+ # https://www.bybit.com/x-api/quote/public/support/download/list-options?bizType={contract_or_spot}&productId={orderbook_or_trade}
32
+ class BatchAPI:
33
+ """Custom API for downloading data from Bybit"""
34
+
35
+ DATA_NAMING_REGEX_PATTERNS: ClassVar[dict[str, str]] = {
36
+ "PERPETUAL": r"(USDT\/|PERP\/)$", # USDT perp or USDC perp;
37
+ "FUTURE": r"-\d{2}[A-Z]{3}\d{2}\/$", # USDC futures e.g. BTC-10NOV23/
38
+ "INVERSE-PERPETUAL": r"USD\/$", # inverse perps;
39
+ "INVERSE-FUTURE": r"USD[A-Z]\d{2}\/$", # inverse futures e.g. BTCUSDH24/
40
+ "CRYPTO": ".*", # match everything since everything from https://public.bybit.com/spot is spot
41
+ }
42
+
43
+ @staticmethod
44
+ def _get_base_url(product: BaseProduct, resolution: Resolution) -> str:
45
+ if resolution.is_quote():
46
+ if product.is_crypto_spot():
47
+ return "https://quote-saver.bycsi.com/orderbook/spot"
48
+ elif product.is_inverse():
49
+ return "https://quote-saver.bycsi.com/orderbook/inverse"
50
+ else:
51
+ return "https://quote-saver.bycsi.com/orderbook/linear"
52
+ else:
53
+ if product.is_crypto_spot():
54
+ return "https://public.bybit.com/spot"
55
+ else:
56
+ return "https://public.bybit.com/trading"
57
+
58
+ @staticmethod
59
+ def _create_filename(
60
+ product: BaseProduct, resolution: Resolution, date: str | datetime.date
61
+ ) -> str:
62
+ if resolution.is_quote():
63
+ from pfund_kit.utils.temporal import convert_to_date
64
+
65
+ orderbook_levels = 200
66
+ # NOTE: somehow after this date, the orderbook (non-spot) levels are changed from 500 to 200
67
+ cutoff_date = "2025-08-21"
68
+ is_before_cutoff = convert_to_date(date) < convert_to_date(cutoff_date)
69
+ if not product.is_crypto_spot() and is_before_cutoff:
70
+ orderbook_levels = 500
71
+ return f"{date}_{product.symbol}_ob{orderbook_levels}.data.zip"
72
+ else:
73
+ if product.is_crypto_spot():
74
+ return f"{product.symbol}_{date}.csv.gz"
75
+ else:
76
+ return f"{product.symbol}{date}.csv.gz"
77
+
78
+ @staticmethod
79
+ def _get(url: str, params: dict[str, Any] | None = None) -> bytes | None:
80
+ """Returns the file content, or None if Bybit has no file at the url (404), e.g. a date with no data.
81
+
82
+ Any other error raises; the faucet retries the ones listed in Bybit.retry_policy.
83
+ """
84
+ import httpx2
85
+
86
+ response: Response = httpx2.get(url, params=params)
87
+ if response.status_code == httpx2.codes.NOT_FOUND:
88
+ return None
89
+ return response.raise_for_status().content
90
+
91
+ def _create_url(
92
+ self, product: BaseProduct, resolution: Resolution, date: str | datetime.date
93
+ ) -> str:
94
+ base_url = self._get_base_url(product, resolution)
95
+ filename = self._create_filename(product, resolution, date)
96
+ url = f"{base_url}/{product.symbol}/{filename}"
97
+ return url
98
+
99
+ def _convert_orderbook_data_to_df(self, zipped_data: bytes) -> pl.DataFrame:
100
+ from pfeed.utils.compression import decompress
101
+
102
+ data = decompress(zipped_data)
103
+ df = pl.read_ndjson(data)
104
+ # flatten nested structs (one level) to mimic pd.json_normalize
105
+ struct_cols = [
106
+ name for name, dtype in df.schema.items() if isinstance(dtype, pl.Struct)
107
+ ]
108
+ if struct_cols:
109
+ df = df.unnest(struct_cols)
110
+ return df
111
+
112
+ def get_data(
113
+ self, product: BaseProduct, resolution: Resolution, date: str | datetime.date
114
+ ) -> pl.LazyFrame | None:
115
+ if product.is_option():
116
+ raise NotImplementedError("Bybit does not provide options data")
117
+ url = self._create_url(product, resolution, date)
118
+ zipped_data = self._get(url)
119
+ if zipped_data is None:
120
+ return None
121
+ if not resolution.is_quote():
122
+ import io
123
+
124
+ # pl.read_csv handles gzip via magic bytes — no manual decompress
125
+ # infer_schema_length=None scans all rows, e.g. inverse products' int 'size'
126
+ # can contain values in scientific notation (1.099309e+06) deep into the file
127
+ return pl.read_csv(io.BytesIO(zipped_data), infer_schema_length=None).lazy()
128
+ else:
129
+ raise NotImplementedError("orderbook data is not supported yet")
130
+ # TODO: it's quote_L2 data, need to support converting to quote_L1, converting to different number of levels (e.g. 10quote_L1) etc.
131
+ # NOTE for the 'data.u' field in data:
132
+ # Update ID. Is a sequence. Occasionally, you'll receive "u"=1, which is a snapshot data due to the restart of the service.
133
+ # So please overwrite your local orderbook
134
+ # df: pl.DataFrame = self._convert_orderbook_data_to_df(zipped_data)
135
+ # return df
136
+
137
+ # def _get_downloadable_symbols(self, ptype: str, epdt: str):
138
+ # '''Get external file names (e.g. BTCUSDT2022-10-04.csv.gz)'''
139
+ # from bs4 import BeautifulSoup
140
+ # url = '/'.join([self.URLS[ptype], epdt])
141
+ # if res := self._get(url, frequency=1, num_retry=3):
142
+ # soup = BeautifulSoup(res.text, 'html.parser')
143
+ # efilenames = [node.get('href') for node in soup.find_all('a')]
144
+ # return efilenames
145
+
146
+ # def _get_symbols_by_asset_type(self, ptype: str):
147
+ # import re
148
+ # from bs4 import BeautifulSoup
149
+ # ptype = ptype.upper()
150
+ # pattern = re.compile(self.DATA_NAMING_REGEX_PATTERNS[ptype])
151
+ # url = self.URLS[ptype]
152
+ # if res := self._get(url):
153
+ # soup = BeautifulSoup(res.text, 'html.parser')
154
+ # epdts = [node.get('href').replace('/', '') for node in soup.find_all('a') if pattern.search(node.get('href'))]
155
+ # return epdts
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
4
+
5
+ if TYPE_CHECKING:
6
+ from pfeed.market.requests.base_request import MarketFeedBaseRequest
7
+ from pfund_blocks.typing import Message
8
+
9
+ from pfeed_bybit.source import Bybit
10
+
11
+ import pfeed as pe
12
+ import polars as pl
13
+ from pfeed.market.data_model import MarketDataModel
14
+
15
+
16
+ class BybitMarketFeed[PipelineT: bool = Literal[False]](
17
+ pe.MarketFeed["Bybit[PipelineT]", "MarketFeedBaseRequest", MarketDataModel, PipelineT]
18
+ ):
19
+ downloaded_data_date_cols: ClassVar[list[str]] = ["timestamp"]
20
+ # Bybit's batch API serves one file per product per day
21
+ download_dataflow_per_date: ClassVar[bool] = True
22
+
23
+ @staticmethod
24
+ def _normalize_downloaded_data(df: pl.LazyFrame) -> pl.LazyFrame:
25
+ """Normalize raw Bybit DataFrame into a consistent format.
26
+
27
+ Args:
28
+ df: DataFrame after `_standardize_date_column`
29
+
30
+ Returns:
31
+ Normalized DataFrame with:
32
+ - 'size' renamed to 'volume'
33
+ - 'side' mapped from Buy/Sell (case-insensitive) to 1/-1
34
+ - volume cast to float64
35
+ """
36
+ # Bybit spot CSVs ship 'volume' directly (and an 'rpi' column);
37
+ # non-spot still ships 'size'. strict=False tolerates either schema.
38
+ RENAMING_COLS: dict[str, str] = {"size": "volume"}
39
+ MAPPING_COLS: dict[str, int] = {"buy": 1, "sell": -1}
40
+ return df.rename(RENAMING_COLS, strict=False).with_columns(
41
+ # some products use "Buy"/"Sell" while some use "buy"/"sell";
42
+ # replace_strict errors on unexpected values rather than silently producing nulls
43
+ pl.col("side")
44
+ .str.to_lowercase()
45
+ .replace_strict(MAPPING_COLS, return_dtype=pl.Int8),
46
+ # cast to float64 to pass pandera schema validation — inverse products have int volume
47
+ pl.col("volume").cast(pl.Float64),
48
+ )
49
+
50
+ @staticmethod
51
+ def _download_impl(
52
+ request: MarketFeedBaseRequest, data_model: MarketDataModel
53
+ ) -> pl.LazyFrame | None:
54
+ data_source = cast("Bybit", request.data_source)
55
+ assert data_model.start_date == data_model.end_date, (
56
+ f"{data_source.name} download() only supports downloading data for a single day"
57
+ )
58
+ # data_model.resolution is the target resolution, download the raw data at data_resolution
59
+ return data_source.get_batch_api().get_data(
60
+ product=data_model.product,
61
+ resolution=request.data_resolution,
62
+ date=data_model.start_date,
63
+ )
64
+
65
+ @staticmethod
66
+ def _parse_streaming_message(msg: Message) -> Message:
67
+ from pfund_bybit.ws_api import BybitWebSocketAPI
68
+
69
+ # NOTE: already parsed by the websocket API, only unparsed with Ray, where parsing is left to the workers
70
+ is_parsed_already = msg['data'] is not None
71
+ if is_parsed_already:
72
+ return msg
73
+ channel = cast(str, msg['channel'])
74
+ message = cast(dict[str, Any], msg['message'])
75
+ return {**msg, 'data': BybitWebSocketAPI.parser.parse_message(channel, message)}
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Literal
4
+
5
+ if TYPE_CHECKING:
6
+ from pfeed.base.feed import BaseFeed
7
+
8
+ import httpx2
9
+ from pfeed.dataflow.retry_policy import RetryPolicy
10
+ from pfeed.enums import (
11
+ DataAccessType,
12
+ DataCategory,
13
+ DataProviderType,
14
+ DataType,
15
+ )
16
+ from pfeed.source import BaseSource, SourceMetadata
17
+ from pfund_blocks.enums.env import Environment
18
+ from pfund_bybit.adapter import BybitAdapter
19
+
20
+ from pfeed_bybit.batch_api import BatchAPI
21
+ from pfeed_bybit.market.feed import BybitMarketFeed
22
+ from pfeed_bybit.stream_api import StreamAPI
23
+
24
+
25
+ class Bybit[PipelineT: bool = Literal[False]](BaseSource[PipelineT]):
26
+ Feeds: ClassVar[dict[DataCategory, type[BaseFeed]]] = {
27
+ DataCategory.MARKET_DATA: BybitMarketFeed,
28
+ }
29
+ metadata: ClassVar[SourceMetadata] = SourceMetadata(
30
+ name="BYBIT",
31
+ url="https://www.bybit.com",
32
+ data_categories={
33
+ DataCategory.MARKET_DATA: {
34
+ DataType.TICK: [
35
+ "PERPETUAL",
36
+ "INVERSE-PERPETUAL",
37
+ "FUTURE",
38
+ "INVERSE-FUTURE",
39
+ "CRYPTO",
40
+ ],
41
+ },
42
+ },
43
+ provider_type=DataProviderType.VENUE,
44
+ access_type=DataAccessType.FREE,
45
+ start_date="2020-01-01",
46
+ )
47
+ # timeouts and connection errors; HTTP error responses (e.g. 5xx) are not retried
48
+ retry_policy: ClassVar[RetryPolicy] = RetryPolicy(retry_on=(httpx2.TransportError,))
49
+ # Bybit's symbol conventions come from pfund-bybit's adapter, shared with pfund's Bybit venue;
50
+ # products are created by BaseSource.create_product() with it
51
+ adapter: ClassVar[BybitAdapter] = BybitAdapter() # ty: ignore[invalid-mutable-override]
52
+
53
+ market_feed: BybitMarketFeed[PipelineT]
54
+ # NOTE: class-level defaults instead of setting them in __init__, so they exist before BaseSource.__init__() creates the feeds,
55
+ # and Bybit doesn't override __init__, which would hide BaseSource's overloads that type the pipeline mode
56
+ _batch_api: BatchAPI | None = None
57
+ _stream_api: StreamAPI | None = None
58
+
59
+ def get_batch_api(self) -> BatchAPI:
60
+ if self._batch_api is None:
61
+ self._batch_api = BatchAPI()
62
+ return self._batch_api
63
+
64
+ def get_stream_api(
65
+ self,
66
+ env: Literal[
67
+ Environment.PAPER,
68
+ Environment.LIVE,
69
+ "PAPER",
70
+ "LIVE",
71
+ ],
72
+ ) -> StreamAPI:
73
+ environment = Environment(env.upper())
74
+ if environment not in (Environment.PAPER, Environment.LIVE):
75
+ raise ValueError(f"{self.name} streaming only supports PAPER and LIVE, got {environment}")
76
+ if self._stream_api is None:
77
+ self._stream_api = StreamAPI(env=environment)
78
+ elif self._stream_api.env != environment:
79
+ raise ValueError(
80
+ f"{self.name} stream API is already in {self._stream_api.env}, cannot stream in {environment}"
81
+ )
82
+ return self._stream_api
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Literal, cast
4
+
5
+ if TYPE_CHECKING:
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ from pfeed.base.requests import BaseStreamRequest
9
+ from pfeed.market.requests import MarketFeedStreamRequest
10
+ from pfund_blocks.entities.products.product_base import BaseProduct
11
+ from pfund_blocks.enums.env import Environment
12
+ from pfund_blocks.resolution import Resolution
13
+ from pfund_blocks.typing import Message
14
+
15
+ import asyncio
16
+ import logging
17
+
18
+ from pfeed.streaming.stream_api import BaseStreamAPI
19
+ from pfund_bybit.venue import Bybit
20
+ from pfund_bybit.ws_api import BybitWebSocketAPI
21
+
22
+
23
+ class StreamAPI(BaseStreamAPI):
24
+ """Simple wrapper of exchange's websocket APIs to connect to public channels,
25
+ one websocket API per category, since Bybit has one public endpoint per category.
26
+ """
27
+
28
+ def __init__(self, env: Literal[Environment.PAPER, Environment.LIVE]):
29
+ super().__init__(env)
30
+ self._logger = logging.getLogger("pfeed.bybit")
31
+ self._ws_apis: dict[Bybit.Category, BybitWebSocketAPI] = {}
32
+
33
+ def _add_ws_api(self, product: BaseProduct) -> BybitWebSocketAPI:
34
+ category = Bybit.get_category(product)
35
+ # NOTE: only categories with channels have a websocket API, since connect() raises if there are no channels
36
+ if category not in self._ws_apis:
37
+ ws_api = BybitWebSocketAPI(env=self._env, category=category)
38
+ ws_api._logger = self._logger
39
+ self._ws_apis[category] = ws_api
40
+ return self._ws_apis[category]
41
+
42
+ def get_supported_resolutions(self, product: BaseProduct) -> list[Resolution]:
43
+ return BybitWebSocketAPI.get_supported_resolutions(product)
44
+
45
+ def add_channel(self, request: BaseStreamRequest) -> None:
46
+ req = cast("MarketFeedStreamRequest", request)
47
+ ws_api = self._add_ws_api(req.product)
48
+ channel = ws_api.create_market_data_channel(req.product, req.data_resolution)
49
+ ws_api.add_channel(channel, channel_type="public")
50
+
51
+ def set_callback(self, callback: Callable[[Message], Awaitable[None] | None], *, skip_parse_message: bool = False) -> None:
52
+ super().set_callback(callback, skip_parse_message=skip_parse_message)
53
+ for ws_api in self._ws_apis.values():
54
+ ws_api.set_callback(callback, skip_parse_message=skip_parse_message)
55
+
56
+ async def connect(self) -> None:
57
+ for ws_api in self._ws_apis.values():
58
+ await ws_api.connect()
59
+
60
+ async def wait_until_disconnected(self) -> None:
61
+ await asyncio.gather(*[ws_api.wait_until_disconnected() for ws_api in self._ws_apis.values()])
62
+
63
+ async def disconnect(self) -> None:
64
+ await asyncio.gather(*[ws_api.disconnect() for ws_api in self._ws_apis.values()])