Qubx 0.0.1__cp311-cp311-manylinux_2_35_x86_64.whl → 0.1.0__cp311-cp311-manylinux_2_35_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 +14 -8
- qubx/_nb_magic.py +4 -20
- qubx/core/account.py +166 -0
- qubx/core/basics.py +155 -24
- qubx/core/loggers.py +343 -0
- qubx/core/lookups.py +263 -23
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +17 -8
- qubx/core/series.pyx +96 -23
- qubx/core/strategy.py +568 -57
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/data/readers.py +32 -22
- qubx/impl/ccxt_connector.py +213 -0
- qubx/impl/ccxt_trading.py +216 -0
- qubx/impl/exchange_customizations.py +76 -0
- qubx/impl/utils.py +108 -0
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/trackers/__init__.py +1 -0
- qubx/trackers/rebalancers.py +116 -0
- qubx/utils/__init__.py +1 -1
- qubx/utils/_pyxreloader.py +7 -1
- qubx/utils/marketdata/binance.py +202 -136
- qubx/utils/misc.py +108 -9
- qubx/utils/pandas.py +284 -4
- qubx/utils/runner.py +247 -0
- {qubx-0.0.1.dist-info → qubx-0.1.0.dist-info}/METADATA +25 -8
- qubx-0.1.0.dist-info/RECORD +36 -0
- qubx-0.0.1.dist-info/RECORD +0 -27
- {qubx-0.0.1.dist-info → qubx-0.1.0.dist-info}/WHEEL +0 -0
qubx/__init__.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
from qubx.utils import set_mpl_theme, runtime_env
|
|
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
|
|
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
|
-
|
|
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
|
|
qubx/_nb_magic.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|
qubx/core/account.py
ADDED
|
@@ -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
|
qubx/core/basics.py
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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'<
|
|
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:
|
|
119
|
-
last_update_price:
|
|
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,
|
|
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
|
-
|
|
203
|
+
self.reset()
|
|
204
|
+
if quantity != 0.0 and pos_average_price > 0.0:
|
|
142
205
|
self.quantity = quantity
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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.
|
|
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
|
|
197
|
-
return self.
|
|
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
|
|
200
|
-
|
|
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
|
|
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) /
|
|
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()
|