Qubx 0.0.1__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (39) hide show
  1. {qubx-0.0.1 → qubx-0.1.0}/PKG-INFO +25 -8
  2. qubx-0.1.0/README.md +25 -0
  3. {qubx-0.0.1 → qubx-0.1.0}/build.py +1 -1
  4. {qubx-0.0.1 → qubx-0.1.0}/pyproject.toml +6 -4
  5. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/__init__.py +14 -8
  6. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/_nb_magic.py +4 -20
  7. qubx-0.1.0/src/qubx/core/account.py +166 -0
  8. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/core/basics.py +155 -24
  9. qubx-0.1.0/src/qubx/core/loggers.py +343 -0
  10. qubx-0.1.0/src/qubx/core/lookups.py +392 -0
  11. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/core/series.pxd +17 -8
  12. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/core/series.pyx +96 -23
  13. qubx-0.1.0/src/qubx/core/strategy.py +600 -0
  14. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/data/readers.py +32 -22
  15. qubx-0.1.0/src/qubx/impl/ccxt_connector.py +213 -0
  16. qubx-0.1.0/src/qubx/impl/ccxt_trading.py +216 -0
  17. qubx-0.1.0/src/qubx/impl/exchange_customizations.py +76 -0
  18. qubx-0.1.0/src/qubx/impl/utils.py +108 -0
  19. qubx-0.1.0/src/qubx/trackers/__init__.py +1 -0
  20. qubx-0.1.0/src/qubx/trackers/rebalancers.py +116 -0
  21. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/utils/__init__.py +1 -1
  22. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/utils/_pyxreloader.py +7 -1
  23. qubx-0.1.0/src/qubx/utils/marketdata/binance.py +278 -0
  24. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/utils/misc.py +108 -9
  25. qubx-0.1.0/src/qubx/utils/pandas.py +486 -0
  26. qubx-0.1.0/src/qubx/utils/runner.py +247 -0
  27. qubx-0.0.1/README.md +0 -10
  28. qubx-0.0.1/src/qubx/core/lookups.py +0 -152
  29. qubx-0.0.1/src/qubx/core/strategy.py +0 -89
  30. qubx-0.0.1/src/qubx/utils/marketdata/binance.py +0 -212
  31. qubx-0.0.1/src/qubx/utils/pandas.py +0 -206
  32. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/core/__init__.py +0 -0
  33. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/core/utils.pyx +0 -0
  34. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/math/__init__.py +0 -0
  35. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/math/stats.py +0 -0
  36. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/ta/__init__.py +0 -0
  37. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/ta/indicators.pyx +0 -0
  38. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  39. {qubx-0.0.1 → qubx-0.1.0}/src/qubx/utils/time.py +0 -0
@@ -1,17 +1,18 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Qubx
3
- Version: 0.0.1
3
+ Version: 0.1.0
4
4
  Summary: Qubx - quantitative trading framework
5
5
  Home-page: https://github.com/dmarienko/Qubx
6
6
  Author: Dmitry Marienko
7
7
  Author-email: dmitry@gmail.com
8
- Requires-Python: >=3.9,<4.0
8
+ Requires-Python: >=3.10,<4.0
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: ccxt (>=4.2.68,<5.0.0)
14
14
  Requires-Dist: cython (==3.0.8)
15
+ Requires-Dist: importlib-metadata
15
16
  Requires-Dist: loguru (>=0.7.2,<0.8.0)
16
17
  Requires-Dist: ntplib (>=0.4.0,<0.5.0)
17
18
  Requires-Dist: numpy (>=1.26.3,<2.0.0)
@@ -23,17 +24,33 @@ Requires-Dist: python-binance (>=1.0.19,<2.0.0)
23
24
  Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
24
25
  Requires-Dist: scipy (>=1.12.0,<2.0.0)
25
26
  Requires-Dist: stackprinter (>=0.2.10,<0.3.0)
27
+ Requires-Dist: tqdm
26
28
  Project-URL: Repository, https://github.com/dmarienko/Qubx
27
29
  Description-Content-Type: text/markdown
28
30
 
29
31
  # Qubx
30
32
 
