Qubx 0.5.7__cp312-cp312-manylinux_2_39_x86_64.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.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/__init__.py +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- qubx-0.5.7.dist-info/entry_points.txt +3 -0
qubx/cli/__init__.py
ADDED
|
File without changes
|
qubx/cli/commands.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from qubx.utils.misc import add_project_to_system_path, logo
|
|
6
|
+
from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter, simulate_strategy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def main():
|
|
11
|
+
"""
|
|
12
|
+
Qubx CLI.
|
|
13
|
+
"""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.argument("config-file", type=Path, required=True)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--account-file",
|
|
21
|
+
"-a",
|
|
22
|
+
type=Path,
|
|
23
|
+
help="Account configuration file path.",
|
|
24
|
+
required=False,
|
|
25
|
+
)
|
|
26
|
+
@click.option("--paper", "-p", is_flag=True, default=False, help="Use paper trading mode.", show_default=True)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--jupyter", "-j", is_flag=True, default=False, help="Run strategy in jupyter console.", show_default=True
|
|
29
|
+
)
|
|
30
|
+
def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool):
|
|
31
|
+
"""
|
|
32
|
+
Starts the strategy with the given configuration file. If paper mode is enabled, account is not required.
|
|
33
|
+
|
|
34
|
+
Account configurations are searched in the following priority:\n
|
|
35
|
+
- If provided, the account file is searched first.\n
|
|
36
|
+
- If exists, accounts.toml located in the same folder with the config searched.\n
|
|
37
|
+
- If neither of the above are provided, the accounts.toml in the ~/qubx/accounts.toml path is searched.
|
|
38
|
+
"""
|
|
39
|
+
add_project_to_system_path()
|
|
40
|
+
add_project_to_system_path(str(config_file.parent))
|
|
41
|
+
if jupyter:
|
|
42
|
+
run_strategy_yaml_in_jupyter(config_file, account_file, paper)
|
|
43
|
+
else:
|
|
44
|
+
logo()
|
|
45
|
+
run_strategy_yaml(config_file, account_file, paper, blocking=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@main.command()
|
|
49
|
+
@click.argument("config-file", type=Path, required=True)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--start", "-s", default=None, type=str, help="Override simulation start date from config.", show_default=True
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--end", "-e", default=None, type=str, help="Override simulation end date from config.", show_default=True
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--output", "-o", default="results", type=str, help="Output directory for simulation results.", show_default=True
|
|
58
|
+
)
|
|
59
|
+
def simulate(config_file: Path, start: str | None, end: str | None, output: str | None):
|
|
60
|
+
add_project_to_system_path()
|
|
61
|
+
add_project_to_system_path(str(config_file.parent))
|
|
62
|
+
logo()
|
|
63
|
+
simulate_strategy(config_file, output, start, end)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
from asyncio.exceptions import CancelledError
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
import ccxt.pro as cxp
|
|
11
|
+
from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
|
|
12
|
+
from qubx import logger
|
|
13
|
+
from qubx.core.account import BasicAccountProcessor
|
|
14
|
+
from qubx.core.basics import (
|
|
15
|
+
CtrlChannel,
|
|
16
|
+
Deal,
|
|
17
|
+
Instrument,
|
|
18
|
+
ITimeProvider,
|
|
19
|
+
Order,
|
|
20
|
+
Position,
|
|
21
|
+
TransactionCostsCalculator,
|
|
22
|
+
dt_64,
|
|
23
|
+
)
|
|
24
|
+
from qubx.core.interfaces import ISubscriptionManager
|
|
25
|
+
from qubx.utils.marketdata.ccxt import ccxt_symbol_to_instrument
|
|
26
|
+
from qubx.utils.misc import AsyncThreadLoop
|
|
27
|
+
|
|
28
|
+
from .exceptions import CcxtSymbolNotRecognized
|
|
29
|
+
from .utils import (
|
|
30
|
+
ccxt_convert_balance,
|
|
31
|
+
ccxt_convert_deal_info,
|
|
32
|
+
ccxt_convert_order_info,
|
|
33
|
+
ccxt_convert_positions,
|
|
34
|
+
ccxt_convert_ticker,
|
|
35
|
+
ccxt_extract_deals_from_exec,
|
|
36
|
+
ccxt_find_instrument,
|
|
37
|
+
ccxt_restore_position_from_deals,
|
|
38
|
+
instrument_to_ccxt_symbol,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CcxtAccountProcessor(BasicAccountProcessor):
|
|
43
|
+
"""
|
|
44
|
+
Subscribes to account information from the exchange.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
exchange: cxp.Exchange
|
|
48
|
+
channel: CtrlChannel
|
|
49
|
+
base_currency: str
|
|
50
|
+
balance_interval: str
|
|
51
|
+
position_interval: str
|
|
52
|
+
subscription_interval: str
|
|
53
|
+
max_position_restore_days: int
|
|
54
|
+
max_retries: int
|
|
55
|
+
|
|
56
|
+
_loop: AsyncThreadLoop
|
|
57
|
+
_polling_tasks: dict[str, concurrent.futures.Future]
|
|
58
|
+
_subscription_manager: ISubscriptionManager | None
|
|
59
|
+
_polling_to_init: dict[str, bool]
|
|
60
|
+
_required_instruments: set[Instrument]
|
|
61
|
+
_latest_instruments: set[Instrument]
|
|
62
|
+
|
|
63
|
+
_free_capital: float = np.nan
|
|
64
|
+
_total_capital: float = np.nan
|
|
65
|
+
_instrument_to_last_price: dict[Instrument, tuple[dt_64, float]]
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
account_id: str,
|
|
70
|
+
exchange: cxp.Exchange,
|
|
71
|
+
channel: CtrlChannel,
|
|
72
|
+
time_provider: ITimeProvider,
|
|
73
|
+
base_currency: str,
|
|
74
|
+
tcc: TransactionCostsCalculator,
|
|
75
|
+
balance_interval: str = "30Sec",
|
|
76
|
+
position_interval: str = "30Sec",
|
|
77
|
+
subscription_interval: str = "10Sec",
|
|
78
|
+
max_position_restore_days: int = 30,
|
|
79
|
+
max_retries: int = 10,
|
|
80
|
+
):
|
|
81
|
+
super().__init__(
|
|
82
|
+
account_id=account_id,
|
|
83
|
+
time_provider=time_provider,
|
|
84
|
+
base_currency=base_currency,
|
|
85
|
+
tcc=tcc,
|
|
86
|
+
initial_capital=0,
|
|
87
|
+
)
|
|
88
|
+
self.exchange = exchange
|
|
89
|
+
self.channel = channel
|
|
90
|
+
self.max_retries = max_retries
|
|
91
|
+
self.balance_interval = balance_interval
|
|
92
|
+
self.position_interval = position_interval
|
|
93
|
+
self.subscription_interval = subscription_interval
|
|
94
|
+
self.max_position_restore_days = max_position_restore_days
|
|
95
|
+
self._loop = AsyncThreadLoop(exchange.asyncio_loop)
|
|
96
|
+
self._is_running = False
|
|
97
|
+
self._polling_tasks = {}
|
|
98
|
+
self._polling_to_init = defaultdict(bool)
|
|
99
|
+
self._instrument_to_last_price = {}
|
|
100
|
+
self._required_instruments = set()
|
|
101
|
+
self._latest_instruments = set()
|
|
102
|
+
self._subscription_manager = None
|
|
103
|
+
|
|
104
|
+
def set_subscription_manager(self, manager: ISubscriptionManager) -> None:
|
|
105
|
+
self._subscription_manager = manager
|
|
106
|
+
|
|
107
|
+
def start(self):
|
|
108
|
+
"""Start the balance and position polling tasks"""
|
|
109
|
+
channel = self.channel
|
|
110
|
+
if channel is None or not channel.control.is_set():
|
|
111
|
+
return
|
|
112
|
+
if self._subscription_manager is None:
|
|
113
|
+
return
|
|
114
|
+
if self._is_running:
|
|
115
|
+
logger.debug("Account polling is already running")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self._is_running = True
|
|
119
|
+
|
|
120
|
+
if not self.exchange.isSandboxModeEnabled:
|
|
121
|
+
# - start polling tasks
|
|
122
|
+
self._polling_tasks["balance"] = self._loop.submit(
|
|
123
|
+
self._poller("balance", self._update_balance, self.balance_interval)
|
|
124
|
+
)
|
|
125
|
+
self._polling_tasks["position"] = self._loop.submit(
|
|
126
|
+
self._poller("position", self._update_positions, self.position_interval)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# - start initialization tasks
|
|
130
|
+
_init_tasks = [
|
|
131
|
+
self._loop.submit(self._init_spot_positions()), # restore spot positions
|
|
132
|
+
self._loop.submit(self._init_open_orders()), # fetch open orders
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
logger.info("Waiting for account polling tasks to be initialized")
|
|
136
|
+
_waiter = self._loop.submit(self._wait_for_init(*_init_tasks))
|
|
137
|
+
_waiter.result()
|
|
138
|
+
logger.info("Account polling tasks have been initialized")
|
|
139
|
+
|
|
140
|
+
# - start subscription polling task
|
|
141
|
+
self._polling_tasks["subscription"] = self._loop.submit(
|
|
142
|
+
self._poller("subscription", self._update_subscriptions, self.subscription_interval)
|
|
143
|
+
)
|
|
144
|
+
# - subscribe to order executions
|
|
145
|
+
self._polling_tasks["executions"] = self._loop.submit(self._subscribe_executions("executions", channel))
|
|
146
|
+
|
|
147
|
+
def stop(self):
|
|
148
|
+
"""Stop all polling tasks"""
|
|
149
|
+
for task in self._polling_tasks.values():
|
|
150
|
+
if not task.done():
|
|
151
|
+
task.cancel()
|
|
152
|
+
self._polling_tasks.clear()
|
|
153
|
+
self._is_running = False
|
|
154
|
+
|
|
155
|
+
def update_position_price(self, time: dt_64, instrument: Instrument, price: float) -> None:
|
|
156
|
+
self._instrument_to_last_price[instrument] = (time, price)
|
|
157
|
+
super().update_position_price(time, instrument, price)
|
|
158
|
+
|
|
159
|
+
def get_total_capital(self) -> float:
|
|
160
|
+
# sum of balances + market value of all positions on non spot/margin
|
|
161
|
+
_currency_to_value = {c: self._get_currency_value(b.total, c) for c, b in self._balances.items()}
|
|
162
|
+
_positions_value = sum([p.market_value_funds for p in self._positions.values() if p.instrument.is_futures()])
|
|
163
|
+
return sum(_currency_to_value.values()) + _positions_value
|
|
164
|
+
|
|
165
|
+
def _get_instrument_for_currency(self, currency: str) -> Instrument:
|
|
166
|
+
symbol = f"{currency}/{self.base_currency}"
|
|
167
|
+
market = self.exchange.market(symbol)
|
|
168
|
+
exchange_name = self.exchange.name
|
|
169
|
+
assert exchange_name is not None
|
|
170
|
+
return ccxt_symbol_to_instrument(exchange_name, market)
|
|
171
|
+
|
|
172
|
+
def _get_currency_value(self, amount: float, currency: str) -> float:
|
|
173
|
+
if not amount:
|
|
174
|
+
return 0.0
|
|
175
|
+
if currency == self.base_currency:
|
|
176
|
+
return amount
|
|
177
|
+
instr = self._get_instrument_for_currency(currency)
|
|
178
|
+
_dt, _price = self._instrument_to_last_price.get(instr, (None, None))
|
|
179
|
+
if not _dt or not _price:
|
|
180
|
+
logger.warning(f"Price for {instr} not available. Using 0.")
|
|
181
|
+
return 0.0
|
|
182
|
+
return amount * _price
|
|
183
|
+
|
|
184
|
+
async def _poller(
|
|
185
|
+
self,
|
|
186
|
+
name: str,
|
|
187
|
+
coroutine: Callable[[], Awaitable],
|
|
188
|
+
interval: str,
|
|
189
|
+
):
|
|
190
|
+
sleep_time = pd.Timedelta(interval).total_seconds()
|
|
191
|
+
retries = 0
|
|
192
|
+
|
|
193
|
+
while self.channel.control.is_set():
|
|
194
|
+
try:
|
|
195
|
+
await coroutine()
|
|
196
|
+
|
|
197
|
+
if not self._polling_to_init[name]:
|
|
198
|
+
logger.info(f"{name} polling task has been initialized")
|
|
199
|
+
self._polling_to_init[name] = True
|
|
200
|
+
|
|
201
|
+
retries = 0 # Reset retry counter on success
|
|
202
|
+
except CancelledError:
|
|
203
|
+
logger.info(f"{name} listening has been cancelled")
|
|
204
|
+
break
|
|
205
|
+
except ExchangeClosedByUser:
|
|
206
|
+
logger.info(f"{name} listening has been stopped")
|
|
207
|
+
break
|
|
208
|
+
except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
|
|
209
|
+
logger.error(f"Error polling account data: {e}")
|
|
210
|
+
retries += 1
|
|
211
|
+
if retries >= self.max_retries:
|
|
212
|
+
logger.error(f"Max retries ({self.max_retries}) reached. Stopping poller.")
|
|
213
|
+
break
|
|
214
|
+
except Exception as e:
|
|
215
|
+
if not self.channel.control.is_set():
|
|
216
|
+
# If the channel is closed, then ignore all exceptions and exit
|
|
217
|
+
break
|
|
218
|
+
logger.error(f"Unexpected error during account polling: {e}")
|
|
219
|
+
logger.exception(e)
|
|
220
|
+
retries += 1
|
|
221
|
+
if retries >= self.max_retries:
|
|
222
|
+
logger.error(f"Max retries ({self.max_retries}) reached. Stopping poller.")
|
|
223
|
+
break
|
|
224
|
+
finally:
|
|
225
|
+
if not self.channel.control.is_set():
|
|
226
|
+
break
|
|
227
|
+
await asyncio.sleep(min(sleep_time * (2 ** (retries)), 60)) # Exponential backoff capped at 60s
|
|
228
|
+
|
|
229
|
+
logger.debug(f"{name} polling task has been stopped")
|
|
230
|
+
|
|
231
|
+
async def _wait(self, condition: Callable[[], bool], sleep: float = 0.1) -> None:
|
|
232
|
+
while not condition():
|
|
233
|
+
await asyncio.sleep(sleep)
|
|
234
|
+
|
|
235
|
+
async def _wait_for_init(self, *futures: concurrent.futures.Future) -> None:
|
|
236
|
+
await self._wait(lambda: all(self._polling_to_init.values()))
|
|
237
|
+
await self._wait(lambda: all([f.done() for f in futures]))
|
|
238
|
+
|
|
239
|
+
async def _update_subscriptions(self) -> None:
|
|
240
|
+
"""Subscribe to required instruments"""
|
|
241
|
+
assert self._subscription_manager is not None
|
|
242
|
+
await asyncio.sleep(pd.Timedelta(self.subscription_interval).total_seconds())
|
|
243
|
+
|
|
244
|
+
# if required instruments have changed, subscribe to them
|
|
245
|
+
if not self._latest_instruments.issuperset(self._required_instruments):
|
|
246
|
+
await self._subscribe_instruments(list(self._required_instruments))
|
|
247
|
+
self._latest_instruments.update(self._required_instruments)
|
|
248
|
+
|
|
249
|
+
async def _update_balance(self) -> None:
|
|
250
|
+
"""Fetch and update balances from exchange"""
|
|
251
|
+
balances_raw = await self.exchange.fetch_balance()
|
|
252
|
+
balances = ccxt_convert_balance(balances_raw)
|
|
253
|
+
current_balances = self.get_balances()
|
|
254
|
+
|
|
255
|
+
# remove balances that are not there anymore
|
|
256
|
+
_removed_currencies = set(current_balances.keys()) - set(balances.keys())
|
|
257
|
+
for currency in _removed_currencies:
|
|
258
|
+
self.update_balance(currency, 0, 0)
|
|
259
|
+
|
|
260
|
+
# update current balances
|
|
261
|
+
for currency, data in balances.items():
|
|
262
|
+
self.update_balance(currency=currency, total=data.total, locked=data.locked)
|
|
263
|
+
|
|
264
|
+
# update required instruments that we need to subscribe to
|
|
265
|
+
currencies = list(self.get_balances().keys())
|
|
266
|
+
instruments = [
|
|
267
|
+
self._get_instrument_for_currency(c) for c in currencies if c.upper() != self.base_currency.upper()
|
|
268
|
+
]
|
|
269
|
+
self._required_instruments.update(instruments)
|
|
270
|
+
|
|
271
|
+
# fetch tickers for instruments that don't have recent price updates
|
|
272
|
+
await self._fetch_missing_tickers(instruments)
|
|
273
|
+
|
|
274
|
+
async def _update_positions(self) -> None:
|
|
275
|
+
# fetch and update positions from exchange
|
|
276
|
+
ccxt_positions = await self.exchange.fetch_positions()
|
|
277
|
+
positions = ccxt_convert_positions(ccxt_positions, self.exchange.name, self.exchange.markets)
|
|
278
|
+
# update required instruments that we need to subscribe to
|
|
279
|
+
self._required_instruments.update([p.instrument for p in positions])
|
|
280
|
+
# update positions
|
|
281
|
+
_instrument_to_position = {p.instrument: p for p in positions}
|
|
282
|
+
_current_instruments = set(self._positions.keys())
|
|
283
|
+
_new_instruments = set([p.instrument for p in positions])
|
|
284
|
+
# - spot positions should not be updated here, because exchanges don't provide spot positions
|
|
285
|
+
# - so we have to trust deal updates to update spot positions
|
|
286
|
+
_to_remove = {instr for instr in _current_instruments - _new_instruments if instr.is_futures()}
|
|
287
|
+
_to_add = _new_instruments - _current_instruments
|
|
288
|
+
_to_modify = _current_instruments.intersection(_new_instruments)
|
|
289
|
+
_update_positions = [Position(i) for i in _to_remove] + [_instrument_to_position[i] for i in _to_modify]
|
|
290
|
+
# - add new positions
|
|
291
|
+
for i in _to_add:
|
|
292
|
+
self._positions[i] = _instrument_to_position[i]
|
|
293
|
+
# - modify existing positions
|
|
294
|
+
_time = self.time_provider.time()
|
|
295
|
+
for pos in _update_positions:
|
|
296
|
+
self._update_instrument_position(_time, self._positions[pos.instrument], pos)
|
|
297
|
+
|
|
298
|
+
def _update_instrument_position(self, timestamp: dt_64, current_pos: Position, new_pos: Position) -> None:
|
|
299
|
+
instrument = current_pos.instrument
|
|
300
|
+
quantity_diff = new_pos.quantity - current_pos.quantity
|
|
301
|
+
if abs(quantity_diff) < instrument.lot_size:
|
|
302
|
+
return
|
|
303
|
+
_current_price = current_pos.last_update_price
|
|
304
|
+
current_pos.change_position_by(timestamp, quantity_diff, _current_price)
|
|
305
|
+
|
|
306
|
+
def _get_start_time_in_ms(self, days_before: int) -> int:
|
|
307
|
+
return (self.time_provider.time() - days_before * pd.Timedelta("1d")).asm8.item() // 1000000
|
|
308
|
+
|
|
309
|
+
def _is_our_order(self, order: Order) -> bool:
|
|
310
|
+
if order.client_id is None:
|
|
311
|
+
return False
|
|
312
|
+
return order.client_id.startswith("qubx_")
|
|
313
|
+
|
|
314
|
+
def _is_base_currency(self, currency: str) -> bool:
|
|
315
|
+
return currency.upper() == self.base_currency
|
|
316
|
+
|
|
317
|
+
async def _subscribe_instruments(self, instruments: list[Instrument]) -> None:
|
|
318
|
+
assert self._subscription_manager is not None
|
|
319
|
+
|
|
320
|
+
# find missing subscriptions
|
|
321
|
+
_base_sub = self._subscription_manager.get_base_subscription()
|
|
322
|
+
_subscribed_instruments = self._subscription_manager.get_subscribed_instruments(_base_sub)
|
|
323
|
+
_add_instruments = list(set(instruments) - set(_subscribed_instruments))
|
|
324
|
+
|
|
325
|
+
if _add_instruments:
|
|
326
|
+
# subscribe to instruments
|
|
327
|
+
self._subscription_manager.subscribe(_base_sub, _add_instruments)
|
|
328
|
+
self._subscription_manager.commit()
|
|
329
|
+
|
|
330
|
+
async def _fetch_missing_tickers(self, instruments: list[Instrument]) -> None:
|
|
331
|
+
_current_time = self.time_provider.time()
|
|
332
|
+
_fetch_instruments: list[Instrument] = []
|
|
333
|
+
for instr in instruments:
|
|
334
|
+
_dt, _ = self._instrument_to_last_price.get(instr, (None, None))
|
|
335
|
+
if _dt is None or pd.Timedelta(_current_time - _dt) > pd.Timedelta(self.balance_interval):
|
|
336
|
+
_fetch_instruments.append(instr)
|
|
337
|
+
|
|
338
|
+
_symbol_to_instrument = {instr.symbol: instr for instr in instruments}
|
|
339
|
+
if _fetch_instruments:
|
|
340
|
+
logger.debug(f"Fetching missing tickers for {_fetch_instruments}")
|
|
341
|
+
_fetch_symbols = [instrument_to_ccxt_symbol(instr) for instr in _fetch_instruments]
|
|
342
|
+
tickers: dict[str, dict] = await self.exchange.fetch_tickers(_fetch_symbols)
|
|
343
|
+
for symbol, ticker in tickers.items():
|
|
344
|
+
instr = _symbol_to_instrument.get(symbol)
|
|
345
|
+
if instr is not None:
|
|
346
|
+
quote = ccxt_convert_ticker(ticker)
|
|
347
|
+
self.update_position_price(_current_time, instr, quote.mid_price())
|
|
348
|
+
|
|
349
|
+
async def _init_spot_positions(self) -> None:
|
|
350
|
+
# - wait for balance to be initialized
|
|
351
|
+
await self._wait(lambda: self._polling_to_init["balance"])
|
|
352
|
+
logger.debug("Restoring spot positions ...")
|
|
353
|
+
|
|
354
|
+
# - get nonzero balances
|
|
355
|
+
_nonzero_balances = {
|
|
356
|
+
c: b.total for c, b in self._balances.items() if b.total > 0 and not self._is_base_currency(c)
|
|
357
|
+
}
|
|
358
|
+
_positions = []
|
|
359
|
+
|
|
360
|
+
async def _restore_pos(currency: str, balance: float) -> None:
|
|
361
|
+
try:
|
|
362
|
+
_instrument = self._get_instrument_for_currency(currency)
|
|
363
|
+
# - get latest order for instrument and check client id
|
|
364
|
+
_latest_orders = await self._fetch_orders(_instrument, limit=1)
|
|
365
|
+
if not _latest_orders:
|
|
366
|
+
return
|
|
367
|
+
_latest_order = list(_latest_orders.values())[-1]
|
|
368
|
+
if self._is_our_order(_latest_order):
|
|
369
|
+
# - if it's our order, then we fetch the deals and restore position
|
|
370
|
+
_deals = await self._fetch_deals(_instrument, self.max_position_restore_days)
|
|
371
|
+
_position = ccxt_restore_position_from_deals(Position(_instrument), balance, _deals)
|
|
372
|
+
_positions.append(_position)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.warning(f"Error restoring position for {currency}: {e}")
|
|
375
|
+
|
|
376
|
+
# - restore positions
|
|
377
|
+
await asyncio.gather(*[_restore_pos(c, b) for c, b in _nonzero_balances.items()])
|
|
378
|
+
|
|
379
|
+
# - attach positions
|
|
380
|
+
if _positions:
|
|
381
|
+
self.attach_positions(*_positions)
|
|
382
|
+
logger.debug("Restored positions ->")
|
|
383
|
+
for p in _positions:
|
|
384
|
+
logger.debug(f" :: {p}")
|
|
385
|
+
|
|
386
|
+
async def _init_open_orders(self) -> None:
|
|
387
|
+
# wait for balances and positions to be initialized
|
|
388
|
+
await self._wait(lambda: all([self._polling_to_init[task] for task in ["balance", "position"]]))
|
|
389
|
+
logger.debug("Fetching open orders ...")
|
|
390
|
+
|
|
391
|
+
# in order to minimize order requests we only fetch open orders for instruments that we have positions in
|
|
392
|
+
_nonzero_balances = {
|
|
393
|
+
c: b.total for c, b in self._balances.items() if b.total > 0 and not self._is_base_currency(c)
|
|
394
|
+
}
|
|
395
|
+
_balance_instruments = [self._get_instrument_for_currency(c) for c in _nonzero_balances.keys()]
|
|
396
|
+
_position_instruments = list(self._positions.keys())
|
|
397
|
+
_instruments = list(set(_balance_instruments + _position_instruments))
|
|
398
|
+
|
|
399
|
+
_open_orders: dict[str, Order] = {}
|
|
400
|
+
|
|
401
|
+
async def _add_open_orders(instrument: Instrument) -> None:
|
|
402
|
+
try:
|
|
403
|
+
_orders = await self._fetch_orders(instrument, is_open=True)
|
|
404
|
+
_open_orders.update(_orders)
|
|
405
|
+
except Exception as e:
|
|
406
|
+
logger.warning(f"Error fetching open orders for {instrument}: {e}")
|
|
407
|
+
|
|
408
|
+
await asyncio.gather(*[_add_open_orders(i) for i in _instruments])
|
|
409
|
+
|
|
410
|
+
self.add_active_orders(_open_orders)
|
|
411
|
+
|
|
412
|
+
logger.debug(f"Found {len(_open_orders)} open orders ->")
|
|
413
|
+
_instr_to_open_orders: dict[Instrument, list[Order]] = defaultdict(list)
|
|
414
|
+
for od in _open_orders.values():
|
|
415
|
+
_instr_to_open_orders[od.instrument].append(od)
|
|
416
|
+
for instr, orders in _instr_to_open_orders.items():
|
|
417
|
+
logger.debug(f" :: {instr} ->")
|
|
418
|
+
for order in orders:
|
|
419
|
+
logger.debug(f" :: {order.side} {order.quantity} @ {order.price} ({order.status})")
|
|
420
|
+
|
|
421
|
+
async def _fetch_orders(
|
|
422
|
+
self, instrument: Instrument, days_before: int = 30, limit: int | None = None, is_open: bool = False
|
|
423
|
+
) -> dict[str, Order]:
|
|
424
|
+
_start_ms = self._get_start_time_in_ms(days_before) if limit is None else None
|
|
425
|
+
_ccxt_symbol = instrument_to_ccxt_symbol(instrument)
|
|
426
|
+
_fetcher = self.exchange.fetch_open_orders if is_open else self.exchange.fetch_orders
|
|
427
|
+
_raw_orders = await _fetcher(_ccxt_symbol, since=_start_ms, limit=limit)
|
|
428
|
+
_orders = [ccxt_convert_order_info(instrument, o) for o in _raw_orders]
|
|
429
|
+
_id_to_order = {o.id: o for o in _orders}
|
|
430
|
+
return dict(sorted(_id_to_order.items(), key=lambda x: x[1].time, reverse=False))
|
|
431
|
+
|
|
432
|
+
async def _fetch_deals(self, instrument: Instrument, days_before: int = 30) -> list[Deal]:
|
|
433
|
+
_start_ms = self._get_start_time_in_ms(days_before)
|
|
434
|
+
_ccxt_symbol = instrument_to_ccxt_symbol(instrument)
|
|
435
|
+
deals_data = await self.exchange.fetch_my_trades(_ccxt_symbol, since=_start_ms)
|
|
436
|
+
deals: list[Deal] = [ccxt_convert_deal_info(o) for o in deals_data]
|
|
437
|
+
return sorted(deals, key=lambda x: x.time) if deals else []
|
|
438
|
+
|
|
439
|
+
async def _listen_to_stream(
|
|
440
|
+
self,
|
|
441
|
+
subscriber: Callable[[], Awaitable[None]],
|
|
442
|
+
exchange: cxp.Exchange,
|
|
443
|
+
channel: CtrlChannel,
|
|
444
|
+
name: str,
|
|
445
|
+
):
|
|
446
|
+
logger.info(f"Listening to {name}")
|
|
447
|
+
n_retry = 0
|
|
448
|
+
while channel.control.is_set():
|
|
449
|
+
try:
|
|
450
|
+
await subscriber()
|
|
451
|
+
n_retry = 0
|
|
452
|
+
except CcxtSymbolNotRecognized:
|
|
453
|
+
continue
|
|
454
|
+
except CancelledError:
|
|
455
|
+
break
|
|
456
|
+
except ExchangeClosedByUser:
|
|
457
|
+
# - we closed connection so just stop it
|
|
458
|
+
logger.info(f"{name} listening has been stopped")
|
|
459
|
+
break
|
|
460
|
+
except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
|
|
461
|
+
logger.error(f"Error in {name} : {e}")
|
|
462
|
+
await asyncio.sleep(1)
|
|
463
|
+
continue
|
|
464
|
+
except Exception as e:
|
|
465
|
+
if not channel.control.is_set():
|
|
466
|
+
# If the channel is closed, then ignore all exceptions and exit
|
|
467
|
+
break
|
|
468
|
+
logger.error(f"exception in {name} : {e}")
|
|
469
|
+
logger.exception(e)
|
|
470
|
+
n_retry += 1
|
|
471
|
+
if n_retry >= self.max_retries:
|
|
472
|
+
logger.error(f"Max retries reached for {name}. Closing connection.")
|
|
473
|
+
del exchange
|
|
474
|
+
break
|
|
475
|
+
await asyncio.sleep(min(2**n_retry, 60)) # Exponential backoff with a cap at 60 seconds
|
|
476
|
+
|
|
477
|
+
async def _subscribe_executions(self, name: str, channel: CtrlChannel):
|
|
478
|
+
_symbol_to_instrument = {}
|
|
479
|
+
|
|
480
|
+
async def _watch_executions():
|
|
481
|
+
exec = await self.exchange.watch_orders()
|
|
482
|
+
for report in exec:
|
|
483
|
+
instrument = ccxt_find_instrument(report["symbol"], self.exchange, _symbol_to_instrument)
|
|
484
|
+
order = ccxt_convert_order_info(instrument, report)
|
|
485
|
+
deals = ccxt_extract_deals_from_exec(report)
|
|
486
|
+
channel.send((instrument, "order", order, False))
|
|
487
|
+
if deals:
|
|
488
|
+
channel.send((instrument, "deals", deals, False))
|
|
489
|
+
|
|
490
|
+
await self._listen_to_stream(
|
|
491
|
+
subscriber=_watch_executions,
|
|
492
|
+
exchange=self.exchange,
|
|
493
|
+
channel=channel,
|
|
494
|
+
name=name,
|
|
495
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import ccxt
|
|
5
|
+
import ccxt.pro as cxp
|
|
6
|
+
from ccxt.base.errors import ExchangeError
|
|
7
|
+
from qubx import logger
|
|
8
|
+
from qubx.core.basics import (
|
|
9
|
+
CtrlChannel,
|
|
10
|
+
Instrument,
|
|
11
|
+
Order,
|
|
12
|
+
Position,
|
|
13
|
+
)
|
|
14
|
+
from qubx.core.interfaces import (
|
|
15
|
+
IAccountProcessor,
|
|
16
|
+
IBroker,
|
|
17
|
+
ITimeProvider,
|
|
18
|
+
)
|
|
19
|
+
from qubx.utils.misc import AsyncThreadLoop
|
|
20
|
+
|
|
21
|
+
from .utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CcxtBroker(IBroker):
|
|
25
|
+
_exchange: cxp.Exchange
|
|
26
|
+
|
|
27
|
+
_positions: dict[Instrument, Position]
|
|
28
|
+
_loop: AsyncThreadLoop
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
exchange: cxp.Exchange,
|
|
33
|
+
channel: CtrlChannel,
|
|
34
|
+
time_provider: ITimeProvider,
|
|
35
|
+
account: IAccountProcessor,
|
|
36
|
+
):
|
|
37
|
+
self._exchange = exchange
|
|
38
|
+
self.ccxt_exchange_id = str(exchange.name)
|
|
39
|
+
self.channel = channel
|
|
40
|
+
self.time_provider = time_provider
|
|
41
|
+
self.account = account
|
|
42
|
+
self._loop = AsyncThreadLoop(exchange.asyncio_loop)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_simulated_trading(self) -> bool:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def send_order(
|
|
49
|
+
self,
|
|
50
|
+
instrument: Instrument,
|
|
51
|
+
order_side: str,
|
|
52
|
+
order_type: str,
|
|
53
|
+
amount: float,
|
|
54
|
+
price: float | None = None,
|
|
55
|
+
client_id: str | None = None,
|
|
56
|
+
time_in_force: str = "gtc",
|
|
57
|
+
) -> Order:
|
|
58
|
+
params = {}
|
|
59
|
+
|
|
60
|
+
if order_type == "limit":
|
|
61
|
+
params["timeInForce"] = time_in_force.upper()
|
|
62
|
+
if price is None:
|
|
63
|
+
raise ValueError("Price must be specified for limit order")
|
|
64
|
+
|
|
65
|
+
if client_id:
|
|
66
|
+
params["newClientOrderId"] = client_id
|
|
67
|
+
|
|
68
|
+
if instrument.is_futures():
|
|
69
|
+
params["type"] = "swap"
|
|
70
|
+
|
|
71
|
+
ccxt_symbol = instrument_to_ccxt_symbol(instrument)
|
|
72
|
+
|
|
73
|
+
r: dict[str, Any] | None = None
|
|
74
|
+
try:
|
|
75
|
+
r = self._loop.submit(
|
|
76
|
+
self._exchange.create_order(
|
|
77
|
+
symbol=ccxt_symbol,
|
|
78
|
+
type=order_type, # type: ignore
|
|
79
|
+
side=order_side, # type: ignore
|
|
80
|
+
amount=amount,
|
|
81
|
+
price=price,
|
|
82
|
+
params=params,
|
|
83
|
+
)
|
|
84
|
+
).result()
|
|
85
|
+
except ccxt.BadRequest as exc:
|
|
86
|
+
logger.error(
|
|
87
|
+
f"(::send_order) BAD REQUEST for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
|
|
88
|
+
)
|
|
89
|
+
raise exc
|
|
90
|
+
except Exception as err:
|
|
91
|
+
logger.error(f"(::send_order) {order_side} {amount} {order_type} for {instrument.symbol} exception : {err}")
|
|
92
|
+
logger.error(traceback.format_exc())
|
|
93
|
+
raise err
|
|
94
|
+
|
|
95
|
+
if r is None:
|
|
96
|
+
msg = "(::send_order) No response from exchange"
|
|
97
|
+
logger.error(msg)
|
|
98
|
+
raise ExchangeError(msg)
|
|
99
|
+
|
|
100
|
+
order = ccxt_convert_order_info(instrument, r)
|
|
101
|
+
logger.info(f"New order {order}")
|
|
102
|
+
return order
|
|
103
|
+
|
|
104
|
+
def cancel_order(self, order_id: str) -> Order | None:
|
|
105
|
+
order = None
|
|
106
|
+
orders = self.account.get_orders()
|
|
107
|
+
if order_id in orders:
|
|
108
|
+
order = orders[order_id]
|
|
109
|
+
try:
|
|
110
|
+
logger.info(f"Canceling order {order_id} ...")
|
|
111
|
+
result = self._loop.submit(
|
|
112
|
+
self._exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(order.instrument))
|
|
113
|
+
).result()
|
|
114
|
+
logger.debug(f"Cancel order result: {result}")
|
|
115
|
+
return order
|
|
116
|
+
except Exception as err:
|
|
117
|
+
logger.error(f"Canceling [{order}] exception : {err}")
|
|
118
|
+
logger.error(traceback.format_exc())
|
|
119
|
+
raise err
|
|
120
|
+
return order
|
|
121
|
+
|
|
122
|
+
def cancel_orders(self, instrument: Instrument) -> None:
|
|
123
|
+
raise NotImplementedError("Not implemented yet")
|
|
124
|
+
|
|
125
|
+
def update_order(self, order_id: str, price: float | None = None, amount: float | None = None) -> Order:
|
|
126
|
+
raise NotImplementedError("Not implemented yet")
|
|
127
|
+
|
|
128
|
+
def exchange(self) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Return the name of the exchange this broker is connected to.
|
|
131
|
+
"""
|
|
132
|
+
return self.ccxt_exchange_id.upper()
|