diff --git a/app/components/backtest/backtest.py b/app/components/backtest/backtest.py index 04b6377..939d6b1 100644 --- a/app/components/backtest/backtest.py +++ b/app/components/backtest/backtest.py @@ -4,6 +4,7 @@ from loguru import logger from pydantic import BaseModel +from components.backtest.utils import remove_overlapping_positions from components.orders.order import Order from components.positions.position import Position @@ -47,7 +48,6 @@ def get_overview(self): } - class Backtest: def __init__(self, data, strategy): self.data = data @@ -73,6 +73,9 @@ def test(self): executor.submit(p.test, self.data) logger.debug(f'Finished testing {len(positions)} positions in parallel.') + # after all positions have been tested, we can check for overlapping positions + positions = remove_overlapping_positions(positions, max_overlap=0) + all_position_orders = [] for p in positions: all_position_orders += p.orders diff --git a/app/components/backtest/utils.py b/app/components/backtest/utils.py index a41394c..314c7e8 100644 --- a/app/components/backtest/utils.py +++ b/app/components/backtest/utils.py @@ -1,26 +1,24 @@ -from components.positions import Position +from loguru import logger -def remove_overlapping_positions(positions: Position, max_overlap): - positions_copy = positions[:] +class ComponentManager: - # Iterate over the positions, comparing each one to all subsequent positions - for i in range(len(positions_copy)): - num_overlaps = 0 - for j in range(i + 1, len(positions_copy)): - position_i = positions_copy[i] - position_j = positions_copy[j] + _components = [] - # Check if the positions overlap - i_start = position_i.opened_timestamp - i_end = position_i.closed_timestamp - j_start = position_j.opened_timestamp - j_end = position_j.closed_timestamp - if (i_start <= j_end) and (j_start <= i_end): - num_overlaps += 1 # Increment the overlap counter - if num_overlaps > max_overlap: - positions_copy.pop(j) - break + @classmethod + def register(cls, component): + if component in cls._components: + return + cls._components.append(component) + logger.debug(f'Registered component {component} ({component.__module__})') - # Return the modified copy of the list - return positions_copy + @classmethod + def all(cls): + return [o() for o in cls._components] + + @classmethod + def get(cls, name): + for component in cls._components: + if component.__name__ == name: + return component() + raise ValueError(f'Component "{name}" not found.') \ No newline at end of file diff --git a/app/components/ohlc/ohlc.py b/app/components/ohlc/ohlc.py index a266ede..e2cec9d 100644 --- a/app/components/ohlc/ohlc.py +++ b/app/components/ohlc/ohlc.py @@ -1,4 +1,5 @@ import pandas as pd +from loguru import logger from components.ohlc.symbol import Symbol @@ -32,7 +33,11 @@ def reset_index(self): def _get_ohlc(self, column: str, index: int = None): if index is None: index = self._index - value = self.dataframe[column].iloc[index] + try: + value = self.dataframe[column].iloc[index] + except IndexError: + logger.error(f'Index out of range. Index: {index}, Length: {len(self.dataframe)}') + value = None return value @property @@ -63,7 +68,10 @@ def get_timestamp(self, offset: int = 0): return self.dataframe.index[self._index + offset] def all(self, column: str): - return self.dataframe[column] + try: + return self.dataframe[column] + except KeyError: + return [] def __str__(self): return f'OHLC: {self.symbol}' diff --git a/app/components/parameter.py b/app/components/parameter.py index 5d3ad18..a7b4993 100644 --- a/app/components/parameter.py +++ b/app/components/parameter.py @@ -44,6 +44,8 @@ def __index__(self): return self.value.__index__() + + class IntegerParameter(BaseParameter): def __init__(self, value: int, min_value: int = 0, max_value: int = 9999): self.value = int(value) @@ -63,7 +65,7 @@ def _validate(self): class FloatParameter(BaseParameter): - def __init__(self, value: float, min_value: float, max_value: float): + def __init__(self, value: float, min_value: float = -9999, max_value: float = 9999): self.value = float(value) self.min_value = min_value self.max_value = max_value diff --git a/app/components/positions/position.py b/app/components/positions/position.py index f44a9c1..e3002f9 100644 --- a/app/components/positions/position.py +++ b/app/components/positions/position.py @@ -29,7 +29,9 @@ def __int__(self): self.id = self._get_id() def __str__(self): - return f'Position: {""} | {self.side} | {self.size} | {self.cost_basis} | {self.pnl}' + return f'Position: {""}\t[{self.side.upper()}]\t{self.size} {self.average_entry_price} -> ' \ + f'{self.average_exit_price} \t pnl:{round(self.pnl, 2)} (opn:' \ + f' {self.opened_timestamp}, cls: {self.closed_timestamp})' def _get_id(self): """Get the id of the position.""" @@ -83,7 +85,7 @@ def _add_order_to_size(self, order: Order): def _fill_order(self, order: Union[Order, StopOrder, LimitOrder], ohlc: 'OHLC' = None): """Handles TBD orders. Sets the timestamp and fills the order if it was filled.""" start_index = ohlc.index.get_loc(self.orders[0].timestamp) - df = ohlc.dataframe.iloc[start_index:] + df = ohlc.dataframe.iloc[start_index + 1:] # loop through the df for index, row in df.iterrows(): # handle stops diff --git a/app/components/strategy/builtins/ta/__init__.py b/app/components/strategy/builtins/ta/__init__.py index 72fa10f..279e38e 100644 --- a/app/components/strategy/builtins/ta/__init__.py +++ b/app/components/strategy/builtins/ta/__init__.py @@ -1,3 +1,5 @@ from components.strategy.builtins.ta.sma import * from components.strategy.builtins.ta.correlation import * +from components.strategy.builtins.ta.kalman_filter import * +from components.strategy.builtins.ta.atr import * from components.strategy.builtins.ta.logic import Logic as logic \ No newline at end of file diff --git a/app/components/strategy/builtins/ta/atr.py b/app/components/strategy/builtins/ta/atr.py new file mode 100644 index 0000000..6abd9b6 --- /dev/null +++ b/app/components/strategy/builtins/ta/atr.py @@ -0,0 +1,31 @@ +import numpy as np + +from components.strategy import Series + + +def atr(high: Series, low: Series, close: Series, period: int = 12) -> Series: + """ Calculate the average true range """ + + high = np.array(high) + low = np.array(low) + close = np.array(close) + + if len(high) != len(low) != len(close): + raise ValueError("Input lists must have the same length") + + if len(high) < period: + raise ValueError("Input lists must have at least 'period' number of elements") + + true_range = [] + + for i in range(1, len(high)): + tr = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1])) + true_range.append(tr) + + result = [] + + for i in range(period, len(true_range) + 1): + average = np.mean(true_range[i - period: i]) + result.append(average) + + return Series(result) diff --git a/app/components/strategy/builtins/ta/correlation.py b/app/components/strategy/builtins/ta/correlation.py index c5e1423..880a067 100644 --- a/app/components/strategy/builtins/ta/correlation.py +++ b/app/components/strategy/builtins/ta/correlation.py @@ -1,12 +1,28 @@ import numpy as np +import pandas as pd from components.strategy import Series def correlation_coefficient(x: Series, y: Series, period: int) -> Series: """ Calculate the correlation coefficient between two lists """ - x = np.array(x) - y = np.array(y) + if len(x) != len(y): + raise ValueError("Input arrays must have the same length") - # Calculate the correlation coefficient - return Series(np.corrcoef(x, y)[0, 1]) + if len(x) < period: + raise ValueError("Period must be less than or equal to the length of input arrays") + + x = np.asarray(x.as_list()) + y = np.asarray(y.as_list()) + + result = np.zeros(len(x) - period + 1) + + for i in range(len(result)): + x_window = x[i:i + period] + y_window = y[i:i + period] + result[i] = np.corrcoef(x_window, y_window)[0, 1] + + pad_size = period - 1 + result = np.pad(result, (pad_size, 0), mode='constant', constant_values=np.nan) + + return Series(list(result)) diff --git a/app/components/strategy/builtins/ta/kalman_filter.py b/app/components/strategy/builtins/ta/kalman_filter.py new file mode 100644 index 0000000..dc3d60c --- /dev/null +++ b/app/components/strategy/builtins/ta/kalman_filter.py @@ -0,0 +1,23 @@ +import math + +import pandas as pd +from loguru import logger + +from components.strategy import Series +from components.strategy.builtins.ta.nz import nz + +import numpy as np + + +def kalman_filter(src: pd.Series, gain): + src = list(src) + kf = np.zeros(len(src)) + velo = np.zeros(len(src)) + smooth = np.zeros(len(src)) + for i in range(len(src)): + dk = src[i] - kf[i-1] if i > 0 else src[i] + smooth[i] = kf[i-1] + dk * np.sqrt((gain / 10000) * 2) + velo[i] = velo[i-1] + ((gain / 10000) * dk) + kf[i] = smooth[i] + velo[i] + return Series(list(kf)) + diff --git a/app/components/strategy/series.py b/app/components/strategy/series.py index f6fee1e..a975f5a 100644 --- a/app/components/strategy/series.py +++ b/app/components/strategy/series.py @@ -27,6 +27,12 @@ def __repr__(self): def __len__(self): return len(self._data) + def __getitem__(self, item): + if isinstance(self._data, list): + return self._data[item] + if isinstance(self._data, pd.Series): + return self._data.iloc[item] + def __float__(self): if isinstance(self._data, list): return self._data[self._loop_index] diff --git a/app/tests/test_backtest.py b/app/tests/test_backtest.py index 8dd7f3e..926051d 100644 --- a/app/tests/test_backtest.py +++ b/app/tests/test_backtest.py @@ -1,6 +1,7 @@ from components.backtest.backtest import Backtest from components.ohlc import CSVAdapter -from components.orders import Order +from components.orders import Order, LimitOrder, StopOrder +from components.orders.enums import OrderSide from components.positions import Position from storage.strategies.examples.sma_cross_over import SMACrossOver @@ -65,7 +66,6 @@ def test_bracket_position(self): assert p.size == 0 assert p.pnl == 1.9999999999999716 - def test_stop_loss(self): strategy = SMACrossOver(data=OHLC) strategy.orders.orders = [] @@ -138,15 +138,15 @@ def test_complex_positions(self): p2 = Position( orders=[ - Order(side='sell', symbol='AAPL', qty=1, order_type='market', filled_avg_price=100, timestamp=1), - Order(side='buy', symbol='AAPL', qty=1, order_type='stop', filled_avg_price=90, timestamp=2), + Order(side='sell', symbol='AAPL', qty=1, order_type='market', filled_avg_price=100, timestamp=10), + Order(side='buy', symbol='AAPL', qty=1, order_type='stop', filled_avg_price=90, timestamp=20), ] ) p3 = Position( orders=[ - Order(side='buy', symbol='AAPL', qty=1, order_type='market', filled_avg_price=100, timestamp=1), - Order(side='sell', symbol='AAPL', qty=1, order_type='stop', filled_avg_price=110, timestamp=2), + Order(side='buy', symbol='AAPL', qty=1, order_type='market', filled_avg_price=100, timestamp=100), + Order(side='sell', symbol='AAPL', qty=1, order_type='stop', filled_avg_price=110, timestamp=200), ] ) @@ -157,4 +157,70 @@ def test_complex_positions(self): assert b.result.pnl == 70 + def test_backtest_short_with_order_types(self): + b = Backtest(strategy=SMACrossOver(), data=OHLC) + + side = OrderSide.SELL + open_order = Order( + type='market', + side=side, + qty=100, + symbol='AAPL', + filled_avg_price=257.33, + timestamp=1653984000000, + filled_timestamp=1653984000000, + ) + take_profit = LimitOrder( + side=OrderSide.inverse(side), + qty=100, + symbol='AAPL', + limit_price=256, + ) + stop_loss = StopOrder( + side=OrderSide.inverse(side), + qty=100, + symbol='AAPL', + stop_price=300 + ) + + p = Position(orders=[open_order, take_profit, stop_loss]) + b.strategy.positions.add(p) + b.test() + tested_position = b.strategy.positions.all()[0] + print(tested_position) + + assert tested_position.size == 0 + assert tested_position.closed_timestamp == 1653984600000 + + def test_backtest_long_with_order_types(self): + b = Backtest(strategy=SMACrossOver(), data=OHLC) + side = OrderSide.BUY + open_order = Order( + type='market', + side=side, + qty=100, + symbol='AAPL', + filled_avg_price=257.33, + timestamp=1653984000000, + filled_timestamp=1653984000000, + ) + take_profit = LimitOrder( + side=OrderSide.inverse(side), + qty=100, + symbol='AAPL', + limit_price=260, + ) + stop_loss = StopOrder( + side=OrderSide.inverse(side), + qty=100, + symbol='AAPL', + stop_price=100 + ) + + p = Position(orders=[open_order, take_profit, stop_loss]) + b.strategy.positions.add(p) + b.test() + tested_position = b.strategy.positions.all()[0] + assert tested_position.size == 0 + assert tested_position.closed_timestamp == 1654183500000