31
- ### Next generation of Qube quantitative backtesting framework (QUBX)
33
+ ## Next generation of Qube quantitative backtesting framework (QUBX)
32
34
  ```
33
- /////\
34
- ///// \
35
- \\\\\ /
36
- \\\\\/ (c) 2024, by M.D.E
35
+ ⠀⠀⡰⡖⠒⠒⢒⢦⠀⠀
36
+ ⠀⢠⠃⠈⢆⣀⣎⣀⣱⡀ QUBX | Quantitative Backtesting Environment
37
+ ⠀⢳⠒⠒⡞⠚⡄⠀⡰⠁ (c) 2024, by Dmytro Mariienko
38
+ ⠀⠀⠱⣜⣀⣀⣈⣦⠃⠀⠀⠀
39
+
37
40
  ```
41
+ ### How to run live trading (Only Binance spot tested)
42
+ 1. cd experiments/
43
+ 2. Edit strategy config file (zero_test.yaml). Testing strategy is just doing flip / flop trading once per minute (trading_allowed should be set for trading)
44
+ 3. Modify accounts config file under ./configs/.env and provide your API binance credentials (see example in example-accounts.cfg):
45
+ ```
46
+ [binance-mde]
47
+ apiKey = ...
48
+ secret = ...
49
+ base_currency = USDT
50
+ ```
51
+ 4. Run in console (-j key if want to run under jupyter console)
38
52
 
53
+ ```
54
+ > python -P ..\src\qubx\utils\runner.py configs\zero_test.yaml -a configs\.env -j
55
+ ```
39
56
 
qubx-0.1.0/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # Qubx
2
+
3
+ ## Next generation of Qube quantitative backtesting framework (QUBX)
4
+ ```
5
+ ⠀⠀⡰⡖⠒⠒⢒⢦⠀⠀
6
+ ⠀⢠⠃⠈⢆⣀⣎⣀⣱⡀ QUBX | Quantitative Backtesting Environment
7
+ ⠀⢳⠒⠒⡞⠚⡄⠀⡰⠁ (c) 2024, by Dmytro Mariienko
8
+ ⠀⠀⠱⣜⣀⣀⣈⣦⠃⠀⠀⠀
9
+
10
+ ```
11
+ ### How to run live trading (Only Binance spot tested)
12
+ 1. cd experiments/
13
+ 2. Edit strategy config file (zero_test.yaml). Testing strategy is just doing flip / flop trading once per minute (trading_allowed should be set for trading)
14
+ 3. Modify accounts config file under ./configs/.env and provide your API binance credentials (see example in example-accounts.cfg):
15
+ ```
16
+ [binance-mde]
17
+ apiKey = ...
18
+ secret = ...
19
+ base_currency = USDT
20
+ ```
21
+ 4. Run in console (-j key if want to run under jupyter console)
22
+
23
+ ```
24
+ > python -P ..\src\qubx\utils\runner.py configs\zero_test.yaml -a configs\.env -j
25
+ ```
@@ -153,7 +153,7 @@ def _copy_build_dir_to_project(cmd: build_ext) -> None:
153
153
  def _strip_unneeded_symbols() -> None:
154
154
  try:
155
155
  print("Stripping unneeded symbols from binaries...")
156
- for so in itertools.chain(Path("nautilus_trader").rglob("*.so")):
156
+ for so in itertools.chain(Path("src/qubx").rglob("*.so")):
157
157
  if platform.system() == "Linux":
158
158
  strip_cmd = ["strip", "--strip-unneeded", so]
159
159
  elif platform.system() == "Darwin":
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "Qubx"
3
- version = "0.0.1"
3
+ version = "0.1.0"
4
4
  description = "Qubx - quantitative trading framework"
5
5
  authors = ["Dmitry Marienko <dmitry@gmail.com>"]
6
6
  readme = "README.md"
@@ -15,14 +15,14 @@ include = [
15
15
  ]
16
16
 
17
17
  [tool.poetry.dependencies]
18
- python = ">=3.9,<4.0"
18
+ python = ">=3.10,<4.0"
19
19
  pytest = {extras = ["lazyfixture"], version = "^7.2.0"}
20
20
  numpy = "^1.26.3"
21
21
  ntplib = "^0.4.0"
22
22
  loguru = "^0.7.2"
23
+ tqdm = "*"
24
+ importlib-metadata = "*"
23
25
  stackprinter = "^0.2.10"
