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.

Files changed (100) hide show
  1. qubx/__init__.py +207 -0
  2. qubx/_nb_magic.py +100 -0
  3. qubx/backtester/__init__.py +5 -0
  4. qubx/backtester/account.py +145 -0
  5. qubx/backtester/broker.py +87 -0
  6. qubx/backtester/data.py +296 -0
  7. qubx/backtester/management.py +378 -0
  8. qubx/backtester/ome.py +296 -0
  9. qubx/backtester/optimization.py +201 -0
  10. qubx/backtester/simulated_data.py +558 -0
  11. qubx/backtester/simulator.py +362 -0
  12. qubx/backtester/utils.py +780 -0
  13. qubx/cli/__init__.py +0 -0
  14. qubx/cli/commands.py +67 -0
  15. qubx/connectors/ccxt/__init__.py +0 -0
  16. qubx/connectors/ccxt/account.py +495 -0
  17. qubx/connectors/ccxt/broker.py +132 -0
  18. qubx/connectors/ccxt/customizations.py +193 -0
  19. qubx/connectors/ccxt/data.py +612 -0
  20. qubx/connectors/ccxt/exceptions.py +17 -0
  21. qubx/connectors/ccxt/factory.py +93 -0
  22. qubx/connectors/ccxt/utils.py +307 -0
  23. qubx/core/__init__.py +0 -0
  24. qubx/core/account.py +251 -0
  25. qubx/core/basics.py +850 -0
  26. qubx/core/context.py +420 -0
  27. qubx/core/exceptions.py +38 -0
  28. qubx/core/helpers.py +480 -0
  29. qubx/core/interfaces.py +1150 -0
  30. qubx/core/loggers.py +514 -0
  31. qubx/core/lookups.py +475 -0
  32. qubx/core/metrics.py +1512 -0
  33. qubx/core/mixins/__init__.py +13 -0
  34. qubx/core/mixins/market.py +94 -0
  35. qubx/core/mixins/processing.py +428 -0
  36. qubx/core/mixins/subscription.py +203 -0
  37. qubx/core/mixins/trading.py +88 -0
  38. qubx/core/mixins/universe.py +270 -0
  39. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  40. qubx/core/series.pxd +125 -0
  41. qubx/core/series.pyi +118 -0
  42. qubx/core/series.pyx +988 -0
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/core/utils.pyi +6 -0
  45. qubx/core/utils.pyx +62 -0
  46. qubx/data/__init__.py +25 -0
  47. qubx/data/helpers.py +416 -0
  48. qubx/data/readers.py +1562 -0
  49. qubx/data/tardis.py +100 -0
  50. qubx/gathering/simplest.py +88 -0
  51. qubx/math/__init__.py +3 -0
  52. qubx/math/stats.py +129 -0
  53. qubx/pandaz/__init__.py +23 -0
  54. qubx/pandaz/ta.py +2757 -0
  55. qubx/pandaz/utils.py +638 -0
  56. qubx/resources/instruments/symbols-binance.cm.json +1 -0
  57. qubx/resources/instruments/symbols-binance.json +1 -0
  58. qubx/resources/instruments/symbols-binance.um.json +1 -0
  59. qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
  60. qubx/resources/instruments/symbols-bitfinex.json +1 -0
  61. qubx/resources/instruments/symbols-kraken.f.json +1 -0
  62. qubx/resources/instruments/symbols-kraken.json +1 -0
  63. qubx/ta/__init__.py +0 -0
  64. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  65. qubx/ta/indicators.pxd +149 -0
  66. qubx/ta/indicators.pyi +41 -0
  67. qubx/ta/indicators.pyx +787 -0
  68. qubx/trackers/__init__.py +3 -0
  69. qubx/trackers/abvanced.py +236 -0
  70. qubx/trackers/composite.py +146 -0
  71. qubx/trackers/rebalancers.py +129 -0
  72. qubx/trackers/riskctrl.py +641 -0
  73. qubx/trackers/sizers.py +235 -0
  74. qubx/utils/__init__.py +5 -0
  75. qubx/utils/_pyxreloader.py +281 -0
  76. qubx/utils/charting/lookinglass.py +1057 -0
  77. qubx/utils/charting/mpl_helpers.py +1183 -0
  78. qubx/utils/marketdata/binance.py +284 -0
  79. qubx/utils/marketdata/ccxt.py +90 -0
  80. qubx/utils/marketdata/dukas.py +130 -0
  81. qubx/utils/misc.py +541 -0
  82. qubx/utils/ntp.py +63 -0
  83. qubx/utils/numbers_utils.py +7 -0
  84. qubx/utils/orderbook.py +491 -0
  85. qubx/utils/plotting/__init__.py +0 -0
  86. qubx/utils/plotting/dashboard.py +150 -0
  87. qubx/utils/plotting/data.py +137 -0
  88. qubx/utils/plotting/interfaces.py +25 -0
  89. qubx/utils/plotting/renderers/__init__.py +0 -0
  90. qubx/utils/plotting/renderers/plotly.py +0 -0
  91. qubx/utils/runner/__init__.py +1 -0
  92. qubx/utils/runner/_jupyter_runner.pyt +60 -0
  93. qubx/utils/runner/accounts.py +88 -0
  94. qubx/utils/runner/configs.py +65 -0
  95. qubx/utils/runner/runner.py +470 -0
  96. qubx/utils/time.py +312 -0
  97. qubx-0.5.7.dist-info/METADATA +105 -0
  98. qubx-0.5.7.dist-info/RECORD +100 -0
  99. qubx-0.5.7.dist-info/WHEEL +4 -0
  100. 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)