Qubx 0.1.3__tar.gz → 0.1.5__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.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- {qubx-0.1.3 → qubx-0.1.5}/PKG-INFO +1 -1
- {qubx-0.1.3 → qubx-0.1.5}/pyproject.toml +1 -1
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/_nb_magic.py +4 -0
- qubx-0.1.5/src/qubx/data/readers.py +513 -0
- qubx-0.1.5/src/qubx/pandaz/__init__.py +4 -0
- qubx-0.1.5/src/qubx/pandaz/ta.py +2527 -0
- qubx-0.1.3/src/qubx/utils/pandas.py → qubx-0.1.5/src/qubx/pandaz/utils.py +15 -2
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/marketdata/binance.py +1 -1
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/misc.py +1 -1
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/runner.py +2 -1
- qubx-0.1.3/src/qubx/data/readers.py +0 -515
- {qubx-0.1.3 → qubx-0.1.5}/README.md +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/build.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/account.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/basics.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/helpers.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/loggers.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/lookups.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/series.pxd +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/series.pyx +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/strategy.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/core/utils.pyx +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/impl/ccxt_connector.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/impl/ccxt_customizations.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/impl/ccxt_trading.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/impl/ccxt_utils.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/math/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/math/stats.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/ta/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/ta/indicators.pyx +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/trackers/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/trackers/rebalancers.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/__init__.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/_pyxreloader.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/charting/mpl_helpers.py +0 -0
- {qubx-0.1.3 → qubx-0.1.5}/src/qubx/utils/time.py +0 -0
|
@@ -32,13 +32,17 @@ if runtime_env() in ['notebook', 'shell']:
|
|
|
32
32
|
from tqdm.auto import tqdm
|
|
33
33
|
|
|
34
34
|
# - - - - TA stuff and indicators - - - -
|
|
35
|
+
import qubx.pandaz.ta as pta
|
|
36
|
+
|
|
35
37
|
# - - - - Portfolio analysis - - - -
|
|
36
38
|
# - - - - Simulator stuff - - - -
|
|
37
39
|
# - - - - Learn stuff - - - -
|
|
38
40
|
# - - - - Charting stuff - - - -
|
|
39
41
|
from matplotlib import pyplot as plt
|
|
40
42
|
from qubx.utils.charting.mpl_helpers import fig, subplot, sbp
|
|
43
|
+
|
|
41
44
|
# - - - - Utils - - - -
|
|
45
|
+
from qubx.pandaz.utils import scols, srows, ohlc_resample, continuous_periods, generate_equal_date_ranges
|
|
42
46
|
|
|
43
47
|
# - setup short numpy output format
|
|
44
48
|
np_fmt_short()
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import re, os
|
|
2
|
+
from typing import Callable, List, Union, Optional, Iterable, Any
|
|
3
|
+
from os.path import exists, join
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
from pyarrow import csv
|
|
8
|
+
import psycopg as pg
|
|
9
|
+
from functools import wraps
|
|
10
|
+
|
|
11
|
+
from qubx import logger
|
|
12
|
+
from qubx.core.series import TimeSeries, OHLCV, time_as_nsec, Quote, Trade
|
|
13
|
+
from qubx.utils.time import infer_series_frequency, handle_start_stop
|
|
14
|
+
|
|
15
|
+
_DT = lambda x: pd.Timedelta(x).to_numpy().item()
|
|
16
|
+
D1, H1 = _DT('1D'), _DT('1h')
|
|
17
|
+
|
|
18
|
+
DEFAULT_DAILY_SESSION = (_DT('00:00:00.100'), _DT('23:59:59.900'))
|
|
19
|
+
STOCK_DAILY_SESSION = (_DT('9:30:00.100'), _DT('15:59:59.900'))
|
|
20
|
+
CME_FUTURES_DAILY_SESSION = (_DT('8:30:00.100'), _DT('15:14:59.900'))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _recognize_t(t: Union[int, str], defaultvalue, timeunit) -> int:
|
|
24
|
+
if isinstance(t, (str, pd.Timestamp)):
|
|
25
|
+
try:
|
|
26
|
+
return np.datetime64(t, timeunit)
|
|
27
|
+
except:
|
|
28
|
+
pass
|
|
29
|
+
return defaultvalue
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_column_index_in_list(xs, *args):
|
|
33
|
+
xs = [x.lower() for x in xs]
|
|
34
|
+
for a in args:
|
|
35
|
+
ai = a.lower()
|
|
36
|
+
if ai in xs:
|
|
37
|
+
return xs.index(ai)
|
|
38
|
+
raise IndexError(f"Can't find any from {args} in list: {xs}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DataTransformer:
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.buffer = []
|
|
45
|
+
self._column_names = []
|
|
46
|
+
|
|
47
|
+
def start_transform(self, name: str, column_names: List[str]):
|
|
48
|
+
self._column_names = column_names
|
|
49
|
+
self.buffer = []
|
|
50
|
+
|
|
51
|
+
def process_data(self, rows_data: Iterable) -> Any:
|
|
52
|
+
if rows_data is not None:
|
|
53
|
+
self.buffer.extend(rows_data)
|
|
54
|
+
|
|
55
|
+
def collect(self) -> Any:
|
|
56
|
+
return self.buffer
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DataReader:
|
|
60
|
+
|
|
61
|
+
def get_names(self) -> List[str] :
|
|
62
|
+
raise NotImplemented()
|
|
63
|
+
|
|
64
|
+
def read(self, data_id: str, start: str | None=None, stop: str | None=None,
|
|
65
|
+
transform: DataTransformer = DataTransformer(),
|
|
66
|
+
chunksize=0,
|
|
67
|
+
**kwargs
|
|
68
|
+
) -> Iterable | List:
|
|
69
|
+
raise NotImplemented()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CsvStorageDataReader(DataReader):
|
|
73
|
+
"""
|
|
74
|
+
Data reader for timeseries data stored as csv files in the specified directory
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, path: str) -> None:
|
|
78
|
+
if not exists(path):
|
|
79
|
+
raise ValueError(f"Folder is not found at {path}")
|
|
80
|
+
self.path = path
|
|
81
|
+
|
|
82
|
+
def __find_time_idx(self, arr: pa.ChunkedArray, v) -> int:
|
|
83
|
+
ix = arr.index(v).as_py()
|
|
84
|
+
if ix < 0:
|
|
85
|
+
for c in arr.iterchunks():
|
|
86
|
+
a = c.to_numpy()
|
|
87
|
+
ix = np.searchsorted(a, v, side='right')
|
|
88
|
+
if ix > 0 and ix < len(c):
|
|
89
|
+
ix = arr.index(a[ix]).as_py() - 1
|
|
90
|
+
break
|
|
91
|
+
return ix
|
|
92
|
+
|
|
93
|
+
def __check_file_name(self, name: str) -> str | None:
|
|
94
|
+
_f = join(self.path, name)
|
|
95
|
+
for sfx in ['.csv', '.csv.gz', '']:
|
|
96
|
+
if exists(p:=(_f + sfx)):
|
|
97
|
+
return p
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def read(self, data_id: str, start: str | None=None, stop: str | None=None,
|
|
101
|
+
transform: DataTransformer = DataTransformer(),
|
|
102
|
+
chunksize=0,
|
|
103
|
+
timestamp_formatters = None
|
|
104
|
+
) -> Iterable | Any:
|
|
105
|
+
|
|
106
|
+
f_path = self.__check_file_name(data_id)
|
|
107
|
+
if not f_path:
|
|
108
|
+
ValueError(f"Can't find any csv data for {data_id} in {self.path} !")
|
|
109
|
+
|
|
110
|
+
convert_options = None
|
|
111
|
+
if timestamp_formatters is not None:
|
|
112
|
+
convert_options=csv.ConvertOptions(timestamp_parsers=timestamp_formatters)
|
|
113
|
+
|
|
114
|
+
table = csv.read_csv(
|
|
115
|
+
f_path,
|
|
116
|
+
parse_options=csv.ParseOptions(ignore_empty_lines=True),
|
|
117
|
+
convert_options=convert_options
|
|
118
|
+
)
|
|
119
|
+
fieldnames = table.column_names
|
|
120
|
+
|
|
121
|
+
# - try to find range to load
|
|
122
|
+
start_idx, stop_idx = 0, table.num_rows
|
|
123
|
+
try:
|
|
124
|
+
_time_field_idx = _find_column_index_in_list(fieldnames, 'time', 'timestamp', 'datetime', 'date')
|
|
125
|
+
_time_type = table.field(_time_field_idx).type
|
|
126
|
+
_time_unit = _time_type.unit if hasattr(_time_type, 'unit') else 's'
|
|
127
|
+
_time_data = table[_time_field_idx]
|
|
128
|
+
|
|
129
|
+
# - check if need convert time to primitive types (i.e. Date32 -> timestamp[x])
|
|
130
|
+
_time_cast_function = lambda xs: xs
|
|
131
|
+
if _time_type != pa.timestamp(_time_unit):
|
|
132
|
+
_time_cast_function = lambda xs: xs.cast(pa.timestamp(_time_unit))
|
|
133
|
+
_time_data = _time_cast_function(_time_data)
|
|
134
|
+
|
|
135
|
+
# - preprocessing start and stop
|
|
136
|
+
t_0, t_1 = handle_start_stop(start, stop, convert=lambda x: _recognize_t(x, None, _time_unit))
|
|
137
|
+
|
|
138
|
+
# - check requested range
|
|
139
|
+
if t_0:
|
|
140
|
+
start_idx = self.__find_time_idx(_time_data, t_0)
|
|
141
|
+
if start_idx >= table.num_rows:
|
|
142
|
+
# no data for requested start date
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
if t_1:
|
|
146
|
+
stop_idx = self.__find_time_idx(_time_data, t_1)
|
|
147
|
+
if stop_idx < 0 or stop_idx < start_idx:
|
|
148
|
+
stop_idx = table.num_rows
|
|
149
|
+
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.warning(exc)
|
|
152
|
+
logger.info('loading whole file')
|
|
153
|
+
|
|
154
|
+
length = (stop_idx - start_idx + 1)
|
|
155
|
+
selected_table = table.slice(start_idx, length)
|
|
156
|
+
|
|
157
|
+
# - in this case we want to return iterable chunks of data
|
|
158
|
+
if chunksize > 0:
|
|
159
|
+
def _iter_chunks():
|
|
160
|
+
for n in range(0, length // chunksize + 1):
|
|
161
|
+
transform.start_transform(data_id, fieldnames)
|
|
162
|
+
raw_data = selected_table[n*chunksize : min((n+1)*chunksize, length)].to_pandas().to_numpy()
|
|
163
|
+
transform.process_data(raw_data)
|
|
164
|
+
yield transform.collect()
|
|
165
|
+
return _iter_chunks()
|
|
166
|
+
|
|
167
|
+
transform.start_transform(data_id, fieldnames)
|
|
168
|
+
raw_data = selected_table.to_pandas().to_numpy()
|
|
169
|
+
transform.process_data(raw_data)
|
|
170
|
+
return transform.collect()
|
|
171
|
+
|
|
172
|
+
def get_names(self) -> List[str] :
|
|
173
|
+
_n = []
|
|
174
|
+
for s in os.listdir(self.path):
|
|
175
|
+
if (m:=re.match(r'(.*)\.csv(.gz)?$', s)):
|
|
176
|
+
_n.append(m.group(1))
|
|
177
|
+
return _n
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AsPandasFrame(DataTransformer):
|
|
181
|
+
"""
|
|
182
|
+
List of records to pandas dataframe transformer
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def start_transform(self, name: str, column_names: List[str]):
|
|
186
|
+
self._time_idx = _find_column_index_in_list(column_names, 'time', 'timestamp', 'datetime', 'date')
|
|
187
|
+
self._column_names = column_names
|
|
188
|
+
self._frame = pd.DataFrame()
|
|
189
|
+
|
|
190
|
+
def process_data(self, rows_data: Iterable) -> Any:
|
|
191
|
+
self._frame
|
|
192
|
+
p = pd.DataFrame.from_records(rows_data, columns=self._column_names)
|
|
193
|
+
p.set_index(self._column_names[self._time_idx], drop=True, inplace=True)
|
|
194
|
+
p.sort_index(inplace=True)
|
|
195
|
+
self._frame = pd.concat((self._frame, p), axis=0, sort=True)
|
|
196
|
+
return p
|
|
197
|
+
|
|
198
|
+
def collect(self) -> Any:
|
|
199
|
+
return self._frame
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AsOhlcvSeries(DataTransformer):
|
|
203
|
+
|
|
204
|
+
def __init__(self, timeframe: str | None = None, timestamp_units='ns') -> None:
|
|
205
|
+
super().__init__()
|
|
206
|
+
self.timeframe = timeframe
|
|
207
|
+
self._series = None
|
|
208
|
+
self._data_type = None
|
|
209
|
+
self.timestamp_units = timestamp_units
|
|
210
|
+
|
|
211
|
+
def start_transform(self, name: str, column_names: List[str]):
|
|
212
|
+
self._time_idx = _find_column_index_in_list(column_names, 'time', 'timestamp', 'datetime', 'date')
|
|
213
|
+
self._volume_idx = None
|
|
214
|
+
self._b_volume_idx = None
|
|
215
|
+
try:
|
|
216
|
+
self._close_idx = _find_column_index_in_list(column_names, 'close')
|
|
217
|
+
self._open_idx = _find_column_index_in_list(column_names, 'open')
|
|
218
|
+
self._high_idx = _find_column_index_in_list(column_names, 'high')
|
|
219
|
+
self._low_idx = _find_column_index_in_list(column_names, 'low')
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
self._volume_idx = _find_column_index_in_list(column_names, 'quote_volume', 'volume', 'vol')
|
|
223
|
+
except: pass
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
self._b_volume_idx = _find_column_index_in_list(column_names, 'taker_buy_volume', 'taker_buy_quote_volume', 'buy_volume')
|
|
227
|
+
except: pass
|
|
228
|
+
|
|
229
|
+
self._data_type = 'ohlc'
|
|
230
|
+
except:
|
|
231
|
+
try:
|
|
232
|
+
self._ask_idx = _find_column_index_in_list(column_names, 'ask')
|
|
233
|
+
self._bid_idx = _find_column_index_in_list(column_names, 'bid')
|
|
234
|
+
self._data_type = 'quotes'
|
|
235
|
+
except:
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
self._price_idx = _find_column_index_in_list(column_names, 'price')
|
|
239
|
+
self._size_idx = _find_column_index_in_list(column_names, 'quote_qty', 'qty', 'size', 'amount', 'volume')
|
|
240
|
+
self._taker_idx = None
|
|
241
|
+
try:
|
|
242
|
+
self._taker_idx = _find_column_index_in_list(column_names, 'is_buyer_maker', 'side', 'aggressive', 'taker', 'is_taker')
|
|
243
|
+
except: pass
|
|
244
|
+
|
|
245
|
+
self._data_type = 'trades'
|
|
246
|
+
except:
|
|
247
|
+
raise ValueError(f"Can't recognize data for update from header: {column_names}")
|
|
248
|
+
|
|
249
|
+
self._column_names = column_names
|
|
250
|
+
self._name = name
|
|
251
|
+
if self.timeframe:
|
|
252
|
+
self._series = OHLCV(self._name, self.timeframe)
|
|
253
|
+
|
|
254
|
+
def _time(self, t) -> int:
|
|
255
|
+
if self.timestamp_units == 'ns':
|
|
256
|
+
return np.datetime64(t, 'ns').item()
|
|
257
|
+
return np.datetime64(t, self.timestamp_units).astype('datetime64[ns]').item()
|
|
258
|
+
|
|
259
|
+
def _proc_ohlc(self, rows_data: List[List]):
|
|
260
|
+
for d in rows_data:
|
|
261
|
+
self._series.update_by_bar(
|
|
262
|
+
self._time(d[self._time_idx]),
|
|
263
|
+
d[self._open_idx], d[self._high_idx], d[self._low_idx], d[self._close_idx],
|
|
264
|
+
d[self._volume_idx] if self._volume_idx else 0,
|
|
265
|
+
d[self._b_volume_idx] if self._b_volume_idx else 0
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _proc_quotes(self, rows_data: List[List]):
|
|
269
|
+
for d in rows_data:
|
|
270
|
+
self._series.update(
|
|
271
|
+
self._time(d[self._time_idx]),
|
|
272
|
+
(d[self._ask_idx] + d[self._bid_idx])/2
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _proc_trades(self, rows_data: List[List]):
|
|
276
|
+
for d in rows_data:
|
|
277
|
+
a = d[self._taker_idx] if self._taker_idx else 0
|
|
278
|
+
s = d[self._size_idx]
|
|
279
|
+
b = s if a else 0
|
|
280
|
+
self._series.update(self._time(d[self._time_idx]), d[self._price_idx], s, b)
|
|
281
|
+
|
|
282
|
+
def process_data(self, rows_data: List[List]) -> Any:
|
|
283
|
+
if self._series is None:
|
|
284
|
+
ts = [t[self._time_idx] for t in rows_data[:100]]
|
|
285
|
+
self.timeframe = pd.Timedelta(infer_series_frequency(ts)).asm8.item()
|
|
286
|
+
|
|
287
|
+
# - create instance after first data received if
|
|
288
|
+
self._series = OHLCV(self._name, self.timeframe)
|
|
289
|
+
|
|
290
|
+
match self._data_type:
|
|
291
|
+
case 'ohlc':
|
|
292
|
+
self._proc_ohlc(rows_data)
|
|
293
|
+
case 'quotes':
|
|
294
|
+
self._proc_quotes(rows_data)
|
|
295
|
+
case 'trades':
|
|
296
|
+
self._proc_trades(rows_data)
|
|
297
|
+
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
def collect(self) -> Any:
|
|
301
|
+
return self._series
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class AsQuotes(DataTransformer):
|
|
305
|
+
|
|
306
|
+
def start_transform(self, name: str, column_names: List[str]):
|
|
307
|
+
self.buffer = list()
|
|
308
|
+
self._time_idx = _find_column_index_in_list(column_names, 'time', 'timestamp', 'datetime')
|
|
309
|
+
self._bid_idx = _find_column_index_in_list(column_names, 'bid')
|
|
310
|
+
self._ask_idx = _find_column_index_in_list(column_names, 'ask')
|
|
311
|
+
self._bidvol_idx = _find_column_index_in_list(column_names, 'bidvol', 'bid_vol', 'bidsize', 'bid_size')
|
|
312
|
+
self._askvol_idx = _find_column_index_in_list(column_names, 'askvol', 'ask_vol', 'asksize', 'ask_size')
|
|
313
|
+
|
|
314
|
+
def process_data(self, rows_data: Iterable) -> Any:
|
|
315
|
+
if rows_data is not None:
|
|
316
|
+
for d in rows_data:
|
|
317
|
+
t = d[self._time_idx]
|
|
318
|
+
b = d[self._bid_idx]
|
|
319
|
+
a = d[self._ask_idx]
|
|
320
|
+
bv = d[self._bidvol_idx]
|
|
321
|
+
av = d[self._askvol_idx]
|
|
322
|
+
self.buffer.append(Quote(t.as_unit('ns').asm8.item(), b, a, bv, av))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class RestoreTicksFromOHLC(DataTransformer):
|
|
326
|
+
"""
|
|
327
|
+
Emulates quotes (and trades) from OHLC bars
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(self,
|
|
331
|
+
trades: bool=False, # if we also wants 'trades'
|
|
332
|
+
default_bid_size=1e9, # default bid/ask is big
|
|
333
|
+
default_ask_size=1e9, # default bid/ask is big
|
|
334
|
+
daily_session_start_end=DEFAULT_DAILY_SESSION,
|
|
335
|
+
spread=0.0):
|
|
336
|
+
super().__init__()
|
|
337
|
+
self._trades = trades
|
|
338
|
+
self._bid_size = default_bid_size
|
|
339
|
+
self._ask_size = default_ask_size
|
|
340
|
+
self._s2 = spread / 2.0
|
|
341
|
+
self._d_session_start = daily_session_start_end[0]
|
|
342
|
+
self._d_session_end = daily_session_start_end[1]
|
|
343
|
+
|
|
344
|
+
def start_transform(self, name: str, column_names: List[str]):
|
|
345
|
+
self.buffer = []
|
|
346
|
+
# - it will fail if receive data doesn't look as ohlcv
|
|
347
|
+
self._time_idx = _find_column_index_in_list(column_names, 'time', 'timestamp', 'datetime', 'date')
|
|
348
|
+
self._open_idx = _find_column_index_in_list(column_names, 'open')
|
|
349
|
+
self._high_idx = _find_column_index_in_list(column_names, 'high')
|
|
350
|
+
self._low_idx = _find_column_index_in_list(column_names, 'low')
|
|
351
|
+
self._close_idx = _find_column_index_in_list(column_names, 'close')
|
|
352
|
+
self._volume_idx = None
|
|
353
|
+
self._freq = None
|
|
354
|
+
try:
|
|
355
|
+
self._volume_idx = _find_column_index_in_list(column_names, 'volume', 'vol')
|
|
356
|
+
except: pass
|
|
357
|
+
|
|
358
|
+
if self._volume_idx is None and self._trades:
|
|
359
|
+
logger.warning("Input OHLC data doesn't contain volume information so trades can't be emulated !")
|
|
360
|
+
self._trades = False
|
|
361
|
+
|
|
362
|
+
def process_data(self, rows_data:List[List]) -> Any:
|
|
363
|
+
if rows_data is None:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
s2 = self._s2
|
|
367
|
+
|
|
368
|
+
if self._freq is None:
|
|
369
|
+
ts = [t[self._time_idx] for t in rows_data[:100]]
|
|
370
|
+
self._freq = infer_series_frequency(ts)
|
|
371
|
+
|
|
372
|
+
# - timestamps when we emit simulated quotes
|
|
373
|
+
dt = self._freq.astype('timedelta64[ns]').item()
|
|
374
|
+
if dt < D1:
|
|
375
|
+
self._t_start = dt // 10
|
|
376
|
+
self._t_mid1 = dt // 2 - dt // 10
|
|
377
|
+
self._t_mid2 = dt // 2 + dt // 10
|
|
378
|
+
self._t_end = dt - dt // 10
|
|
379
|
+
else:
|
|
380
|
+
self._t_start = self._d_session_start
|
|
381
|
+
self._t_mid1 = dt // 2 - H1
|
|
382
|
+
self._t_mid2 = dt // 2 + H1
|
|
383
|
+
self._t_end = self._d_session_end
|
|
384
|
+
|
|
385
|
+
# - input data
|
|
386
|
+
for data in rows_data:
|
|
387
|
+
ti = pd.Timestamp(data[self._time_idx]).as_unit('ns').asm8.item()
|
|
388
|
+
o = data[self._open_idx]
|
|
389
|
+
h= data[self._high_idx]
|
|
390
|
+
l = data[self._low_idx]
|
|
391
|
+
c = data[self._close_idx]
|
|
392
|
+
rv = data[self._volume_idx] if self._volume_idx else 0
|
|
393
|
+
|
|
394
|
+
# - opening quote
|
|
395
|
+
self.buffer.append(Quote(ti + self._t_start, o - s2, o + s2, self._bid_size, self._ask_size))
|
|
396
|
+
|
|
397
|
+
if c >= o:
|
|
398
|
+
if self._trades:
|
|
399
|
+
self.buffer.append(Trade(ti + self._t_start, o - s2, rv * (o - l))) # sell 1
|
|
400
|
+
self.buffer.append(Quote(ti + self._t_mid1, l - s2, l + s2, self._bid_size, self._ask_size))
|
|
401
|
+
|
|
402
|
+
if self._trades:
|
|
403
|
+
self.buffer.append(Trade(ti + self._t_mid1, l + s2, rv * (c - o))) # buy 1
|
|
404
|
+
self.buffer.append(Quote(ti + self._t_mid2, h - s2, h + s2, self._bid_size, self._ask_size))
|
|
405
|
+
|
|
406
|
+
if self._trades:
|
|
407
|
+
self.buffer.append(Trade(ti + self._t_mid2, h - s2, rv * (h - c))) # sell 2
|
|
408
|
+
else:
|
|
409
|
+
if self._trades:
|
|
410
|
+
self.buffer.append(Trade(ti + self._t_start, o + s2, rv * (h - o))) # buy 1
|
|
411
|
+
self.buffer.append(Quote(ti + self._t_mid1, h - s2, h + s2, self._bid_size, self._ask_size))
|
|
412
|
+
|
|
413
|
+
if self._trades:
|
|
414
|
+
self.buffer.append(Trade(ti + self._t_mid1, h - s2, rv * (o - c))) # sell 1
|
|
415
|
+
self.buffer.append(Quote(ti + self._t_mid2, l - s2, l + s2, self._bid_size, self._ask_size))
|
|
416
|
+
|
|
417
|
+
if self._trades:
|
|
418
|
+
self.buffer.append(Trade(ti + self._t_mid2, l + s2, rv * (c - l))) # buy 2
|
|
419
|
+
|
|
420
|
+
# - closing quote
|
|
421
|
+
self.buffer.append(Quote(ti + self._t_end, c - s2, c + s2, self._bid_size, self._ask_size))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _retry(fn):
|
|
425
|
+
@wraps(fn)
|
|
426
|
+
def wrapper(*args, **kw):
|
|
427
|
+
cls = args[0]
|
|
428
|
+
for x in range(cls._reconnect_tries):
|
|
429
|
+
# print(x, cls._reconnect_tries)
|
|
430
|
+
try:
|
|
431
|
+
return fn(*args, **kw)
|
|
432
|
+
except (pg.InterfaceError, pg.OperationalError) as e:
|
|
433
|
+
logger.warning("Database Connection [InterfaceError or OperationalError]")
|
|
434
|
+
# print ("Idle for %s seconds" % (cls._reconnect_idle))
|
|
435
|
+
# time.sleep(cls._reconnect_idle)
|
|
436
|
+
cls._connect()
|
|
437
|
+
return wrapper
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class QuestDBConnector(DataReader):
|
|
441
|
+
"""
|
|
442
|
+
Very first version of QuestDB connector
|
|
443
|
+
|
|
444
|
+
# Connect to an existing QuestDB instance
|
|
445
|
+
>>> db = QuestDBConnector('user=admin password=quest host=localhost port=8812', OhlcvPandasDataProcessor())
|
|
446
|
+
>>> db.read('BINANCEF.ETHUSDT', '2024-01-01')
|
|
447
|
+
"""
|
|
448
|
+
_reconnect_tries = 5
|
|
449
|
+
_reconnect_idle = 0.1 # wait seconds before retying
|
|
450
|
+
|
|
451
|
+
def __init__(self, connection_url: str) -> None:
|
|
452
|
+
self._connection = None
|
|
453
|
+
self._cursor = None
|
|
454
|
+
self.connection_url = connection_url
|
|
455
|
+
self._connect()
|
|
456
|
+
|
|
457
|
+
def _connect(self):
|
|
458
|
+
logger.info("Connecting to QuestDB ...")
|
|
459
|
+
self._connection = pg.connect(self.connection_url, autocommit=True)
|
|
460
|
+
self._cursor = self._connection.cursor()
|
|
461
|
+
|
|
462
|
+
@_retry
|
|
463
|
+
def read(self, data_id: str, start: str|None=None, stop: str|None=None,
|
|
464
|
+
transform: DataTransformer = DataTransformer(),
|
|
465
|
+
chunksize=0, # TODO: use self._cursor.fetchmany in this case !!!!
|
|
466
|
+
timeframe: str='1m') -> Any:
|
|
467
|
+
start, end = handle_start_stop(start, stop)
|
|
468
|
+
w0 = f"timestamp >= '{start}'" if start else ''
|
|
469
|
+
w1 = f"timestamp <= '{end}'" if end else ''
|
|
470
|
+
where = f'where {w0} and {w1}' if (w0 and w1) else f"where {(w0 or w1)}"
|
|
471
|
+
|
|
472
|
+
# just a temp hack - actually we need to discuss symbology etc
|
|
473
|
+
symbol = data_id#.split('.')[-1]
|
|
474
|
+
|
|
475
|
+
self._cursor.execute(
|
|
476
|
+
f"""
|
|
477
|
+
select timestamp,
|
|
478
|
+
first(open) as open,
|
|
479
|
+
max(high) as high,
|
|
480
|
+
min(low) as low,
|
|
481
|
+
last(close) as close,
|
|
482
|
+
sum(volume) as volume,
|
|
483
|
+
sum(quote_volume) as quote_volume,
|
|
484
|
+
sum(count) as count,
|
|
485
|
+
sum(taker_buy_volume) as taker_buy_volume,
|
|
486
|
+
sum(taker_buy_quote_volume) as taker_buy_quote_volume
|
|
487
|
+
from "{symbol.upper()}" {where}
|
|
488
|
+
SAMPLE by {timeframe};
|
|
489
|
+
""" # type: ignore
|
|
490
|
+
)
|
|
491
|
+
records = self._cursor.fetchall() # TODO: for chunksize > 0 use fetchmany etc
|
|
492
|
+
names = [d.name for d in self._cursor.description]
|
|
493
|
+
|
|
494
|
+
transform.start_transform(data_id, names)
|
|
495
|
+
|
|
496
|
+
# d = np.array(records)
|
|
497
|
+
transform.process_data(records)
|
|
498
|
+
return transform.collect()
|
|
499
|
+
|
|
500
|
+
@_retry
|
|
501
|
+
def get_names(self) -> List[str] :
|
|
502
|
+
self._cursor.execute("select table_name from tables()")
|
|
503
|
+
records = self._cursor.fetchall()
|
|
504
|
+
return [r[0] for r in records]
|
|
505
|
+
|
|
506
|
+
def __del__(self):
|
|
507
|
+
for c in (self._cursor, self._connection):
|
|
508
|
+
try:
|
|
509
|
+
logger.info("Closing connection")
|
|
510
|
+
c.close()
|
|
511
|
+
except:
|
|
512
|
+
pass
|
|
513
|
+
|