24
- #websocket-client = "^1.6.3"
25
- #websockets = "^11.0.3"
26
26
  pymongo = "^4.6.1"
27
27
  pydantic = "^1.10.2"
28
28
  python-dotenv = "^1.0.0"
@@ -30,6 +30,7 @@ python-binance = "^1.0.19"
30
30
  pyarrow = "^15.0.0"
31
31
  scipy = "^1.12.0"
32
32
  cython = "3.0.8"
33
+ ccxt = "^4.2.68"
33
34
 
34
35
  [tool.poetry.group.dev.dependencies]
35
36
  pre-commit = "^2.20.0"
@@ -37,6 +38,7 @@ pytest = "^7.1.3"
37
38
 
38
39
  #[project.optional-dependencies]
39
40
  #numba = "^0.57.1"
41
+ ipykernel = "^6.29.4"
40
42
 
41
43
  [build-system]
42
44
  requires = ["poetry-core", "setuptools", "numpy>=1.26.3", "cython==3.0.8", "toml>=0.10.2",]
@@ -1,9 +1,9 @@
1
- from qubx.utils import set_mpl_theme, runtime_env, install_pyx_recompiler_for_dev
2
- install_pyx_recompiler_for_dev()
1
+ from qubx.utils import set_mpl_theme, runtime_env
2
+ from qubx.utils.misc import install_pyx_recompiler_for_dev
3
3
 
4
4
  from loguru import logger
5
5
  import os, sys, stackprinter
6
- from qubx.core.lookups import InstrumentsLookup
6
+ from qubx.core.lookups import FeesLookup, GlobalLookup, InstrumentsLookup
7
7
 
8
8
 
9
9
  def formatter(record):
@@ -36,7 +36,9 @@ logger.remove(None)
36
36
  logger.add(sys.stdout, format=formatter, colorize=True)
37
37
  logger = logger.opt(colors=True)
38
38
 
39
- lookup = InstrumentsLookup()
39
+ # - global lookup helper
40
+ lookup = GlobalLookup(InstrumentsLookup(), FeesLookup())
41
+
40
42
 
41
43
  # registering magic for jupyter notebook
42
44
  if runtime_env() in ['notebook', 'shell']:
@@ -50,21 +52,25 @@ if runtime_env() in ['notebook', 'shell']:
50
52
 
51
53
  @line_magic
52
54
  def qubxd(self, line: str):
53
- self.qubx_setup('dark')
55
+ self.qubx_setup('dark' + ' ' + line)
54
56
 
55
57
  @line_magic
56
58
  def qubxl(self, line: str):
57
- self.qubx_setup('light')
59
+ self.qubx_setup('light' + ' ' + line)
58
60
 
59
61
  @line_magic
60
62
  def qubx_setup(self, line: str):
61
63
  """
62
- QUBE framework initialization
64
+ QUBX framework initialization
63
65
  """
64
66
  import os
67
+ args = [x.strip() for x in line.split(' ')]
68
+
69
+ # setup cython dev hooks - only if 'dev' is passed as argument
70
+ if line and 'dev' in args:
71
+ install_pyx_recompiler_for_dev()
65
72
 
66
73
  tpl_path = os.path.join(os.path.dirname(__file__), "_nb_magic.py")
67
- # print("TPL:", tpl_path)
68
74
  with open(tpl_path, 'r', encoding="utf8") as myfile:
69
75
  s = myfile.read()
70
76
 
@@ -5,7 +5,7 @@ import importlib_metadata
5
5
 
6
6
  import qubx
7
7
  from qubx.utils import runtime_env
8
- from qubx.utils.misc import add_project_to_system_path
8
+ from qubx.utils.misc import add_project_to_system_path, logo
9
9
 
10
10
 
11
11
  def np_fmt_short():
@@ -46,24 +46,8 @@ if runtime_env() in ['notebook', 'shell']:
46
46
  # - add project home to system path
47
47
  add_project_to_system_path()
48
48
 
49
- # - check current version
50
- try:
51
- version = importlib_metadata.version('qube2')
52
- except:
53
- version = 'Dev'
54
-
55
- # some new logo
49
+ # show logo first time
56
50
  if not hasattr(qubx.QubxMagics, '__already_initialized__'):
