Skip to content

Commit

Permalink
add builtins, tests and utils
Browse files Browse the repository at this point in the history
  • Loading branch information
robswc committed Mar 24, 2023
1 parent d320455 commit 9613e17
Show file tree
Hide file tree
Showing 11 changed files with 194 additions and 37 deletions.
5 changes: 4 additions & 1 deletion app/components/backtest/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -47,7 +48,6 @@ def get_overview(self):
}



class Backtest:
def __init__(self, data, strategy):
self.data = data
Expand All @@ -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
Expand Down
40 changes: 19 additions & 21 deletions app/components/backtest/utils.py
Original file line number Diff line number Diff line change
@@ -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.')
12 changes: 10 additions & 2 deletions app/components/ohlc/ohlc.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pandas as pd
from loguru import logger

from components.ohlc.symbol import Symbol

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}'
Expand Down
4 changes: 3 additions & 1 deletion app/components/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/components/positions/position.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/components/strategy/builtins/ta/__init__.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions app/components/strategy/builtins/ta/atr.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 20 additions & 4 deletions app/components/strategy/builtins/ta/correlation.py
Original file line number Diff line number Diff line change
@@ -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))
23 changes: 23 additions & 0 deletions app/components/strategy/builtins/ta/kalman_filter.py
Original file line number Diff line number Diff line change
@@ -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))

6 changes: 6 additions & 0 deletions app/components/strategy/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
78 changes: 72 additions & 6 deletions app/tests/test_backtest.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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),
]
)

Expand All @@ -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

0 comments on commit 9613e17

Please sign in to comment.