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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from threading import Thread
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import ccxt.pro as cxp
|
|
6
|
+
|
|
7
|
+
from .customizations import BinancePortfolioMargin, BinanceQV, BinanceQVUSDM
|
|
8
|
+
|
|
9
|
+
EXCHANGE_ALIASES = {
|
|
10
|
+
"binance": "binanceqv",
|
|
11
|
+
"binance.um": "binanceqv_usdm",
|
|
12
|
+
"binance.cm": "binancecoinm",
|
|
13
|
+
"binance.pm": "binancepm",
|
|
14
|
+
"kraken.f": "krakenfutures",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
cxp.binanceqv = BinanceQV # type: ignore
|
|
18
|
+
cxp.binanceqv_usdm = BinanceQVUSDM # type: ignore
|
|
19
|
+
cxp.binancepm = BinancePortfolioMargin # type: ignore
|
|
20
|
+
|
|
21
|
+
cxp.exchanges.append("binanceqv")
|
|
22
|
+
cxp.exchanges.append("binanceqv_usdm")
|
|
23
|
+
cxp.exchanges.append("binancepm")
|
|
24
|
+
cxp.exchanges.append("binancepm_usdm")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_ccxt_exchange(
|
|
28
|
+
exchange: str,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
secret: str | None = None,
|
|
31
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
32
|
+
use_testnet: bool = False,
|
|
33
|
+
**kwargs,
|
|
34
|
+
) -> cxp.Exchange:
|
|
35
|
+
"""
|
|
36
|
+
Get a ccxt exchange object with the given api_key and api_secret.
|
|
37
|
+
Parameters:
|
|
38
|
+
exchange (str): The exchange name.
|
|
39
|
+
api_key (str, optional): The API key. Default is None.
|
|
40
|
+
api_secret (str, optional): The API secret. Default is None.
|
|
41
|
+
Returns:
|
|
42
|
+
ccxt.Exchange: The ccxt exchange object.
|
|
43
|
+
"""
|
|
44
|
+
_exchange = exchange.lower()
|
|
45
|
+
_exchange = EXCHANGE_ALIASES.get(_exchange, _exchange)
|
|
46
|
+
|
|
47
|
+
if _exchange not in cxp.exchanges:
|
|
48
|
+
raise ValueError(f"Exchange {exchange} is not supported by ccxt.")
|
|
49
|
+
|
|
50
|
+
options: dict[str, Any] = {"name": exchange}
|
|
51
|
+
|
|
52
|
+
if loop is not None:
|
|
53
|
+
options["asyncio_loop"] = loop
|
|
54
|
+
else:
|
|
55
|
+
loop = asyncio.new_event_loop()
|
|
56
|
+
thread = Thread(target=loop.run_forever, daemon=True)
|
|
57
|
+
thread.start()
|
|
58
|
+
options["thread_asyncio_loop"] = thread
|
|
59
|
+
options["asyncio_loop"] = loop
|
|
60
|
+
|
|
61
|
+
api_key, secret = _get_api_credentials(api_key, secret, kwargs)
|
|
62
|
+
if api_key and secret:
|
|
63
|
+
options["apiKey"] = api_key
|
|
64
|
+
options["secret"] = secret
|
|
65
|
+
|
|
66
|
+
ccxt_exchange = getattr(cxp, _exchange)(options | kwargs)
|
|
67
|
+
|
|
68
|
+
if use_testnet:
|
|
69
|
+
ccxt_exchange.set_sandbox_mode(True)
|
|
70
|
+
|
|
71
|
+
return ccxt_exchange
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_api_credentials(
|
|
75
|
+
api_key: str | None, secret: str | None, kwargs: dict[str, Any]
|
|
76
|
+
) -> tuple[str | None, str | None]:
|
|
77
|
+
if api_key is None:
|
|
78
|
+
if "apiKey" in kwargs:
|
|
79
|
+
api_key = kwargs.pop("apiKey")
|
|
80
|
+
elif "key" in kwargs:
|
|
81
|
+
api_key = kwargs.pop("key")
|
|
82
|
+
elif "API_KEY" in kwargs:
|
|
83
|
+
api_key = kwargs.get("API_KEY")
|
|
84
|
+
if secret is None:
|
|
85
|
+
if "secret" in kwargs:
|
|
86
|
+
secret = kwargs.pop("secret")
|
|
87
|
+
elif "apiSecret" in kwargs:
|
|
88
|
+
secret = kwargs.pop("apiSecret")
|
|
89
|
+
elif "API_SECRET" in kwargs:
|
|
90
|
+
secret = kwargs.get("API_SECRET")
|
|
91
|
+
elif "SECRET" in kwargs:
|
|
92
|
+
secret = kwargs.get("SECRET")
|
|
93
|
+
return api_key, secret
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
import ccxt.pro as cxp
|
|
8
|
+
from ccxt import BadSymbol
|
|
9
|
+
from qubx import logger
|
|
10
|
+
from qubx.core.basics import (
|
|
11
|
+
AssetBalance,
|
|
12
|
+
Deal,
|
|
13
|
+
FundingRate,
|
|
14
|
+
Instrument,
|
|
15
|
+
Liquidation,
|
|
16
|
+
Order,
|
|
17
|
+
Position,
|
|
18
|
+
)
|
|
19
|
+
from qubx.core.series import OrderBook, Quote, Trade, time_as_nsec
|
|
20
|
+
from qubx.utils.marketdata.ccxt import (
|
|
21
|
+
ccxt_symbol_to_instrument,
|
|
22
|
+
)
|
|
23
|
+
from qubx.utils.orderbook import build_orderbook_snapshots
|
|
24
|
+
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
CcxtLiquidationParsingError,
|
|
27
|
+
CcxtSymbolNotRecognized,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
EXCH_SYMBOL_PATTERN = re.compile(r"(?P<base>[^/]+)/(?P<quote>[^:]+)(?::(?P<margin>.+))?")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ccxt_convert_order_info(instrument: Instrument, raw: dict[str, Any]) -> Order:
|
|
34
|
+
"""
|
|
35
|
+
Convert CCXT excution record to Order object
|
|
36
|
+
"""
|
|
37
|
+
ri = raw["info"]
|
|
38
|
+
amnt = float(ri.get("origQty", raw.get("amount")))
|
|
39
|
+
price = raw["price"]
|
|
40
|
+
status = raw["status"]
|
|
41
|
+
side = raw["side"].upper()
|
|
42
|
+
_type = ri.get("type", raw.get("type")).upper()
|
|
43
|
+
if status == "open":
|
|
44
|
+
status = ri.get("status", status) # for filled / part_filled ?
|
|
45
|
+
|
|
46
|
+
return Order(
|
|
47
|
+
id=raw["id"],
|
|
48
|
+
type=_type,
|
|
49
|
+
instrument=instrument,
|
|
50
|
+
time=pd.Timestamp(raw["timestamp"], unit="ms"), # type: ignore
|
|
51
|
+
quantity=amnt,
|
|
52
|
+
price=float(price) if price is not None else 0.0,
|
|
53
|
+
side=side,
|
|
54
|
+
status=status.upper(),
|
|
55
|
+
time_in_force=raw["timeInForce"],
|
|
56
|
+
client_id=raw["clientOrderId"],
|
|
57
|
+
cost=float(raw["cost"] or 0), # cost can be None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ccxt_convert_deal_info(raw: Dict[str, Any]) -> Deal:
|
|
62
|
+
fee_amount = None
|
|
63
|
+
fee_currency = None
|
|
64
|
+
if "fee" in raw:
|
|
65
|
+
fee_amount = float(raw["fee"]["cost"])
|
|
66
|
+
fee_currency = raw["fee"]["currency"]
|
|
67
|
+
return Deal(
|
|
68
|
+
id=raw["id"],
|
|
69
|
+
order_id=raw["order"],
|
|
70
|
+
time=pd.Timestamp(raw["timestamp"], unit="ms"), # type: ignore
|
|
71
|
+
amount=float(raw["amount"]) * (-1 if raw["side"] == "sell" else +1),
|
|
72
|
+
price=float(raw["price"]),
|
|
73
|
+
aggressive=raw["takerOrMaker"] == "taker",
|
|
74
|
+
fee_amount=fee_amount,
|
|
75
|
+
fee_currency=fee_currency,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ccxt_extract_deals_from_exec(report: Dict[str, Any]) -> List[Deal]:
|
|
80
|
+
"""
|
|
81
|
+
Small helper for extracting deals (trades) from CCXT execution report
|
|
82
|
+
"""
|
|
83
|
+
deals = list()
|
|
84
|
+
if trades := report.get("trades"):
|
|
85
|
+
for t in trades:
|
|
86
|
+
deals.append(ccxt_convert_deal_info(t))
|
|
87
|
+
return deals
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ccxt_restore_position_from_deals(
|
|
91
|
+
pos: Position, current_volume: float, deals: List[Deal], reserved_amount: float = 0.0
|
|
92
|
+
) -> Position:
|
|
93
|
+
if current_volume != 0:
|
|
94
|
+
instr = pos.instrument
|
|
95
|
+
_last_deals = []
|
|
96
|
+
|
|
97
|
+
# - try to find last deals that led to this position
|
|
98
|
+
for d in sorted(deals, key=lambda x: x.time, reverse=True):
|
|
99
|
+
current_volume -= d.amount
|
|
100
|
+
# - spot case when fees may be deducted from the base coin
|
|
101
|
+
# that may decrease total amount
|
|
102
|
+
if d.fee_amount is not None:
|
|
103
|
+
if instr.base == d.fee_currency:
|
|
104
|
+
current_volume += d.fee_amount
|
|
105
|
+
# print(d.amount, current_volume)
|
|
106
|
+
_last_deals.insert(0, d)
|
|
107
|
+
|
|
108
|
+
# - take in account reserves
|
|
109
|
+
if abs(current_volume) - abs(reserved_amount) < instr.lot_size:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
# - reset to 0
|
|
113
|
+
pos.reset()
|
|
114
|
+
|
|
115
|
+
if abs(current_volume) - abs(reserved_amount) > instr.lot_size:
|
|
116
|
+
# - - - TODO - - - !!!!
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"Couldn't restore full deals history for {instr.symbol} symbol. Qubx will use zero position !"
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
fees_in_base = 0.0
|
|
122
|
+
for d in _last_deals:
|
|
123
|
+
pos.update_position_by_deal(d)
|
|
124
|
+
if d.fee_amount is not None:
|
|
125
|
+
if instr.base == d.fee_currency:
|
|
126
|
+
fees_in_base += d.fee_amount
|
|
127
|
+
# - we round fees up in case of fees in base currency
|
|
128
|
+
pos.quantity -= pos.instrument.round_size_up(fees_in_base)
|
|
129
|
+
return pos
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ccxt_convert_trade(trade: dict[str, Any]) -> Trade:
|
|
133
|
+
t_ns = trade["timestamp"] * 1_000_000 # this is trade time
|
|
134
|
+
info, price, amnt = trade["info"], trade["price"], trade["amount"]
|
|
135
|
+
m = info["m"]
|
|
136
|
+
return Trade(t_ns, price, amnt, int(not m), int(trade["id"]))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ccxt_convert_positions(
|
|
140
|
+
pos_infos: list[dict], ccxt_exchange_name: str, markets: dict[str, dict[str, Any]]
|
|
141
|
+
) -> list[Position]:
|
|
142
|
+
positions = []
|
|
143
|
+
for info in pos_infos:
|
|
144
|
+
symbol = info["symbol"]
|
|
145
|
+
if symbol not in markets:
|
|
146
|
+
logger.warning(f"Could not find symbol {symbol}, skipping position...")
|
|
147
|
+
continue
|
|
148
|
+
instr = ccxt_symbol_to_instrument(
|
|
149
|
+
ccxt_exchange_name,
|
|
150
|
+
markets[symbol],
|
|
151
|
+
)
|
|
152
|
+
pos = Position(
|
|
153
|
+
instrument=instr,
|
|
154
|
+
quantity=info["contracts"] * (-1 if info["side"] == "short" else 1),
|
|
155
|
+
pos_average_price=info["entryPrice"],
|
|
156
|
+
)
|
|
157
|
+
pos.update_market_price(pd.Timestamp(info["timestamp"], unit="ms").asm8, info["markPrice"], 1)
|
|
158
|
+
positions.append(pos)
|
|
159
|
+
return positions
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ccxt_convert_orderbook(
|
|
163
|
+
ob: dict, instr: Instrument, levels: int = 50, tick_size_pct: float = 0.01, sizes_in_quoted: bool = False
|
|
164
|
+
) -> OrderBook | None:
|
|
165
|
+
"""
|
|
166
|
+
Convert a ccxt order book to an OrderBook object with a fixed tick size percentage.
|
|
167
|
+
Parameters:
|
|
168
|
+
ob (dict): The order book dictionary from ccxt.
|
|
169
|
+
instr (Instrument): The instrument object containing market-specific details.
|
|
170
|
+
levels (int, optional): The number of levels to include in the order book. Default is 50.
|
|
171
|
+
tick_size_pct (float, optional): The tick size percentage. Default is 0.01%.
|
|
172
|
+
sizes_in_quoted (bool, optional): Whether the size is in the quoted currency. Default is False.
|
|
173
|
+
Returns:
|
|
174
|
+
OrderBook: The converted OrderBook object.
|
|
175
|
+
"""
|
|
176
|
+
_dt = pd.Timestamp(ob["datetime"]).replace(tzinfo=None).asm8
|
|
177
|
+
_prev_dt = _dt - pd.Timedelta("1ms").asm8
|
|
178
|
+
|
|
179
|
+
updates = [
|
|
180
|
+
*[(_prev_dt, update[0], update[1], True) for update in ob["bids"]],
|
|
181
|
+
*[(_prev_dt, update[0], update[1], False) for update in ob["asks"]],
|
|
182
|
+
]
|
|
183
|
+
# add an artificial update to trigger the snapshot building
|
|
184
|
+
updates.append((_dt, 0, 0, True))
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
snapshots = build_orderbook_snapshots(
|
|
188
|
+
updates,
|
|
189
|
+
levels=levels,
|
|
190
|
+
tick_size_pct=tick_size_pct,
|
|
191
|
+
min_tick_size=instr.tick_size,
|
|
192
|
+
min_size_step=instr.lot_size,
|
|
193
|
+
sizes_in_quoted=sizes_in_quoted,
|
|
194
|
+
)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.error(f"Failed to build order book snapshots: {e}")
|
|
197
|
+
snapshots = None
|
|
198
|
+
|
|
199
|
+
if not snapshots:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
(dt, _bids, _asks, top_bid, top_ask, tick_size) = snapshots[-1]
|
|
203
|
+
bids = np.array([s for _, s in _bids[::-1]])
|
|
204
|
+
asks = np.array([s for _, s in _asks])
|
|
205
|
+
|
|
206
|
+
return OrderBook(
|
|
207
|
+
time=time_as_nsec(dt),
|
|
208
|
+
top_bid=top_bid,
|
|
209
|
+
top_ask=top_ask,
|
|
210
|
+
tick_size=tick_size,
|
|
211
|
+
bids=bids,
|
|
212
|
+
asks=asks,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def ccxt_convert_liquidation(liq: dict[str, Any]) -> Liquidation:
|
|
217
|
+
try:
|
|
218
|
+
_dt = pd.Timestamp(liq["datetime"]).replace(tzinfo=None).asm8
|
|
219
|
+
return Liquidation(
|
|
220
|
+
time=_dt,
|
|
221
|
+
price=liq["price"],
|
|
222
|
+
quantity=liq["contracts"],
|
|
223
|
+
side=(1 if liq["info"]["S"] == "BUY" else -1),
|
|
224
|
+
)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
raise CcxtLiquidationParsingError(f"Failed to parse liquidation: {e}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ccxt_convert_ticker(ticker: dict[str, Any]) -> Quote:
|
|
230
|
+
"""
|
|
231
|
+
Convert a ccxt ticker to a Quote object.
|
|
232
|
+
Parameters:
|
|
233
|
+
ticker (dict): The ticker dictionary from ccxt.
|
|
234
|
+
instr (Instrument): The instrument object containing market-specific details.
|
|
235
|
+
Returns:
|
|
236
|
+
Quote: The converted Quote object.
|
|
237
|
+
"""
|
|
238
|
+
return Quote(
|
|
239
|
+
time=pd.Timestamp(ticker["datetime"]).replace(tzinfo=None).asm8,
|
|
240
|
+
bid=ticker["bid"],
|
|
241
|
+
ask=ticker["ask"],
|
|
242
|
+
bid_size=ticker["bidVolume"],
|
|
243
|
+
ask_size=ticker["askVolume"],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def ccxt_convert_funding_rate(info: dict[str, Any]) -> FundingRate:
|
|
248
|
+
return FundingRate(
|
|
249
|
+
time=pd.Timestamp(info["timestamp"], unit="ms").asm8,
|
|
250
|
+
rate=info["fundingRate"],
|
|
251
|
+
interval=info["interval"],
|
|
252
|
+
next_funding_time=pd.Timestamp(info["nextFundingTime"], unit="ms").asm8,
|
|
253
|
+
mark_price=info.get("markPrice"),
|
|
254
|
+
index_price=info.get("indexPrice"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def ccxt_convert_balance(d: dict[str, Any]) -> dict[str, AssetBalance]:
|
|
259
|
+
balances = {}
|
|
260
|
+
for currency, data in d["total"].items():
|
|
261
|
+
if not data:
|
|
262
|
+
continue
|
|
263
|
+
total = float(d["total"].get(currency, 0) or 0)
|
|
264
|
+
locked = float(d["used"].get(currency, 0) or 0)
|
|
265
|
+
balances[currency] = AssetBalance(free=total - locked, locked=locked, total=total)
|
|
266
|
+
return balances
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def find_instrument_for_exch_symbol(exch_symbol: str, symbol_to_instrument: Dict[str, Instrument]) -> Instrument:
|
|
270
|
+
match = EXCH_SYMBOL_PATTERN.match(exch_symbol)
|
|
271
|
+
if not match:
|
|
272
|
+
raise CcxtSymbolNotRecognized(f"Invalid exchange symbol {exch_symbol}")
|
|
273
|
+
base = match.group("base")
|
|
274
|
+
quote = match.group("quote")
|
|
275
|
+
symbol = f"{base}{quote}"
|
|
276
|
+
if symbol not in symbol_to_instrument:
|
|
277
|
+
raise CcxtSymbolNotRecognized(f"Unknown symbol {symbol}")
|
|
278
|
+
return symbol_to_instrument[symbol]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def instrument_to_ccxt_symbol(instr: Instrument) -> str:
|
|
282
|
+
return f"{instr.base}/{instr.quote}:{instr.settle}" if instr.is_futures() else f"{instr.base}/{instr.quote}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def ccxt_find_instrument(
|
|
286
|
+
symbol: str, exchange: cxp.Exchange, symbol_to_instrument: Dict[str, Instrument] | None = None
|
|
287
|
+
) -> Instrument:
|
|
288
|
+
instrument = None
|
|
289
|
+
if symbol_to_instrument is not None:
|
|
290
|
+
instrument = symbol_to_instrument.get(symbol)
|
|
291
|
+
if instrument is not None:
|
|
292
|
+
return instrument
|
|
293
|
+
try:
|
|
294
|
+
instrument = find_instrument_for_exch_symbol(symbol, symbol_to_instrument)
|
|
295
|
+
except CcxtSymbolNotRecognized:
|
|
296
|
+
pass
|
|
297
|
+
if instrument is None:
|
|
298
|
+
try:
|
|
299
|
+
symbol_info = exchange.market(symbol)
|
|
300
|
+
except BadSymbol:
|
|
301
|
+
raise CcxtSymbolNotRecognized(f"Unknown symbol {symbol}")
|
|
302
|
+
exchange_name = exchange.name
|
|
303
|
+
assert exchange_name is not None
|
|
304
|
+
instrument = ccxt_symbol_to_instrument(exchange_name, symbol_info)
|
|
305
|
+
if symbol_to_instrument is not None and symbol not in symbol_to_instrument:
|
|
306
|
+
symbol_to_instrument[symbol] = instrument
|
|
307
|
+
return instrument
|
qubx/core/__init__.py
ADDED
|
File without changes
|
qubx/core/account.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from qubx import logger
|
|
6
|
+
from qubx.core.basics import (
|
|
7
|
+
ZERO_COSTS,
|
|
8
|
+
AssetBalance,
|
|
9
|
+
Deal,
|
|
10
|
+
Instrument,
|
|
11
|
+
ITimeProvider,
|
|
12
|
+
Order,
|
|
13
|
+
Position,
|
|
14
|
+
TransactionCostsCalculator,
|
|
15
|
+
dt_64,
|
|
16
|
+
)
|
|
17
|
+
from qubx.core.interfaces import IAccountProcessor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BasicAccountProcessor(IAccountProcessor):
|
|
21
|
+
account_id: str
|
|
22
|
+
time_provider: ITimeProvider
|
|
23
|
+
base_currency: str
|
|
24
|
+
commissions: str
|
|
25
|
+
_tcc: TransactionCostsCalculator
|
|
26
|
+
_balances: dict[str, AssetBalance]
|
|
27
|
+
_active_orders: dict[str, Order]
|
|
28
|
+
_processed_trades: dict[str, list[str | int]]
|
|
29
|
+
_positions: dict[Instrument, Position]
|
|
30
|
+
_locked_capital_by_order: dict[str, float]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
account_id: str,
|
|
35
|
+
time_provider: ITimeProvider,
|
|
36
|
+
base_currency: str,
|
|
37
|
+
tcc: TransactionCostsCalculator = ZERO_COSTS,
|
|
38
|
+
initial_capital: float = 100_000,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.account_id = account_id
|
|
41
|
+
self.time_provider = time_provider
|
|
42
|
+
self.base_currency = base_currency.upper()
|
|
43
|
+
self._tcc = tcc
|
|
44
|
+
self._processed_trades = defaultdict(list)
|
|
45
|
+
self._active_orders = dict()
|
|
46
|
+
self._positions = {}
|
|
47
|
+
self._locked_capital_by_order = dict()
|
|
48
|
+
self._balances = defaultdict(lambda: AssetBalance())
|
|
49
|
+
self._balances[self.base_currency] += initial_capital
|
|
50
|
+
|
|
51
|
+
def get_base_currency(self) -> str:
|
|
52
|
+
return self.base_currency
|
|
53
|
+
|
|
54
|
+
########################################################
|
|
55
|
+
# Balance and position information
|
|
56
|
+
########################################################
|
|
57
|
+
def get_capital(self) -> float:
|
|
58
|
+
return self.get_available_margin()
|
|
59
|
+
|
|
60
|
+
def get_total_capital(self) -> float:
|
|
61
|
+
# sum of cash + market value of all positions
|
|
62
|
+
_cash_amount = self._balances[self.base_currency].total
|
|
63
|
+
_positions_value = sum([p.market_value_funds for p in self._positions.values()])
|
|
64
|
+
return _cash_amount + _positions_value
|
|
65
|
+
|
|
66
|
+
def get_balances(self) -> dict[str, AssetBalance]:
|
|
67
|
+
return self._balances
|
|
68
|
+
|
|
69
|
+
def get_positions(self) -> dict[Instrument, Position]:
|
|
70
|
+
return self._positions
|
|
71
|
+
|
|
72
|
+
def get_position(self, instrument: Instrument) -> Position:
|
|
73
|
+
_pos = self._positions.get(instrument)
|
|
74
|
+
if _pos is None:
|
|
75
|
+
_pos = Position(instrument)
|
|
76
|
+
self._positions[instrument] = _pos
|
|
77
|
+
return _pos
|
|
78
|
+
|
|
79
|
+
def get_orders(self, instrument: Instrument | None = None) -> dict[str, Order]:
|
|
80
|
+
orders = self._active_orders.copy()
|
|
81
|
+
if instrument is not None:
|
|
82
|
+
orders = dict(filter(lambda x: x[1].instrument == instrument, orders.items()))
|
|
83
|
+
return orders
|
|
84
|
+
|
|
85
|
+
def position_report(self) -> dict:
|
|
86
|
+
rep = {}
|
|
87
|
+
for p in self._positions.values():
|
|
88
|
+
rep[p.instrument.symbol] = {
|
|
89
|
+
"Qty": p.quantity,
|
|
90
|
+
"Price": p.position_avg_price_funds,
|
|
91
|
+
"PnL": p.pnl,
|
|
92
|
+
"MktValue": p.market_value_funds,
|
|
93
|
+
"Leverage": self.get_leverage(p.instrument),
|
|
94
|
+
}
|
|
95
|
+
return rep
|
|
96
|
+
|
|
97
|
+
########################################################
|
|
98
|
+
# Leverage information
|
|
99
|
+
########################################################
|
|
100
|
+
def get_leverage(self, instrument: Instrument) -> float:
|
|
101
|
+
pos = self._positions.get(instrument)
|
|
102
|
+
if pos is not None:
|
|
103
|
+
return pos.notional_value / self.get_total_capital()
|
|
104
|
+
return 0.0
|
|
105
|
+
|
|
106
|
+
def get_leverages(self) -> dict[Instrument, float]:
|
|
107
|
+
return {s: self.get_leverage(s) for s in self._positions.keys()}
|
|
108
|
+
|
|
109
|
+
def get_net_leverage(self) -> float:
|
|
110
|
+
return sum(self.get_leverages().values())
|
|
111
|
+
|
|
112
|
+
def get_gross_leverage(self) -> float:
|
|
113
|
+
return sum(map(abs, self.get_leverages().values()))
|
|
114
|
+
|
|
115
|
+
########################################################
|
|
116
|
+
# Margin information
|
|
117
|
+
# Used for margin, swap, futures, options trading
|
|
118
|
+
########################################################
|
|
119
|
+
def get_total_required_margin(self) -> float:
|
|
120
|
+
# sum of margin required for all positions
|
|
121
|
+
return sum([p.maint_margin for p in self._positions.values()])
|
|
122
|
+
|
|
123
|
+
def get_available_margin(self) -> float:
|
|
124
|
+
# total capital - total required margin
|
|
125
|
+
return self.get_total_capital() - self.get_total_required_margin()
|
|
126
|
+
|
|
127
|
+
def get_margin_ratio(self) -> float:
|
|
128
|
+
# total capital / total required margin
|
|
129
|
+
return self.get_total_capital() / self.get_total_required_margin()
|
|
130
|
+
|
|
131
|
+
########################################################
|
|
132
|
+
# Order and trade processing
|
|
133
|
+
########################################################
|
|
134
|
+
def update_balance(self, currency: str, total: float, locked: float):
|
|
135
|
+
# create new asset balance if doesn't exist, otherwise update existing
|
|
136
|
+
if currency not in self._balances:
|
|
137
|
+
self._balances[currency] = AssetBalance(free=total - locked, locked=locked, total=total)
|
|
138
|
+
else:
|
|
139
|
+
self._balances[currency].free = total - locked
|
|
140
|
+
self._balances[currency].locked = locked
|
|
141
|
+
self._balances[currency].total = total
|
|
142
|
+
|
|
143
|
+
def attach_positions(self, *position: Position) -> IAccountProcessor:
|
|
144
|
+
for p in position:
|
|
145
|
+
if p.instrument not in self._positions:
|
|
146
|
+
self._positions[p.instrument] = p
|
|
147
|
+
else:
|
|
148
|
+
self._positions[p.instrument].reset_by_position(p)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def add_active_orders(self, orders: dict[str, Order]):
|
|
152
|
+
for oid, od in orders.items():
|
|
153
|
+
self._active_orders[oid] = od
|
|
154
|
+
|
|
155
|
+
def update_position_price(self, time: dt_64, instrument: Instrument, price: float) -> None:
|
|
156
|
+
if instrument in self._positions:
|
|
157
|
+
p = self._positions[instrument]
|
|
158
|
+
p.update_market_price(time, price, 1)
|
|
159
|
+
|
|
160
|
+
def process_order(self, order: Order, update_locked_value: bool = True) -> None:
|
|
161
|
+
_new = order.status == "NEW"
|
|
162
|
+
_open = order.status == "OPEN"
|
|
163
|
+
_closed = order.status == "CLOSED"
|
|
164
|
+
_cancel = order.status == "CANCELED"
|
|
165
|
+
|
|
166
|
+
if _open or _new:
|
|
167
|
+
self._active_orders[order.id] = order
|
|
168
|
+
|
|
169
|
+
# - calculate amount locked by this order
|
|
170
|
+
if update_locked_value and order.type == "LIMIT":
|
|
171
|
+
self._lock_limit_order_value(order)
|
|
172
|
+
|
|
173
|
+
if _closed or _cancel:
|
|
174
|
+
if order.id in self._processed_trades:
|
|
175
|
+
self._processed_trades.pop(order.id)
|
|
176
|
+
|
|
177
|
+
if order.id in self._active_orders:
|
|
178
|
+
self._active_orders.pop(order.id)
|
|
179
|
+
|
|
180
|
+
# - calculate amount to unlock after canceling
|
|
181
|
+
if _cancel and update_locked_value and order.type == "LIMIT":
|
|
182
|
+
self._unlock_limit_order_value(order)
|
|
183
|
+
|
|
184
|
+
logger.debug(
|
|
185
|
+
f" [<y>{self.__class__.__name__}</y>(<g>{order.instrument}</g>)] :: New status for order <r>{order.id}</r> -> <y>{order.status}</y> ({order.type} {order.side} {order.quantity}"
|
|
186
|
+
f"{(' @ ' + str(order.price)) if order.price else ''})"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def process_deals(self, instrument: Instrument, deals: list[Deal]) -> None:
|
|
190
|
+
self._fill_missing_fee_info(instrument, deals)
|
|
191
|
+
pos = self._positions.get(instrument)
|
|
192
|
+
|
|
193
|
+
if pos is not None:
|
|
194
|
+
conversion_rate = 1
|
|
195
|
+
traded_amnt, realized_pnl, deal_cost = 0, 0, 0
|
|
196
|
+
|
|
197
|
+
# - process deals
|
|
198
|
+
for d in deals:
|
|
199
|
+
if d.id not in self._processed_trades[d.order_id]:
|
|
200
|
+
self._processed_trades[d.order_id].append(d.id)
|
|
201
|
+
r_pnl, fee_in_base = pos.update_position_by_deal(d, conversion_rate)
|
|
202
|
+
realized_pnl += r_pnl
|
|
203
|
+
deal_cost += d.amount * d.price / conversion_rate
|
|
204
|
+
traded_amnt += d.amount
|
|
205
|
+
total_cost = deal_cost + fee_in_base
|
|
206
|
+
logger.debug(
|
|
207
|
+
f" [<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: traded {d.amount} @ {d.price} -> {realized_pnl:.2f} {self.base_currency} realized profit"
|
|
208
|
+
)
|
|
209
|
+
if not instrument.is_futures():
|
|
210
|
+
self._balances[self.base_currency] -= total_cost
|
|
211
|
+
self._balances[instrument.base] += d.amount
|
|
212
|
+
else:
|
|
213
|
+
self._balances[self.base_currency] -= fee_in_base
|
|
214
|
+
self._balances[instrument.settle] += realized_pnl
|
|
215
|
+
|
|
216
|
+
def _fill_missing_fee_info(self, instrument: Instrument, deals: list[Deal]) -> None:
|
|
217
|
+
for d in deals:
|
|
218
|
+
if d.fee_amount is None:
|
|
219
|
+
d.fee_amount = self._tcc.get_execution_fees(
|
|
220
|
+
instrument=instrument, exec_price=d.price, amount=d.amount, crossed_market=d.aggressive
|
|
221
|
+
)
|
|
222
|
+
# this is only true for linear contracts
|
|
223
|
+
d.fee_currency = instrument.quote
|
|
224
|
+
|
|
225
|
+
def _lock_limit_order_value(self, order: Order) -> float:
|
|
226
|
+
pos = self._positions.get(order.instrument)
|
|
227
|
+
excess = 0.0
|
|
228
|
+
# - we handle only instruments it;s subscribed to
|
|
229
|
+
if pos:
|
|
230
|
+
sgn = -1 if order.side == "SELL" else +1
|
|
231
|
+
pos_change = sgn * order.quantity
|
|
232
|
+
direction = np.sign(pos_change)
|
|
233
|
+
prev_direction = np.sign(pos.quantity)
|
|
234
|
+
# how many shares are closed/open
|
|
235
|
+
qty_closing = min(abs(pos.quantity), abs(pos_change)) * direction if prev_direction != direction else 0
|
|
236
|
+
qty_opening = pos_change if prev_direction == direction else pos_change - qty_closing
|
|
237
|
+
excess = abs(qty_opening) * order.price
|
|
238
|
+
|
|
239
|
+
# TODO: locking likely doesn't work correctly for spot accounts (Account)
|
|
240
|
+
# Example: if we have 1 BTC at price 100k and set a limit order for 0.1 BTC at 110k
|
|
241
|
+
# it will not lock 0.1 BTC
|
|
242
|
+
if excess > 0:
|
|
243
|
+
self._balances[self.base_currency].lock(excess)
|
|
244
|
+
self._locked_capital_by_order[order.id] = excess
|
|
245
|
+
|
|
246
|
+
return excess
|
|
247
|
+
|
|
248
|
+
def _unlock_limit_order_value(self, order: Order):
|
|
249
|
+
if order.id in self._locked_capital_by_order:
|
|
250
|
+
excess = self._locked_capital_by_order.pop(order.id)
|
|
251
|
+
self._balances[self.base_currency].lock(-excess)
|