57
- from qubx.utils.misc import (green, yellow, cyan, magenta, white, blue, red)
58
-
59
- print(
60
- f"""
61
- {red("╻")}
62
- {green("┏┓ ╻ ")} {red("┃")} {yellow("┏┓")} {cyan("Quantitative Backtesting Environment")}
63
- {green("┃┃ ┓┏ ┣┓ ┏┓")} {red("┃")} {yellow("┏┛")}
64
- {green("┗┻ ┗┻ ┗┛ ┗ ")} {red("┃")} {yellow("┗━")} (c) 2024, ver. {magenta(version.rstrip())}
65
- {red("╹")}
66
- """
67
- )
68
- qubx.QubxMagics.__already_initialized__ = True
51
+ setattr(qubx.QubxMagics, "__already_initialized__", True)
52
+ logo()
69
53
 
@@ -0,0 +1,166 @@
1
+ from typing import Any, Optional, List, Dict, Tuple
2
+ from collections import defaultdict
3
+
4
+ import numpy as np
5
+
6
+ from qubx import lookup, logger
7
+ from qubx.core.basics import Instrument, Position, TransactionCostsCalculator, dt_64, Deal, Order
8
+
9
+
10
+ class AccountProcessor:
11
+ """
12
+ Account processor class
13
+ """
14
+ account_id: str
15
+ base_currency: str
16
+ reserved: Dict[str, float] # how much asset is reserved against the trading
17
+ _balances: Dict[str, Tuple[float, float]]
18
+ _active_orders: Dict[str|int, Order] # active orders
19
+ _processed_trades: Dict[str|int, List[str|int]]
20
+ _positions: Dict[str, Position]
21
+ _total_capital_in_base: float = 0.0
22
+ _locked_capital_in_base: float = 0.0
23
+ _locked_capital_by_order: Dict[str|int, float]
24
+
25
+ def __init__(self,
26
+ account_id: str,
27
+ base_currency: str,
28
+ reserves: Dict[str, float] | None,
29
+ total_capital: float=0,
30
+ locked_capital: float=0
31
+ ) -> None:
32
+ self.account_id = account_id
33
+ self.base_currency = base_currency
34
+ self.reserved = dict() if reserves is None else reserves
35
+ self._processed_trades = defaultdict(list)
36
+ self._active_orders = dict()
37
+ self._positions = {}
38
+ self._locked_capital_by_order = dict()
39
+ self._balances = dict()
40
+ self.update_base_balance(total_capital, locked_capital)
41
+
42
+ def update_base_balance(self, total_capital: float, locked_capital: float):
43
+ """
44
+ Update base currency balance
45
+ """
46
+ self._total_capital_in_base = total_capital
47
+ self._locked_capital_in_base = locked_capital
48
+
49
+ def update_balance(self, symbol: str, total_capital: float, locked_capital: float):
50
+ self._balances[symbol] = (total_capital, locked_capital)
51
+
52
+ def get_balances(self) -> Dict[str, Tuple[float, float]]:
53
+ return dict(self._balances)
54
+
55
+ def attach_positions(self, *position: Position) -> 'AccountProcessor':
56
+ for p in position:
57
+ self._positions[p.instrument.symbol] = p
58
+ return self
59
+
60
+ def update_position_price(self, time: dt_64, symbol: str, price: float):
61
+ p = self._positions[symbol]
62
+ p.update_market_price(time, price, 1)
63
+
64
+ def get_capital(self) -> float:
65
+ # TODO: need to take in account leverage and funds currently locked
66
+ return self._total_capital_in_base - self._locked_capital_in_base
67
+
68
+ def get_reserved(self, instrument: Instrument) -> float:
69
+ """
70
+ Check how much were reserved for this instrument
71
+ """
72
+ return self.reserved.get(instrument.symbol, self.reserved.get(instrument.base, 0))
73
+
74
+ def process_deals(self, symbol: str, deals: List[Deal]):
75
+ pos = self._positions.get(symbol)
76
+
77
+ if pos is not None:
78
+ conversion_rate = 1
79
+ instr = pos.instrument
80
+ traded_amnt, realized_pnl, deal_cost = 0, 0, 0
81
+
82
+ # - check if we need conversion rate for this instrument
83
+ # - TODO - need test on it !
84
+ if instr._aux_instrument is not None:
85
+ aux_pos = self._positions.get(instr._aux_instrument.symbol)
86
+ if aux_pos:
87
+ conversion_rate = aux_pos.last_update_price
88
+ else:
89
+ logger.error(f"Can't find additional instrument {instr._aux_instrument} for estimating {symbol} position value !!!")
90
+
91
+ # - process deals
92
+ for d in deals:
93
+ if d.id not in self._processed_trades[d.order_id]:
94
+ self._processed_trades[d.order_id].append(d.id)
95
+ realized_pnl += pos.update_position_by_deal(d, conversion_rate)
96
+ deal_cost += d.amount * d.price / conversion_rate
97
+ traded_amnt += d.amount
98
+ logger.info(f" :: traded {d.amount} for {symbol} @ {d.price} -> {realized_pnl:.2f}")
99
+ self._total_capital_in_base -= deal_cost
100
+
101
+ def _lock_limit_order_value(self, order: Order) -> float:
102
+ pos = self._positions.get(order.symbol)
103
+ excess = 0.0
104
+ # - we handle only instruments it;s subscribed to
105
+ if pos:
106
+ sgn = -1 if order.side == 'SELL' else +1
107
+ pos_change = sgn * order.quantity
108
+ direction = np.sign(pos_change)
109
+ prev_direction = np.sign(pos.quantity)
110
+ # how many shares are closed/open
111
+ qty_closing = min(abs(pos.quantity), abs(pos_change)) * direction if prev_direction != direction else 0
112
+ qty_opening = pos_change if prev_direction == direction else pos_change - qty_closing
113
+ excess = abs(qty_opening) * order.price
114
+
115
+ if excess > 0:
116
+ self._locked_capital_in_base += excess
117
+ self._locked_capital_by_order[order.id] = excess
118
+
119
+ return excess
120
+
121
+ def _unlock_limit_order_value(self, order: Order):
122
+ if order.id in self._locked_capital_by_order:
123
+ excess = self._locked_capital_by_order.pop(order.id )
124
+ self._locked_capital_in_base -= excess
125
+
126
+ def process_order(self, order: Order):
127
+ _new = order.status == 'NEW'
128
+ _open = order.status == 'OPEN'
129
+ _closed = order.status == 'CLOSED'
130
+ _cancel = order.status == 'CANCELED'
131
+
132
+ if _open or _new:
133
+ self._active_orders[order.id] = order
134
+
135
+ # - calculate amount locked by this order
136
+ if order.type == 'LIMIT':
137
+ self._lock_limit_order_value(order)
138
+
139
+ if _closed or _cancel:
140
+ if order.id in self._processed_trades:
141
+ self._processed_trades.pop(order.id)
142
+
143
+ if order.id in self._active_orders:
144
+ self._active_orders.pop(order.id)
145
+
146
+ # - calculate amount to unlock after canceling
147
+ if _cancel and order.type == 'LIMIT':
148
+ self._unlock_limit_order_value(order)
149
+
150
+ logger.info(f"Order {order.id} {order.type} {order.side} {order.quantity} of {order.symbol} -> {order.status}")
151
+
152
+ def add_active_orders(self, orders: Dict[str, Order]):
153
+ for oid, od in orders.items():
154
+ self._active_orders[oid] = od
155
+
156
+ def get_orders(self, symbol: str | None = None) -> List[Order]:
157
+ ols = list(self._active_orders.values())
158
+ if symbol is not None:
159
+ ols = list(filter(lambda x: x.symbol == symbol, ols))
160
+ return ols
161
+
162
+ def positions_report(self) -> dict:
163
+ rep = {}
164
+ for p in self._positions.values():
165
+ rep[p.instrument.symbol] = {'Qty': p.quantity, 'Price': p.position_avg_price_funds, 'PnL': p.pnl, 'MktValue': p.market_value_funds}
166
+ return rep
@@ -1,13 +1,21 @@
1
1
  from datetime import datetime
