pfeed-bybit 0.0.1__py3-none-any.whl
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.
- pfeed_bybit/__init__.py +5 -0
- pfeed_bybit/batch_api.py +155 -0
- pfeed_bybit/market/feed.py +75 -0
- pfeed_bybit/source.py +82 -0
- pfeed_bybit/stream_api.py +64 -0
- pfeed_bybit-0.0.1.dist-info/METADATA +13 -0
- pfeed_bybit-0.0.1.dist-info/RECORD +9 -0
- pfeed_bybit-0.0.1.dist-info/WHEEL +4 -0
- pfeed_bybit-0.0.1.dist-info/entry_points.txt +3 -0
pfeed_bybit/__init__.py
ADDED
pfeed_bybit/batch_api.py
ADDED
|
@@ -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)}
|
pfeed_bybit/source.py
ADDED
|
@@ -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()])
|
|
@@ -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,9 @@
|
|
|
1
|
+
pfeed_bybit/__init__.py,sha256=XFhvPDq4puWYNG0ArmQloYVHX-CsybAd1bBDsyaqTc8,65
|
|
2
|
+
pfeed_bybit/batch_api.py,sha256=nO0jd4CoT_SR9Zi2X9Mk1Dr-wbvWE3HOn-2r6_jTPFU,6992
|
|
3
|
+
pfeed_bybit/market/feed.py,sha256=kTcTQTJEgYeIkVoaJs2EXROChNTs6jKaV0dTaFBOLuU,3134
|
|
4
|
+
pfeed_bybit/source.py,sha256=VlsNhzlE6aJTI4HcFB9_Ll5zmH4uKu1k8EvK4JvvyFc,3045
|
|
5
|
+
pfeed_bybit/stream_api.py,sha256=ROnly2kc1h5ao6PBtS8T6UCq7PVfDRiT1KqLih6aYmU,2779
|
|
6
|
+
pfeed_bybit-0.0.1.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
7
|
+
pfeed_bybit-0.0.1.dist-info/entry_points.txt,sha256=TEt1T_Qb2dOxa25MoH4u8SWIu8O8NocmCLKqbSsmf5A,43
|
|
8
|
+
pfeed_bybit-0.0.1.dist-info/METADATA,sha256=V37AYhSpd39spR3bKvjRNpzE2rgiRGGRZzo6s8DNEwk,475
|
|
9
|
+
pfeed_bybit-0.0.1.dist-info/RECORD,,
|