2
2
  from typing import Callable, Dict, List, Optional, Union
3
3
  import numpy as np
4
+ import pandas as pd
4
5
  import math
5
6
  from dataclasses import dataclass, field
7
+
8
+ import asyncio
9
+ # from multiprocessing import Queue, Process, Event, Lock
10
+ from threading import Thread, Event, Lock
11
+ from queue import Queue
12
+
6
13
  from qubx.core.series import Quote, Trade, time_as_nsec
7
14
  from qubx.core.utils import time_to_str, time_delta_to_str, recognize_timeframe
8
15
 
9
16
 
10
17
  dt_64 = np.datetime64
18
+ td_64 = np.timedelta64
11
19
 
12
20
 
13
21
  @dataclass
@@ -80,9 +88,26 @@ class Signal:
80
88
 
81
89
 
82
90
  class TransactionCostsCalculator:
83
- def __init__(self, maker: float, taker: float):
84
- self.maker = maker
85
- self.taker = taker
91
+ """
92
+ A class for calculating transaction costs for a trading strategy.
93
+ Attributes
94
+ ----------
95
+ name : str
96
+ The name of the transaction costs calculator.
97
+ maker : float
98
+ The maker fee, as a percentage of the transaction value.
99
+ taker : float
100
+ The taker fee, as a percentage of the transaction value.
101
+
102
+ """
103
+ name: str
104
+ maker: float
105
+ taker: float
106
+
107
+ def __init__(self, name: str, maker: float, taker: float):
108
+ self.name = name
109
+ self.maker = maker / 100.0
110
+ self.taker = taker / 100.0
86
111
 
87
112
  def get_execution_fees(self, instrument: Instrument, exec_price: float, amount: float, crossed_market=False, conversion_rate=1.0):
88
113
  if crossed_market:
@@ -97,10 +122,45 @@ class TransactionCostsCalculator:
97
122
  return 0.0
98
123
 
99
124
  def __repr__(self):
100
- return f'<TCC: {self.maker * 100:.4f} / {self.taker * 100:.4f}>'
125
+ return f'<{self.name}: {self.maker * 100:.4f} / {self.taker * 100:.4f}>'
101
126
 
102
127
 
103
- ZERO_COSTS = TransactionCostsCalculator(0.0, 0.0)
128
+ ZERO_COSTS = TransactionCostsCalculator('Zero', 0.0, 0.0)
129
+
130
+
131
+ @dataclass
132
+ class Deal:
133
+ id: str | int # trade id
134
+ order_id: str | int # order's id
135
+ time: dt_64 # time of trade
136
+ amount: float # signed traded amount: positive for buy and negative for selling
137
+ price: float
138
+ aggressive: bool
139
+ fee_amount: float | None = None
140
+ fee_currency: str | None = None
141
+
142
+
143
+ @dataclass
144
+ class Order:
145
+ id: str
146
+ type: str
147
+ symbol: str
148
+ time: dt_64
149
+ quantity: float
150
+ price: float
151
+ side: str
152
+ status: str
153
+ time_in_force: str
154
+ client_id: str | None = None
155
+ cost: float = 0.0
156
+
157
+ def __str__(self) -> str:
158
+ return f"[{self.id}] {self.type} {self.side} {self.quantity} of {self.symbol} {('@ ' + str(self.price)) if self.price > 0 else ''} ({self.time_in_force}) [{self.status}]"
159
+
160
+
161
+ def round_down(x, n):
162
+ dvz = 10**(-n)
163
+ return (int(x / dvz)) * dvz
104
164
 
105
165
 
106
166
  class Position:
@@ -115,16 +175,18 @@ class Position:
115
175
  position_avg_price_funds: float = 0.0 # average position price
116
176
  commissions: float = 0.0 # cumulative commissions paid for this position
117
177
 
118
- last_update_time: Optional[int] = None # when price updated or position changed
119
- last_update_price: Optional[float] = None # last update price (actually instrument's price) in quoted currency
178
+ last_update_time: int = np.nan # when price updated or position changed
179
+ last_update_price: float = np.nan # last update price (actually instrument's price) in quoted currency
180
+ last_update_conversion_rate: float = np.nan # last update conversion rate
120
181
 
121
182
  # - helpers for position processing
122
183
  _formatter: str
123
184
  _prc_formatter: str
124
185
  _qty_multiplier: float = 1.0
186
+ __pos_incr_qty: float = 0
125
187
 
126
188
  def __init__(self, instrument: Instrument, tcc: TransactionCostsCalculator,
127
- quantity=0.0, average_price=0.0, aux_price=1.0,
189
+ quantity=0.0, pos_average_price=0.0, r_pnl=0.0
128
190
  ) -> None:
129
191
  self.instrument = instrument
130
192
  self.tcc = tcc
@@ -136,19 +198,41 @@ class Position:
136
198
  self._formatter += f'%10.{instrument.price_precision}f %+10.4f | %s %10.2f'
137
199
  self._prc_formatter = f"%.{instrument.price_precision}f"
138
200
  if instrument.is_futures:
139
- self._qty_multiplier = instrument.futures_info.contract_size
201
+ self._qty_multiplier = instrument.futures_info.contract_size # type: ignore
140
202
 
141
- if quantity != 0.0 and average_price > 0.0:
203
+ self.reset()
204
+ if quantity != 0.0 and pos_average_price > 0.0:
142
205
  self.quantity = quantity
143
- raise ValueError("[TODO] Position: restore state by quantity and avg price !!!!")
144
-
145
- def _price(self, update: Union[Quote, Trade]) -> float:
206
+ self.position_avg_price = pos_average_price
207
+ self.r_pnl = r_pnl
208
+
209
+ def reset(self):
210
+ """
211
+ Reset position to zero
212
+ """
213
+ self.quantity = 0.0
214
+ self.pnl = 0.0
215
+ self.r_pnl = 0.0
216
+ self.market_value = 0.0
217
+ self.market_value_funds = 0.0
218
+ self.position_avg_price = 0.0
219
+ self.position_avg_price_funds = 0.0
220
+ self.commissions = 0.0
221
+ self.last_update_time = np.nan # type: ignore
222
+ self.last_update_price = np.nan
223
+ self.last_update_conversion_rate = np.nan
224
+ self.__pos_incr_qty = 0
225
+
226
+ def _price(self, update: Quote | Trade) -> float:
146
227
  if isinstance(update, Quote):
147
228
  return update.bid if np.sign(self.quantity) > 0 else update.ask
148
229
  elif isinstance(update, Trade):
149
230
  return update.price
150
231
  raise ValueError(f"Unknown update type: {type(update)}")
151
232
 
233
+ def change_position_by(self, timestamp: dt_64, amount: float, exec_price: float, aggressive=True, conversion_rate:float=1) -> float:
234
+ return self.update_position(timestamp, self.quantity + amount, exec_price, aggressive=aggressive, conversion_rate=conversion_rate)
235
+
152
236
  def update_position(self, timestamp: dt_64, position: float, exec_price: float, aggressive=True, conversion_rate:float=1) -> float:
153
237
  # - realized PnL of this fill
154
238
  deal_pnl = 0
@@ -171,11 +255,15 @@ class Position:
171
255
  if abs(quantity) < self.instrument.min_size_step:
172
256
  quantity = 0.0
173
257
  self.position_avg_price = 0.0
258
+ self.__pos_incr_qty = 0
174
259
 
175
260
  # - if it has something to add to position let's update price and cost
176
261
  if qty_opening != 0:
177
- qa_open, qas = abs(qty_opening), abs(quantity)
178
- self.position_avg_price = (qa_open * exec_price + qas * self.position_avg_price) / (qas + qa_open)
262
+ _abs_qty_open = abs(qty_opening)
263
+ pos_avg_price_raw = (_abs_qty_open * exec_price + self.__pos_incr_qty * self.position_avg_price) / (self.__pos_incr_qty + _abs_qty_open)
264
+ # - round position average price to be in line with how it's calculated by broker
265
+ self.position_avg_price = round_down(pos_avg_price_raw, self.instrument.price_precision)
266
+ self.__pos_incr_qty += _abs_qty_open
179
267
 
180
268
  # - update position and position's price
181
269
  self.position_avg_price_funds = self.position_avg_price / conversion_rate
@@ -185,7 +273,7 @@ class Position:
185
273
  self.r_pnl += deal_pnl / conversion_rate
186
274
 
187
275
  # - update pnl
188
- self._update_market_price(time_as_nsec(timestamp), exec_price, conversion_rate)
276
+ self.update_market_price(time_as_nsec(timestamp), exec_price, conversion_rate)
189
277
 
190
278
  # - calculate transaction costs
191
279
  comms = self.tcc.get_execution_fees(self.instrument, exec_price, pos_change, aggressive, conversion_rate)
@@ -193,12 +281,19 @@ class Position:
193
281
 
194
282
  return deal_pnl
195
283
 
196
- def update_market_price(self, price: Union[Quote, Trade], conversion_rate:float=1) -> float:
197
- return self._update_market_price(price.time, self._price(price), conversion_rate)
284
+ def update_market_price_by_tick(self, tick: Quote | Trade, conversion_rate:float=1) -> float:
285
+ return self.update_market_price(tick.time, self._price(tick), conversion_rate)
198
286
 
199
- def _update_market_price(self, timestamp: dt_64, price: float, conversion_rate:float) -> float:
200
- self.last_update_time = timestamp
287
+ def update_position_by_deal(self, deal: Deal, conversion_rate:float=1) -> float:
288
+ time = deal.time.as_unit('ns').asm8 if isinstance(deal.time, pd.Timestamp) else deal.time
289
+ return self.change_position_by(time, deal.amount, deal.price, deal.aggressive, conversion_rate)
290
+ # - deal contains cumulative amount
291
+ # return self.update_position(time, deal.amount, deal.price, deal.aggressive, conversion_rate)
292
+
293
+ def update_market_price(self, timestamp: dt_64, price: float, conversion_rate:float) -> float:
294
+ self.last_update_time = timestamp # type: ignore
201
295
  self.last_update_price = price
296
+ self.last_update_conversion_rate = conversion_rate
202
297
 
203
298
  if not np.isnan(price):
204
299
  self.pnl = self.quantity * (price - self.position_avg_price) / conversion_rate + self.r_pnl
@@ -208,17 +303,53 @@ class Position:
208
303
  self.market_value_funds = self.market_value / conversion_rate
209
304
  return self.pnl
210
305
 
211
- def total_pnl(self, conversion_rate:float=1.0) -> float:
306
+ def total_pnl(self) -> float:
212
307
  pnl = self.r_pnl
213
- if not np.isnan(self.last_update_price):
214
- pnl += self.quantity * (self.last_update_price - self.position_avg_price) / conversion_rate
308
+ if not np.isnan(self.last_update_price): # type: ignore
309
+ pnl += self.quantity * (self.last_update_price - self.position_avg_price) / self.last_update_conversion_rate # type: ignore
215
310
  return pnl
216
311
 
312
+ def get_amount_released_funds_after_closing(self, to_remain: float = 0.0) -> float:
313
+ """
314
+ Estimate how much funds would be released if part of position closed
315
+ """
316
+ d = np.sign(self.quantity)
317
+ funds_release = self.market_value_funds
318
+ if to_remain != 0 and self.quantity != 0 and np.sign(to_remain) == d:
319
+ qty_to_release = max(self.quantity - to_remain, 0) if d > 0 else min(self.quantity - to_remain, 0)
320
+ funds_release = qty_to_release * self.last_update_price / self.last_update_conversion_rate
321
+ return abs(funds_release)
322
+
217
323
  @staticmethod
218
324
  def _t2s(t) -> str:
219
- return np.datetime64(t, 'ns').astype('datetime64[ms]').item().strftime('%Y-%m-%d %H:%M:%S') if t else '---'
325
+ return np.datetime64(t, 'ns').astype('datetime64[ms]').item().strftime('%Y-%m-%d %H:%M:%S') if not np.isnan(t) else '???'
220
326
 
221
327
  def __str__(self):
222
328
  _mkt_price = (self._prc_formatter % self.last_update_price) if self.last_update_price else "---"
223
329
  return self._formatter % (Position._t2s(self.last_update_time), self.quantity, self.position_avg_price_funds,self.pnl, _mkt_price, self.market_value_funds)
224
330
 
331
+
332
+ class CtrlChannel:
333
+ """
334
+ Controlled data communication channel
335
+ """
336
+ control: Event
337
+ queue: Queue # we need something like disruptor here (Queue is temporary)
338
+ name: str
339
+ lock: Lock
340
+
341
+ def __init__(self, name: str, sent=(None, None)):
342
+ self.name = name
343
+ self.control = Event()
344
+ self.queue = Queue()
345
+ self.lock = Lock()
346
+ self.sent = sent
347
+ self.start()
348
+
349
+ def stop(self):
350
+ if self.control.is_set():
351
+ self.control.clear()
352
+ self.queue.put(self.sent) # send sentinel
353
+
354
+ def start(self):
355
+ self.control.set()